@btravstack/outbox
v0.32.0
Published
The transactional outbox relay for @btravstack/core: a poll loop that publishes committed facts in outbox order, a per-tenant claim so replicas take turns rather than race for the same rows, and a Prisma 8 store
Maintainers
Readme
@btravstack/outbox
The transactional outbox relay for
@btravstack/core: a poll loop that publishes committed facts in outbox order, a per-tenant claim so replicas take turns rather than race for the same rows, and a Prisma 8 store.
📖 Documentation · API Reference
pnpm add @btravstack/outbox @btravstack/core @btravstack/config @btravstack/di unthrownFour peer dependencies, plus @prisma/orm-postgres — optional, and needed only
if you compose the Prisma store from @btravstack/outbox/prisma. Node >=22.
A worked example
The application writes its outbox row in the same transaction as the row it describes — that stays its own. What it provides the relay is where the table is, and what publishing a row means:
import { OutboxPublisher, OutboxStore, outbox } from "@btravstack/outbox";
import { prismaOutboxStore } from "@btravstack/outbox/prisma";
const store = Provider(OutboxStore)({
inject: { db: OrderDatabase },
sync: ({ db }) => prismaOutboxStore(db, { schema: "orders" }),
});
const publisher = Provider(OutboxPublisher)({
inject: { broker: Broker },
sync: ({ broker }) => ({
publish: (message) =>
broker.send(`${message.kind}.changed`, {
eventId: message.id,
tenantId: message.tenantId,
id: message.subjectId,
payload: message.payload === null ? null : JSON.parse(message.payload),
}),
}),
});
export const Relay = Module("Relay")({
imports: [OrderPersistenceModule, BrokerModule, outbox()],
provides: [store, publisher],
exports: [],
});Options
| Option | Where | What it is |
| -------------------- | ----------------------------- | -------------------------------------------------------------------------------- |
| OUTBOX_TENANTS | environment, or tenants | the tenants this relay serves, comma-separated — required |
| OUTBOX_POLL_MS | environment, or pollMs | the idle sleep between sweeps (default 200) |
| OUTBOX_MAX_LAG_MS | environment, or maxLagMs | the oldest pending age the outbox health check tolerates (default 60 s) |
| OUTBOX_RETAIN_MS | environment, or retainMs | how long a published row is kept before the relay deletes it (default: kept) |
| OUTBOX_CONCURRENCY | environment, or concurrency | how many tenants claim or prune, each on a pooled connection, at once (4) |
| clock | outbox({ clock }) | what the poll sleeps on and the lag is measured against (default: systemClock) |
| schema, table | prismaOutboxStore(db, {…}) | where the model lives (default public, outboxMessage) |
| columns | prismaOutboxStore(db, {…}) | the physical column names, for a model mapped with @map |
| tenantSetting | prismaOutboxStore(db, {…}) | the setting each tenant's reads are pinned to, for @@rls (app.tenant_id) |
The full table — defaults, semantics, the table's PSL and the reasoning — lives on the reference page, which is this list's one detailed home.
What it decides, and what it does not
It decides that delivery is at-least-once, that one relay publishes a
tenant at a time while its claiming session lives — so replicas take turns
rather than race for a tenant's rows, and a tenant's committed facts go out in
outbox order (which is not commit order: the reference page says what that
means for a subject); a session lost mid-batch, like a crash between a publish
and its mark, re-publishes — that a refused
publish stops its tenant's batch and backs that tenant off, and that a tenant
falling behind is unhealthy on /healthz. A subscriber deduplicates on the
outbox id, which the publisher puts on the wire.
It does not decide what a publish is, which transport it rides or how the
payload is encoded — that is your OutboxPublisher — nor the transaction that
writes the row, which is your adapter's. There is no exactly-once and no
second store; the reasons are in AGENTS.md.
License
MIT
