@siax/outbox (0.1.0)
Installation
@siax:registry=https://git.cloud.siax.io/api/packages/sax3l/npm/npm install @siax/outbox@0.1.0"@siax/outbox": "0.1.0"About this package
@siax/outbox
Shared, zero-dependency PostgreSQL primitives for the SIAX transactional outbox + consumer inbox pattern.
Why this package exists
The estate already contains a real durable outbox in N0D (n0d_job_events):
a lifecycle mutation records an event durably and a separate dispatcher
retries delivery. Other domains describe the same pattern but do not share a
single reusable primitive. This package extracts only the domain-neutral
parts:
- append an immutable event record inside the producer's own transaction;
- lease pending rows across multiple relay replicas with
FOR UPDATE SKIP LOCKED; - retry with backoff and a bounded dead-letter threshold;
- mark a broker-accepted event published;
- dedupe a consumer event with a primary-key inbox claim in the SAME transaction as the consumer's business mutation.
N0D's JobDefinition/JobRun model, callback URL, CloudEvents choices and ACT0 callback dispatcher remain N0D-owned. This package is not a competing event bus or business model.
The guarantee, precisely
Producer: atomic database truth
Use one transaction-scoped query function for both the domain mutation and
outbox.append():
await client.query('BEGIN');
try {
await client.query('UPDATE orders SET status=$2 WHERE id=$1', [orderId, 'confirmed']);
await outbox.append(client.query.bind(client), {
eventId,
tenantId,
subject: 'siax.v1.order.order.confirmed',
eventType: 'siax.v1.order.order.confirmed',
envelope,
});
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
If the transaction rolls back, neither the business mutation nor its outbox record exists. If it commits, both exist.
Relay: at-least-once, intentionally
claimBatch() uses a bounded lease. The relay publishes the envelope to NATS
JetStream/HTTP/etc and then calls markPublished().
A process can crash after the broker accepted the publish but before
markPublished() commits. When the lease expires the event will be published
again. That is correct at-least-once behavior; pretending a database and a
remote broker form one atomic transaction would be false.
Consumer: exactly-once database effect
Use inbox.claim(), the domain mutation and inbox.complete() in ONE database
transaction:
await client.query('BEGIN');
try {
const claimed = await inbox.claim(client.query.bind(client), {
eventId,
tenantId,
eventType,
envelope,
workerId,
});
if (!claimed) {
await client.query('ROLLBACK');
return { replayed: true };
}
await applyDomainMutation(client, envelope);
const completed = await inbox.complete(client.query.bind(client), eventId, workerId);
if (!completed) throw new Error('lost inbox claim');
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
PostgreSQL's primary key on event_id is the concurrency primitive. A racing
insert of the same event waits for the first transaction. If the first commits,
the duplicate does not claim. If the first rolls back, the waiting transaction
can claim and execute.
For a side effect outside that database (for example charging an external
provider), the provider boundary must ALSO be idempotent. This package cannot
turn an external network side effect into an atomic PostgreSQL transaction.
Use @siax/idempotency/provider idempotency keys at that boundary.
API
import {
createSqlOutboxStore,
createSqlInboxStore,
OUTBOX_POSTGRES_DDL,
INBOX_POSTGRES_DDL,
} from '@siax/outbox';
The package never imports pg. Every method accepts a query(sql, params)
function, so a caller can supply either pool.query.bind(pool) for standalone
atomic statements or client.query.bind(client) for a domain transaction.
SQL-injection posture
Payloads are always bound parameters. Table names cannot be bound in
PostgreSQL, so configurable table names are accepted only when they match the
strict lowercase identifier grammar [a-z_][a-z0-9_]*. Anything else is
rejected before SQL is constructed.
What this package does not own
It does not own:
- NATS topology, streams, accounts or credentials;
- event envelope validation (
siax.event/@siax/event-clientowns that); - event naming/capability ownership;
- retry policy for a specific business domain;
- domain transactions;
- workflow orchestration (ACT0 owns durable workflows);
- audit truth (AUD0 owns audit/evidence).
Required production verification
The zero-dependency unit suite checks SQL shape, validation and result semantics. Before the first estate consumer is called production-ready, canonical Gitea must additionally run a real Postgres integration test proving:
- domain write + append commit/rollback atomically;
- two relay workers never simultaneously lease the same row;
- lease expiry makes an abandoned row reclaimable;
- publish-before-ack crash causes safe redelivery;
- two consumer transactions receiving one event execute the database effect once;
- a consumer rollback permits a later redelivery to claim and execute.
Those are integration facts, not claims made by this mirror package alone.
Dependencies
Development Dependencies
| ID | Version |
|---|---|
| @siax/pg-pool-factory | workspace:* |