SIAX Technology (sax3l)

@siax/outbox (0.1.0)

Published 2026-09-16 09:35:08 +00:00 by admin

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-client owns 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:

  1. domain write + append commit/rollback atomically;
  2. two relay workers never simultaneously lease the same row;
  3. lease expiry makes an abandoned row reclaimable;
  4. publish-before-ack crash causes safe redelivery;
  5. two consumer transactions receiving one event execute the database effect once;
  6. 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:*
Details
npm
2026-09-16 09:35:08 +00:00
3021
UNLICENSED
latest
8.0 KiB
Assets (1)
Versions (1) View all
0.1.0 2026-09-16