@eventferry/postgres 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -197,6 +197,22 @@ var PostgresStore = class {
197
197
  [recordId, code, nextRetryAt]
198
198
  );
199
199
  }
200
+ /**
201
+ * Re-queue a record to `failed` with the given `retryAt` **without
202
+ * bumping attempts** — used by the relay for backpressure handling.
203
+ * Also clears `claimed_at` so the reaper does not race the row.
204
+ */
205
+ async requeue(recordId, retryAt) {
206
+ const failed = import_core2.OUTBOX_STATUS_CODE.failed;
207
+ await this.pool.query(
208
+ `UPDATE ${this.table}
209
+ SET status = ${failed},
210
+ claimed_at = NULL,
211
+ next_retry_at = $2
212
+ WHERE id = $1`,
213
+ [recordId, retryAt]
214
+ );
215
+ }
200
216
  /**
201
217
  * Delete `done` rows whose `processed_at` is older than `olderThanMs`, in
202
218
  * batches (to avoid long locks / table bloat). Returns the total deleted.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/store.ts","../src/ident.ts","../src/row.ts","../src/migrations.ts","../src/notify-waker.ts","../src/streaming-relay.ts"],"sourcesContent":["export * from \"./store.js\";\nexport * from \"./migrations.js\";\nexport * from \"./notify-waker.js\";\nexport * from \"./streaming-relay.js\";\n","import type {\n OutboxMessageInput,\n OutboxRecord,\n OutboxStore,\n Tracing,\n} from \"@eventferry/core\";\nimport { OUTBOX_STATUS_CODE } from \"@eventferry/core\";\nimport { assertIdent } from \"./ident.js\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/**\n * Minimal query interface satisfied by both `pg.Pool` and `pg.PoolClient`,\n * so `enqueue` can run inside a caller-supplied transaction.\n */\nexport interface Queryable {\n query(\n queryText: string,\n values?: unknown[],\n ): Promise<{ rows: Record<string, unknown>[] }>;\n}\n\nexport interface PostgresStoreOptions {\n /** A connected pg Pool used by the relay for claim/ack queries. */\n pool: Queryable;\n /** Outbox table name. Default \"outbox\". */\n table?: string;\n /**\n * Visibility timeout (ms) after which a row stuck in `processing` is\n * reclaimable by any relay. Guards against permanently-orphaned rows when\n * a relay crashes between claiming and acking. MUST be comfortably larger\n * than the worst-case publish latency, otherwise a slow-but-alive relay's\n * in-flight rows get reclaimed and re-published (a duplicate). Default 60s.\n */\n claimTimeoutMs?: number;\n /**\n * When true, `claimBatch` never claims `pending`(0) rows — only `failed`(3)\n * (retry due) and timed-out `processing`(1). Used by the streaming relay,\n * where pending rows are owned by the WAL stream and the claim loop only\n * drains failures. Default false (claims pending too).\n */\n claimFailedOnly?: boolean;\n /**\n * Optional trace-context propagator. When set, `enqueue` captures the active\n * W3C trace context into the row's headers, so it rides along to the published\n * message and the consumer can continue the trace. See {@link Tracing}.\n */\n tracing?: Tracing;\n}\n\nexport interface PurgeDoneOptions {\n /** Delete `done` rows whose processed_at is older than this (milliseconds). */\n olderThanMs: number;\n /** Rows deleted per batch. Default 1000. */\n batchSize?: number;\n /** Optional cap on the total rows deleted in this call. */\n maxRows?: number;\n}\n\nconst DEFAULT_CLAIM_TIMEOUT_MS = 60_000;\n\nexport class PostgresStore implements OutboxStore {\n private readonly pool: Queryable;\n private readonly table: string;\n private readonly claimTimeoutMs: number;\n private readonly claimFailedOnly: boolean;\n private readonly tracing: Tracing | null;\n\n constructor(opts: PostgresStoreOptions) {\n this.pool = opts.pool;\n this.table = assertIdent(opts.table ?? \"outbox\");\n this.claimTimeoutMs = opts.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS;\n this.claimFailedOnly = opts.claimFailedOnly ?? false;\n this.tracing = opts.tracing ?? null;\n }\n\n /**\n * Insert a message into the outbox. MUST be called with the same\n * transaction (`tx`) that persists the business state, so the event\n * and the state commit atomically.\n *\n * @returns the generated message id.\n */\n async enqueue(\n tx: Queryable,\n msg: OutboxMessageInput & { traceId?: string },\n ): Promise<string> {\n // Copy (never mutate the caller's object) and let tracing capture the\n // active W3C context into the headers, so it rides along to the broker.\n const headers = { ...(msg.headers ?? {}) };\n this.tracing?.inject(headers);\n\n const text = `\n INSERT INTO ${this.table}\n (message_id, aggregate_type, aggregate_id, topic, \"key\", payload, headers, trace_id, status)\n VALUES\n (COALESCE($1, gen_random_uuid()), $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, 0)\n RETURNING message_id\n `;\n const res = await tx.query(text, [\n msg.messageId ?? null,\n msg.aggregateType,\n msg.aggregateId,\n msg.topic,\n msg.key ?? null,\n JSON.stringify(msg.payload),\n JSON.stringify(headers),\n msg.traceId ?? null,\n ]);\n return res.rows[0]?.message_id as string;\n }\n\n /**\n * Claim up to `batchSize` due rows using FOR UPDATE SKIP LOCKED so that\n * concurrent relay instances never contend for the same rows. Claimed\n * rows are flipped to status=processing(1) and stamped with claimed_at,\n * atomically in the same CTE.\n *\n * Strict per-aggregate ordering is enforced by only claiming a row when it\n * is the *head* of its aggregate — i.e. no earlier row (lower id) for the\n * same aggregate_id is still unfinished (pending/processing/failed). This\n * guarantees:\n * - at most one in-flight row per aggregate at any time (across relays),\n * - a failed row blocks its successors until it is done or dead,\n * - within a single batch every aggregate appears at most once, so the\n * broker can never observe two same-key messages out of order.\n * `done`(2) and `dead`(4) are terminal and stop blocking successors, so a\n * poison message routed to the DLQ does not stall its aggregate forever.\n *\n * A row stuck in `processing` longer than claimTimeoutMs is treated as due\n * again (its owning relay is presumed dead), which is what keeps a crash\n * between claim and ack from orphaning messages.\n *\n * $1 = batchSize, $2 = claimTimeoutMs.\n */\n async claimBatch(batchSize: number): Promise<OutboxRecord[]> {\n const processing = OUTBOX_STATUS_CODE.processing;\n // Streaming mode (claimFailedOnly) leaves pending(0) rows to the WAL stream.\n const pendingClause = this.claimFailedOnly ? \"\" : \"o.status = 0\\n OR \";\n const text = `\n WITH due AS (\n SELECT o.id\n FROM ${this.table} o\n WHERE (\n ${pendingClause}(o.status = 3 AND (o.next_retry_at IS NULL OR o.next_retry_at <= now()))\n OR (o.status = 1 AND o.claimed_at IS NOT NULL\n AND o.claimed_at <= now() - ($2 * interval '1 millisecond'))\n )\n AND NOT EXISTS (\n SELECT 1\n FROM ${this.table} earlier\n WHERE earlier.aggregate_id = o.aggregate_id\n AND earlier.id < o.id\n AND earlier.status IN (0, 1, 3)\n )\n ORDER BY o.id\n LIMIT $1\n FOR UPDATE SKIP LOCKED\n )\n UPDATE ${this.table} AS o\n SET status = ${processing}, claimed_at = now()\n FROM due\n WHERE o.id = due.id\n RETURNING o.id, o.message_id, o.aggregate_type, o.aggregate_id,\n o.topic, o.\"key\", o.payload, o.headers, o.trace_id,\n o.status, o.attempts, o.next_retry_at, o.created_at, o.processed_at\n `;\n const res = await this.pool.query(text, [batchSize, this.claimTimeoutMs]);\n return (res.rows as unknown as OutboxRow[]).map(rowToRecord);\n }\n\n async markDone(recordIds: string[]): Promise<void> {\n if (recordIds.length === 0) return;\n const done = OUTBOX_STATUS_CODE.done;\n await this.pool.query(\n `UPDATE ${this.table}\n SET status = ${done}, processed_at = now()\n WHERE id = ANY($1::bigint[])`,\n [recordIds],\n );\n }\n\n async markFailed(\n recordId: string,\n nextRetryAt: Date | null,\n status: \"failed\" | \"dead\",\n ): Promise<void> {\n const code = OUTBOX_STATUS_CODE[status];\n await this.pool.query(\n `UPDATE ${this.table}\n SET status = $2,\n attempts = attempts + 1,\n next_retry_at = $3\n WHERE id = $1`,\n [recordId, code, nextRetryAt],\n );\n }\n\n /**\n * Delete `done` rows whose `processed_at` is older than `olderThanMs`, in\n * batches (to avoid long locks / table bloat). Returns the total deleted.\n * Run it periodically (e.g. from a cron) — there is no built-in scheduler.\n * Note: only `done`(2) rows are purged; `dead` rows are left in place.\n */\n async purgeDone(opts: PurgeDoneOptions): Promise<number> {\n const done = OUTBOX_STATUS_CODE.done;\n const batchSize = opts.batchSize ?? 1000;\n const text = `\n DELETE FROM ${this.table}\n WHERE id IN (\n SELECT id FROM ${this.table}\n WHERE status = ${done}\n AND processed_at IS NOT NULL\n AND processed_at < now() - ($1 * interval '1 millisecond')\n ORDER BY id\n LIMIT $2\n )\n RETURNING id\n `;\n let total = 0;\n for (;;) {\n const res = await this.pool.query(text, [opts.olderThanMs, batchSize]);\n total += res.rows.length;\n if (res.rows.length < batchSize) break;\n if (opts.maxRows !== undefined && total >= opts.maxRows) break;\n }\n return total;\n }\n}\n","/**\n * Allow only safe SQL identifier characters. Used wherever a user-supplied name\n * (table, channel) is interpolated into SQL, to prevent injection.\n */\nexport function assertIdent(name: string): string {\n if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {\n throw new Error(\n `Invalid identifier \"${name}\": must match /^[a-zA-Z_][a-zA-Z0-9_]*$/`,\n );\n }\n return name;\n}\n","import type { OutboxRecord, OutboxStatus } from \"@eventferry/core\";\nimport { OUTBOX_STATUS_FROM_CODE } from \"@eventferry/core\";\n\n/** Raw outbox row shape as read from Postgres (snake_case columns). */\nexport interface OutboxRow {\n id: string;\n message_id: string;\n aggregate_type: string;\n aggregate_id: string;\n topic: string;\n key: string | null;\n payload: unknown;\n headers: Record<string, string> | null;\n trace_id: string | null;\n status: number;\n attempts: number;\n next_retry_at: Date | null;\n created_at: Date;\n processed_at: Date | null;\n}\n\n/** Map a raw DB row to the broker-agnostic core record. */\nexport function rowToRecord(row: OutboxRow): OutboxRecord {\n const status: OutboxStatus = OUTBOX_STATUS_FROM_CODE[row.status] ?? \"pending\";\n return {\n id: String(row.id),\n messageId: row.message_id,\n topic: row.topic,\n aggregateType: row.aggregate_type,\n aggregateId: row.aggregate_id,\n key: row.key,\n payload: row.payload,\n headers: row.headers ?? {},\n traceId: row.trace_id,\n status,\n attempts: row.attempts,\n nextRetryAt: row.next_retry_at,\n createdAt: row.created_at,\n processedAt: row.processed_at,\n };\n}\n","import { assertIdent } from \"./ident.js\";\n\n/**\n * Generate the DDL for the outbox table, parameterized by table name.\n * Kept as a string template (not a file read) so it works regardless of\n * how the package is bundled or where it's installed.\n */\nexport function createMigrationSql(tableName = \"outbox\"): string {\n const t = assertIdent(tableName);\n return `\nCREATE TABLE IF NOT EXISTS ${t} (\n id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n message_id UUID NOT NULL DEFAULT gen_random_uuid(),\n aggregate_type VARCHAR(255) NOT NULL,\n aggregate_id VARCHAR(255) NOT NULL,\n topic VARCHAR(255) NOT NULL,\n \"key\" TEXT,\n payload JSONB NOT NULL,\n headers JSONB NOT NULL DEFAULT '{}'::jsonb,\n trace_id VARCHAR(64),\n status SMALLINT NOT NULL DEFAULT 0,\n attempts INT NOT NULL DEFAULT 0,\n next_retry_at TIMESTAMPTZ,\n claimed_at TIMESTAMPTZ,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n processed_at TIMESTAMPTZ\n);\n\n-- Upgrade path for tables created before claimed_at / the reaper existed.\nALTER TABLE ${t} ADD COLUMN IF NOT EXISTS claimed_at TIMESTAMPTZ;\n\n-- The v0.1 index was keyed on created_at and excluded processing rows; the\n-- ordered, reaper-aware claim needs id-ordered scans over unfinished rows.\nDROP INDEX IF EXISTS idx_${t}_due;\n\n-- Rides the claim's id-ordered scan over unfinished rows. processing(1) is\n-- included so the reaper can find timed-out rows. done(2)/dead(4) are excluded\n-- so the index stays tiny even with millions of completed rows.\nCREATE INDEX IF NOT EXISTS idx_${t}_ready\n ON ${t} (id)\n WHERE status IN (0, 1, 3);\n\n-- Supports the per-aggregate head check (NOT EXISTS earlier unfinished row).\nCREATE INDEX IF NOT EXISTS idx_${t}_agg_order\n ON ${t} (aggregate_id, id)\n WHERE status IN (0, 1, 3);\n\n-- Dedup / idempotency lookups by message_id.\nCREATE UNIQUE INDEX IF NOT EXISTS uq_${t}_message_id\n ON ${t} (message_id);\n`.trim();\n}\n\n/**\n * Generate the trigger that fires a Postgres NOTIFY whenever a row is inserted\n * into the outbox table, so a {@link PostgresNotifyWaker} can wake the relay the\n * instant a row commits. The payload is empty — the relay re-claims from the\n * table, so it needs only the \"something committed\" edge (and this sidesteps the\n * 8 KB NOTIFY limit). NOTIFY is transactional: it is delivered only on commit.\n *\n * @param tableName outbox table the trigger is attached to. Default \"outbox\".\n * @param channel LISTEN/NOTIFY channel; must match the waker's. Default \"outbox\".\n */\nexport function createNotifyTriggerSql(\n tableName = \"outbox\",\n channel = \"outbox\",\n): string {\n const t = assertIdent(tableName);\n const ch = assertIdent(channel);\n return `\nCREATE OR REPLACE FUNCTION ${t}_notify() RETURNS trigger AS $$\nBEGIN\n PERFORM pg_notify('${ch}', '');\n RETURN NULL;\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS ${t}_notify_trg ON ${t};\nCREATE TRIGGER ${t}_notify_trg\n AFTER INSERT ON ${t}\n FOR EACH STATEMENT EXECUTE FUNCTION ${t}_notify();\n`.trim();\n}\n\n/**\n * Generate an idempotent publication on the outbox table for the streaming relay\n * (logical replication). Only INSERTs are published — the outbox is append-only\n * from the relay's perspective. Requires `wal_level = logical` on the server.\n *\n * @param tableName outbox table to capture. Default \"outbox\".\n * @param publication publication name; must match the streaming relay's. Default \"outbox_pub\".\n */\nexport function createPublicationSql(\n tableName = \"outbox\",\n publication = \"outbox_pub\",\n): string {\n const t = assertIdent(tableName);\n const p = assertIdent(publication);\n return `\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = '${p}') THEN\n CREATE PUBLICATION ${p} FOR TABLE ${t} WITH (publish = 'insert');\n END IF;\nEND\n$$;\n`.trim();\n}\n\n/**\n * Optional index that speeds up `purgeDone` on high-volume tables. The default\n * partial indexes exclude done(2) rows, so the retention scan is unindexed; this\n * covers exactly the rows it deletes. Skip it unless retention scans are slow —\n * it adds write/space overhead on the bulk (done) segment.\n */\nexport function createRetentionIndexSql(tableName = \"outbox\"): string {\n const t = assertIdent(tableName);\n return `\nCREATE INDEX IF NOT EXISTS idx_${t}_done_processed\n ON ${t} (processed_at)\n WHERE status = 2;\n`.trim();\n}\n","import type { Logger, Waker } from \"@eventferry/core\";\nimport { NoopLogger } from \"@eventferry/core\";\nimport { assertIdent } from \"./ident.js\";\n\n/**\n * Structural subset of `pg.Client` the waker needs. A `pg.Client` satisfies this\n * directly. A pooled client must NOT be used — a LISTENing connection is held for\n * the relay's lifetime and would never return to the pool.\n */\nexport interface NotificationConnection {\n connect(): Promise<void>;\n query(sql: string): Promise<unknown>;\n on(\n event: \"notification\",\n listener: (msg: { channel: string; payload?: string }) => void,\n ): unknown;\n on(event: \"error\", listener: (err: Error) => void): unknown;\n on(event: \"end\", listener: () => void): unknown;\n end(): Promise<void>;\n}\n\nexport interface PostgresNotifyWakerOptions {\n /**\n * Creates a fresh, unconnected notification connection (e.g.\n * `() => new pg.Client(config)`). Called on every (re)connect.\n */\n connect: () => NotificationConnection;\n /** LISTEN channel; must match `createNotifyTriggerSql`. Default \"outbox\". */\n channel?: string;\n /** Base reconnect backoff in ms after a dropped connection. Default 1000. */\n reconnectDelayMs?: number;\n /** Optional logger. Defaults to a no-op. */\n logger?: Logger;\n}\n\n/**\n * A {@link Waker} backed by Postgres LISTEN/NOTIFY. Holds a dedicated connection\n * that LISTENs on a channel; each notification wakes the relay. On a dropped\n * connection it reconnects with a fixed backoff — meanwhile the relay's polling\n * covers the gap, so no notification gap can lose an event.\n */\nexport class PostgresNotifyWaker implements Waker {\n private readonly connectFactory: () => NotificationConnection;\n private readonly channel: string;\n private readonly reconnectDelayMs: number;\n private readonly log: Logger;\n\n private conn: NotificationConnection | null = null;\n private onWake: (() => void) | null = null;\n private stopped = false;\n\n constructor(opts: PostgresNotifyWakerOptions) {\n this.connectFactory = opts.connect;\n this.channel = assertIdent(opts.channel ?? \"outbox\");\n this.reconnectDelayMs = opts.reconnectDelayMs ?? 1000;\n this.log = opts.logger ?? new NoopLogger();\n }\n\n async start(onWake: () => void): Promise<void> {\n this.onWake = onWake;\n this.stopped = false;\n await this.connectAndListen();\n }\n\n async stop(): Promise<void> {\n this.stopped = true;\n const conn = this.conn;\n this.conn = null;\n if (!conn) return;\n try {\n await conn.query(`UNLISTEN ${this.channel}`);\n } catch {\n // best effort — the connection may already be gone\n }\n try {\n await conn.end();\n } catch {\n // best effort\n }\n }\n\n private async connectAndListen(): Promise<void> {\n if (this.stopped) return;\n const conn = this.connectFactory();\n this.conn = conn;\n conn.on(\"notification\", () => this.onWake?.());\n conn.on(\"error\", (err) => this.scheduleReconnect(err));\n conn.on(\"end\", () => this.scheduleReconnect(new Error(\"connection ended\")));\n await conn.connect();\n await conn.query(`LISTEN ${this.channel}`);\n }\n\n private scheduleReconnect(err: Error): void {\n if (this.stopped) return;\n this.conn = null;\n this.log.warn(\"notify waker connection lost; reconnecting\", {\n error: err.message,\n });\n setTimeout(() => {\n if (this.stopped) return;\n this.connectAndListen().catch((e) => {\n const error = e instanceof Error ? e : new Error(String(e));\n this.log.error(\"notify waker reconnect failed\", {\n error: error.message,\n });\n this.scheduleReconnect(error);\n });\n }, this.reconnectDelayMs);\n }\n}\n","import {\n buildPublishable,\n ConsoleLogger,\n JsonSerializer,\n Relay,\n} from \"@eventferry/core\";\nimport type {\n DlqConfig,\n Logger,\n OutboxStore,\n Publisher,\n RelayHooks,\n RetryConfig,\n Serializer,\n} from \"@eventferry/core\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/** Connection + slot/publication settings for the WAL stream. */\nexport interface ReplicationConfig {\n /** A replication-capable connection (e.g. a connection string). */\n connectionString: string;\n /** Persistent logical replication slot name. Created if absent. */\n slot: string;\n /** Publication name (see createPublicationSql). */\n publication: string;\n /** Outbox table to capture. Default \"outbox\". */\n table?: string;\n}\n\nexport interface PostgresStreamingRelayOptions {\n /** Outbox store. Construct with `{ claimFailedOnly: true }` so the internal\n * retry loop only drains failures (pending rows are owned by the stream). */\n store: OutboxStore;\n publisher: Publisher;\n replication: ReplicationConfig;\n retry?: Partial<RetryConfig>;\n dlq?: DlqConfig;\n serializer?: Serializer;\n logger?: Logger;\n hooks?: RelayHooks;\n /** Poll interval (ms) for the internal failed-row retry loop. Default 5000. */\n failedPollIntervalMs?: number;\n /** Mark happy-path rows done (status=2) after publish. Default true. */\n markPublished?: boolean;\n /** Max rows published per chunk within a committed transaction. Default 100. */\n batchSize?: number;\n}\n\n/** A decoded INSERT on the outbox table, with the WAL position it occurred at. */\nexport interface DecodedInsert {\n readonly lsn: string;\n readonly row: OutboxRow;\n}\n\nexport interface ReplicationStreamHandlers {\n onInsert: (insert: DecodedInsert) => void;\n onCommit: (lsn: string) => void | Promise<void>;\n onError: (err: Error) => void;\n}\n\n/** The WAL source the streaming relay consumes. Implemented over\n * pg-logical-replication; swappable for tests. */\nexport interface ReplicationStream {\n start(handlers: ReplicationStreamHandlers): Promise<void>;\n acknowledge(lsn: string): Promise<void>;\n stop(): Promise<void>;\n}\n\n/**\n * Publishes outbox events straight from the Postgres WAL (logical replication),\n * with no claim query on the happy path. Failures are demoted to `failed` and\n * drained by an internal claim-based retry loop (the core `Relay` over a\n * `claimFailedOnly` store), reusing the existing backoff / DLQ / dead handling.\n *\n * At-least-once: a commit's LSN is acknowledged only after its batch's side\n * effects commit, so a crash re-streams and re-publishes (a duplicate idempotent\n * consumers absorb). Ordering is best-effort per aggregate — a retried failure\n * lands after later same-aggregate rows; use the polling relay for strict order.\n */\nexport class PostgresStreamingRelay {\n private readonly store: OutboxStore;\n private readonly publisher: Publisher;\n private readonly serializer: Serializer;\n private readonly log: Logger;\n private readonly hooks: RelayHooks;\n private readonly replication: ReplicationConfig;\n private readonly markPublished: boolean;\n private readonly batchSize: number;\n private readonly retryRelay: Relay;\n\n private stream: ReplicationStream | null = null;\n private buffer: DecodedInsert[] = [];\n private tail: Promise<void> = Promise.resolve();\n private running = false;\n\n constructor(opts: PostgresStreamingRelayOptions) {\n this.store = opts.store;\n this.publisher = opts.publisher;\n this.serializer = opts.serializer ?? new JsonSerializer();\n this.log = opts.logger ?? new ConsoleLogger();\n this.hooks = opts.hooks ?? {};\n this.replication = opts.replication;\n this.markPublished = opts.markPublished ?? true;\n this.batchSize = opts.batchSize ?? 100;\n\n // Internal failed-only retry loop: owns publisher connect/disconnect and\n // reuses the engine's retry/backoff/DLQ/dead/reaper for demoted rows.\n this.retryRelay = new Relay({\n store: opts.store,\n publisher: opts.publisher,\n retry: opts.retry,\n dlq: opts.dlq,\n serializer: this.serializer,\n logger: opts.logger,\n hooks: opts.hooks,\n pollIntervalMs: opts.failedPollIntervalMs ?? 5000,\n });\n }\n\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n await this.retryRelay.start(); // connects publisher + starts the failed-only loop\n this.stream = this.createReplicationStream();\n await this.stream.start({\n onInsert: (insert) => {\n this.buffer.push(insert);\n },\n onCommit: (lsn) => {\n const batch = this.buffer;\n this.buffer = [];\n this.tail = this.tail.then(() => this.processBatch(batch, lsn));\n return this.tail;\n },\n onError: (err) => {\n this.log.error(\"replication stream error\", { error: err.message });\n this.hooks.onError?.(err);\n },\n });\n this.log.info(\"streaming relay started\", { slot: this.replication.slot });\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n await this.stream?.stop();\n await this.tail; // drain any in-flight batch\n await this.retryRelay.stop(); // stops the loop + disconnects publisher\n this.log.info(\"streaming relay stopped\");\n }\n\n /** Build the WAL stream. Overridable as a test seam. */\n protected createReplicationStream(): ReplicationStream {\n return createPgLogicalStream(this.replication);\n }\n\n private async processBatch(\n batch: DecodedInsert[],\n lsn: string,\n ): Promise<void> {\n try {\n for (let i = 0; i < batch.length; i += this.batchSize) {\n await this.publishChunk(batch.slice(i, i + this.batchSize));\n }\n await this.stream?.acknowledge(lsn);\n } catch (err) {\n // A failure here (e.g. a DB write while demoting) must NOT advance the\n // LSN: the commit is re-streamed and re-published on reconnect.\n const error = err instanceof Error ? err : new Error(String(err));\n this.log.error(\"streaming batch failed; not acknowledging\", {\n error: error.message,\n });\n this.hooks.onError?.(error);\n }\n }\n\n private async publishChunk(chunk: DecodedInsert[]): Promise<void> {\n const records = chunk.map((c) => rowToRecord(c.row));\n const messages = await Promise.all(\n records.map((r) => buildPublishable(r, this.serializer)),\n );\n const results = await this.publisher.publish(messages);\n const byId = new Map(records.map((r) => [r.id, r]));\n\n const succeeded: string[] = [];\n for (const result of results) {\n const record = byId.get(result.recordId);\n if (!record) continue;\n if (result.ok) {\n succeeded.push(record.id);\n this.hooks.onPublished?.(result);\n } else {\n // Demote to failed (due now); the internal retry loop owns backoff/DLQ.\n await this.store.markFailed(record.id, null, \"failed\");\n this.hooks.onFailed?.(\n record,\n result.error ?? new Error(\"publish failed\"),\n true,\n );\n }\n }\n if (this.markPublished && succeeded.length > 0) {\n await this.store.markDone(succeeded);\n }\n }\n}\n\n/** Real WAL stream backed by pg-logical-replication + pgoutput. Covered by the\n * integration suite (it needs a live Postgres); unit tests use a fake stream. */\nfunction createPgLogicalStream(config: ReplicationConfig): ReplicationStream {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let service: any = null;\n let stopped = false;\n const table = config.table ?? \"outbox\";\n\n return {\n async start(handlers: ReplicationStreamHandlers): Promise<void> {\n await ensureSlot(config.connectionString, config.slot);\n const mod = await importPgLogical();\n service = new mod.LogicalReplicationService(\n { connectionString: config.connectionString },\n { acknowledge: { auto: false, timeoutSeconds: 0 } },\n );\n const plugin = new mod.PgoutputPlugin({\n protoVersion: 2,\n publicationNames: [config.publication],\n });\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n service.on(\"data\", (lsn: string, msg: any) => {\n if (msg?.tag === \"insert\" && msg?.relation?.name === table) {\n handlers.onInsert({ lsn, row: normalizeRow(msg.new) });\n } else if (msg?.tag === \"commit\") {\n void handlers.onCommit(lsn);\n }\n });\n service.on(\"error\", (err: Error) => {\n if (stopped) return;\n handlers.onError(err);\n setTimeout(() => {\n if (stopped) return;\n service\n .subscribe(plugin, config.slot)\n .catch((e: Error) => handlers.onError(e));\n }, 1000);\n });\n service\n .subscribe(plugin, config.slot)\n .catch((e: Error) => {\n if (!stopped) handlers.onError(e);\n });\n },\n async acknowledge(lsn: string): Promise<void> {\n if (service) await service.acknowledge(lsn);\n },\n async stop(): Promise<void> {\n stopped = true;\n if (service) await service.stop();\n },\n };\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction normalizeRow(raw: any): OutboxRow {\n const json = (v: unknown) =>\n typeof v === \"string\" ? JSON.parse(v) : v;\n return {\n id: String(raw.id),\n message_id: raw.message_id,\n aggregate_type: raw.aggregate_type,\n aggregate_id: raw.aggregate_id,\n topic: raw.topic,\n key: raw.key ?? null,\n payload: json(raw.payload),\n headers: (json(raw.headers) as Record<string, string>) ?? {},\n trace_id: raw.trace_id ?? null,\n status: Number(raw.status),\n attempts: Number(raw.attempts),\n next_retry_at: raw.next_retry_at ? new Date(raw.next_retry_at) : null,\n created_at: raw.created_at ? new Date(raw.created_at) : new Date(0),\n processed_at: raw.processed_at ? new Date(raw.processed_at) : null,\n };\n}\n\n/** Create the persistent pgoutput slot if it does not already exist. */\nasync function ensureSlot(\n connectionString: string,\n slot: string,\n): Promise<void> {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const pg: any = await import(\"pg\");\n const client = new pg.Client({ connectionString });\n await client.connect();\n try {\n const existing = await client.query(\n \"SELECT 1 FROM pg_replication_slots WHERE slot_name = $1\",\n [slot],\n );\n if (existing.rows.length === 0) {\n await client.query(\n \"SELECT pg_create_logical_replication_slot($1, 'pgoutput')\",\n [slot],\n );\n }\n } finally {\n await client.end();\n }\n}\n\nasync function importPgLogical(): Promise<{\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n LogicalReplicationService: any;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n PgoutputPlugin: any;\n}> {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return (await import(\"pg-logical-replication\")) as any;\n } catch {\n throw new Error(\n 'Streaming relay needs the \"pg-logical-replication\" package. Run: npm i pg-logical-replication',\n );\n }\n}\n"],"mappings":"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","names":["import_core","import_core","import_core"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/store.ts","../src/ident.ts","../src/row.ts","../src/migrations.ts","../src/notify-waker.ts","../src/streaming-relay.ts"],"sourcesContent":["export * from \"./store.js\";\nexport * from \"./migrations.js\";\nexport * from \"./notify-waker.js\";\nexport * from \"./streaming-relay.js\";\n","import type {\n OutboxMessageInput,\n OutboxRecord,\n OutboxStore,\n Tracing,\n} from \"@eventferry/core\";\nimport { OUTBOX_STATUS_CODE } from \"@eventferry/core\";\nimport { assertIdent } from \"./ident.js\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/**\n * Minimal query interface satisfied by both `pg.Pool` and `pg.PoolClient`,\n * so `enqueue` can run inside a caller-supplied transaction.\n */\nexport interface Queryable {\n query(\n queryText: string,\n values?: unknown[],\n ): Promise<{ rows: Record<string, unknown>[] }>;\n}\n\nexport interface PostgresStoreOptions {\n /** A connected pg Pool used by the relay for claim/ack queries. */\n pool: Queryable;\n /** Outbox table name. Default \"outbox\". */\n table?: string;\n /**\n * Visibility timeout (ms) after which a row stuck in `processing` is\n * reclaimable by any relay. Guards against permanently-orphaned rows when\n * a relay crashes between claiming and acking. MUST be comfortably larger\n * than the worst-case publish latency, otherwise a slow-but-alive relay's\n * in-flight rows get reclaimed and re-published (a duplicate). Default 60s.\n */\n claimTimeoutMs?: number;\n /**\n * When true, `claimBatch` never claims `pending`(0) rows — only `failed`(3)\n * (retry due) and timed-out `processing`(1). Used by the streaming relay,\n * where pending rows are owned by the WAL stream and the claim loop only\n * drains failures. Default false (claims pending too).\n */\n claimFailedOnly?: boolean;\n /**\n * Optional trace-context propagator. When set, `enqueue` captures the active\n * W3C trace context into the row's headers, so it rides along to the published\n * message and the consumer can continue the trace. See {@link Tracing}.\n */\n tracing?: Tracing;\n}\n\nexport interface PurgeDoneOptions {\n /** Delete `done` rows whose processed_at is older than this (milliseconds). */\n olderThanMs: number;\n /** Rows deleted per batch. Default 1000. */\n batchSize?: number;\n /** Optional cap on the total rows deleted in this call. */\n maxRows?: number;\n}\n\nconst DEFAULT_CLAIM_TIMEOUT_MS = 60_000;\n\nexport class PostgresStore implements OutboxStore {\n private readonly pool: Queryable;\n private readonly table: string;\n private readonly claimTimeoutMs: number;\n private readonly claimFailedOnly: boolean;\n private readonly tracing: Tracing | null;\n\n constructor(opts: PostgresStoreOptions) {\n this.pool = opts.pool;\n this.table = assertIdent(opts.table ?? \"outbox\");\n this.claimTimeoutMs = opts.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS;\n this.claimFailedOnly = opts.claimFailedOnly ?? false;\n this.tracing = opts.tracing ?? null;\n }\n\n /**\n * Insert a message into the outbox. MUST be called with the same\n * transaction (`tx`) that persists the business state, so the event\n * and the state commit atomically.\n *\n * @returns the generated message id.\n */\n async enqueue(\n tx: Queryable,\n msg: OutboxMessageInput & { traceId?: string },\n ): Promise<string> {\n // Copy (never mutate the caller's object) and let tracing capture the\n // active W3C context into the headers, so it rides along to the broker.\n const headers = { ...(msg.headers ?? {}) };\n this.tracing?.inject(headers);\n\n const text = `\n INSERT INTO ${this.table}\n (message_id, aggregate_type, aggregate_id, topic, \"key\", payload, headers, trace_id, status)\n VALUES\n (COALESCE($1, gen_random_uuid()), $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, 0)\n RETURNING message_id\n `;\n const res = await tx.query(text, [\n msg.messageId ?? null,\n msg.aggregateType,\n msg.aggregateId,\n msg.topic,\n msg.key ?? null,\n JSON.stringify(msg.payload),\n JSON.stringify(headers),\n msg.traceId ?? null,\n ]);\n return res.rows[0]?.message_id as string;\n }\n\n /**\n * Claim up to `batchSize` due rows using FOR UPDATE SKIP LOCKED so that\n * concurrent relay instances never contend for the same rows. Claimed\n * rows are flipped to status=processing(1) and stamped with claimed_at,\n * atomically in the same CTE.\n *\n * Strict per-aggregate ordering is enforced by only claiming a row when it\n * is the *head* of its aggregate — i.e. no earlier row (lower id) for the\n * same aggregate_id is still unfinished (pending/processing/failed). This\n * guarantees:\n * - at most one in-flight row per aggregate at any time (across relays),\n * - a failed row blocks its successors until it is done or dead,\n * - within a single batch every aggregate appears at most once, so the\n * broker can never observe two same-key messages out of order.\n * `done`(2) and `dead`(4) are terminal and stop blocking successors, so a\n * poison message routed to the DLQ does not stall its aggregate forever.\n *\n * A row stuck in `processing` longer than claimTimeoutMs is treated as due\n * again (its owning relay is presumed dead), which is what keeps a crash\n * between claim and ack from orphaning messages.\n *\n * $1 = batchSize, $2 = claimTimeoutMs.\n */\n async claimBatch(batchSize: number): Promise<OutboxRecord[]> {\n const processing = OUTBOX_STATUS_CODE.processing;\n // Streaming mode (claimFailedOnly) leaves pending(0) rows to the WAL stream.\n const pendingClause = this.claimFailedOnly ? \"\" : \"o.status = 0\\n OR \";\n const text = `\n WITH due AS (\n SELECT o.id\n FROM ${this.table} o\n WHERE (\n ${pendingClause}(o.status = 3 AND (o.next_retry_at IS NULL OR o.next_retry_at <= now()))\n OR (o.status = 1 AND o.claimed_at IS NOT NULL\n AND o.claimed_at <= now() - ($2 * interval '1 millisecond'))\n )\n AND NOT EXISTS (\n SELECT 1\n FROM ${this.table} earlier\n WHERE earlier.aggregate_id = o.aggregate_id\n AND earlier.id < o.id\n AND earlier.status IN (0, 1, 3)\n )\n ORDER BY o.id\n LIMIT $1\n FOR UPDATE SKIP LOCKED\n )\n UPDATE ${this.table} AS o\n SET status = ${processing}, claimed_at = now()\n FROM due\n WHERE o.id = due.id\n RETURNING o.id, o.message_id, o.aggregate_type, o.aggregate_id,\n o.topic, o.\"key\", o.payload, o.headers, o.trace_id,\n o.status, o.attempts, o.next_retry_at, o.created_at, o.processed_at\n `;\n const res = await this.pool.query(text, [batchSize, this.claimTimeoutMs]);\n return (res.rows as unknown as OutboxRow[]).map(rowToRecord);\n }\n\n async markDone(recordIds: string[]): Promise<void> {\n if (recordIds.length === 0) return;\n const done = OUTBOX_STATUS_CODE.done;\n await this.pool.query(\n `UPDATE ${this.table}\n SET status = ${done}, processed_at = now()\n WHERE id = ANY($1::bigint[])`,\n [recordIds],\n );\n }\n\n async markFailed(\n recordId: string,\n nextRetryAt: Date | null,\n status: \"failed\" | \"dead\",\n ): Promise<void> {\n const code = OUTBOX_STATUS_CODE[status];\n await this.pool.query(\n `UPDATE ${this.table}\n SET status = $2,\n attempts = attempts + 1,\n next_retry_at = $3\n WHERE id = $1`,\n [recordId, code, nextRetryAt],\n );\n }\n\n /**\n * Re-queue a record to `failed` with the given `retryAt` **without\n * bumping attempts** — used by the relay for backpressure handling.\n * Also clears `claimed_at` so the reaper does not race the row.\n */\n async requeue(recordId: string, retryAt: Date): Promise<void> {\n const failed = OUTBOX_STATUS_CODE.failed;\n await this.pool.query(\n `UPDATE ${this.table}\n SET status = ${failed},\n claimed_at = NULL,\n next_retry_at = $2\n WHERE id = $1`,\n [recordId, retryAt],\n );\n }\n\n /**\n * Delete `done` rows whose `processed_at` is older than `olderThanMs`, in\n * batches (to avoid long locks / table bloat). Returns the total deleted.\n * Run it periodically (e.g. from a cron) — there is no built-in scheduler.\n * Note: only `done`(2) rows are purged; `dead` rows are left in place.\n */\n async purgeDone(opts: PurgeDoneOptions): Promise<number> {\n const done = OUTBOX_STATUS_CODE.done;\n const batchSize = opts.batchSize ?? 1000;\n const text = `\n DELETE FROM ${this.table}\n WHERE id IN (\n SELECT id FROM ${this.table}\n WHERE status = ${done}\n AND processed_at IS NOT NULL\n AND processed_at < now() - ($1 * interval '1 millisecond')\n ORDER BY id\n LIMIT $2\n )\n RETURNING id\n `;\n let total = 0;\n for (;;) {\n const res = await this.pool.query(text, [opts.olderThanMs, batchSize]);\n total += res.rows.length;\n if (res.rows.length < batchSize) break;\n if (opts.maxRows !== undefined && total >= opts.maxRows) break;\n }\n return total;\n }\n}\n","/**\n * Allow only safe SQL identifier characters. Used wherever a user-supplied name\n * (table, channel) is interpolated into SQL, to prevent injection.\n */\nexport function assertIdent(name: string): string {\n if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {\n throw new Error(\n `Invalid identifier \"${name}\": must match /^[a-zA-Z_][a-zA-Z0-9_]*$/`,\n );\n }\n return name;\n}\n","import type { OutboxRecord, OutboxStatus } from \"@eventferry/core\";\nimport { OUTBOX_STATUS_FROM_CODE } from \"@eventferry/core\";\n\n/** Raw outbox row shape as read from Postgres (snake_case columns). */\nexport interface OutboxRow {\n id: string;\n message_id: string;\n aggregate_type: string;\n aggregate_id: string;\n topic: string;\n key: string | null;\n payload: unknown;\n headers: Record<string, string> | null;\n trace_id: string | null;\n status: number;\n attempts: number;\n next_retry_at: Date | null;\n created_at: Date;\n processed_at: Date | null;\n}\n\n/** Map a raw DB row to the broker-agnostic core record. */\nexport function rowToRecord(row: OutboxRow): OutboxRecord {\n const status: OutboxStatus = OUTBOX_STATUS_FROM_CODE[row.status] ?? \"pending\";\n return {\n id: String(row.id),\n messageId: row.message_id,\n topic: row.topic,\n aggregateType: row.aggregate_type,\n aggregateId: row.aggregate_id,\n key: row.key,\n payload: row.payload,\n headers: row.headers ?? {},\n traceId: row.trace_id,\n status,\n attempts: row.attempts,\n nextRetryAt: row.next_retry_at,\n createdAt: row.created_at,\n processedAt: row.processed_at,\n };\n}\n","import { assertIdent } from \"./ident.js\";\n\n/**\n * Generate the DDL for the outbox table, parameterized by table name.\n * Kept as a string template (not a file read) so it works regardless of\n * how the package is bundled or where it's installed.\n */\nexport function createMigrationSql(tableName = \"outbox\"): string {\n const t = assertIdent(tableName);\n return `\nCREATE TABLE IF NOT EXISTS ${t} (\n id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n message_id UUID NOT NULL DEFAULT gen_random_uuid(),\n aggregate_type VARCHAR(255) NOT NULL,\n aggregate_id VARCHAR(255) NOT NULL,\n topic VARCHAR(255) NOT NULL,\n \"key\" TEXT,\n payload JSONB NOT NULL,\n headers JSONB NOT NULL DEFAULT '{}'::jsonb,\n trace_id VARCHAR(64),\n status SMALLINT NOT NULL DEFAULT 0,\n attempts INT NOT NULL DEFAULT 0,\n next_retry_at TIMESTAMPTZ,\n claimed_at TIMESTAMPTZ,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n processed_at TIMESTAMPTZ\n);\n\n-- Upgrade path for tables created before claimed_at / the reaper existed.\nALTER TABLE ${t} ADD COLUMN IF NOT EXISTS claimed_at TIMESTAMPTZ;\n\n-- The v0.1 index was keyed on created_at and excluded processing rows; the\n-- ordered, reaper-aware claim needs id-ordered scans over unfinished rows.\nDROP INDEX IF EXISTS idx_${t}_due;\n\n-- Rides the claim's id-ordered scan over unfinished rows. processing(1) is\n-- included so the reaper can find timed-out rows. done(2)/dead(4) are excluded\n-- so the index stays tiny even with millions of completed rows.\nCREATE INDEX IF NOT EXISTS idx_${t}_ready\n ON ${t} (id)\n WHERE status IN (0, 1, 3);\n\n-- Supports the per-aggregate head check (NOT EXISTS earlier unfinished row).\nCREATE INDEX IF NOT EXISTS idx_${t}_agg_order\n ON ${t} (aggregate_id, id)\n WHERE status IN (0, 1, 3);\n\n-- Dedup / idempotency lookups by message_id.\nCREATE UNIQUE INDEX IF NOT EXISTS uq_${t}_message_id\n ON ${t} (message_id);\n`.trim();\n}\n\n/**\n * Generate the trigger that fires a Postgres NOTIFY whenever a row is inserted\n * into the outbox table, so a {@link PostgresNotifyWaker} can wake the relay the\n * instant a row commits. The payload is empty — the relay re-claims from the\n * table, so it needs only the \"something committed\" edge (and this sidesteps the\n * 8 KB NOTIFY limit). NOTIFY is transactional: it is delivered only on commit.\n *\n * @param tableName outbox table the trigger is attached to. Default \"outbox\".\n * @param channel LISTEN/NOTIFY channel; must match the waker's. Default \"outbox\".\n */\nexport function createNotifyTriggerSql(\n tableName = \"outbox\",\n channel = \"outbox\",\n): string {\n const t = assertIdent(tableName);\n const ch = assertIdent(channel);\n return `\nCREATE OR REPLACE FUNCTION ${t}_notify() RETURNS trigger AS $$\nBEGIN\n PERFORM pg_notify('${ch}', '');\n RETURN NULL;\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS ${t}_notify_trg ON ${t};\nCREATE TRIGGER ${t}_notify_trg\n AFTER INSERT ON ${t}\n FOR EACH STATEMENT EXECUTE FUNCTION ${t}_notify();\n`.trim();\n}\n\n/**\n * Generate an idempotent publication on the outbox table for the streaming relay\n * (logical replication). Only INSERTs are published — the outbox is append-only\n * from the relay's perspective. Requires `wal_level = logical` on the server.\n *\n * @param tableName outbox table to capture. Default \"outbox\".\n * @param publication publication name; must match the streaming relay's. Default \"outbox_pub\".\n */\nexport function createPublicationSql(\n tableName = \"outbox\",\n publication = \"outbox_pub\",\n): string {\n const t = assertIdent(tableName);\n const p = assertIdent(publication);\n return `\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = '${p}') THEN\n CREATE PUBLICATION ${p} FOR TABLE ${t} WITH (publish = 'insert');\n END IF;\nEND\n$$;\n`.trim();\n}\n\n/**\n * Optional index that speeds up `purgeDone` on high-volume tables. The default\n * partial indexes exclude done(2) rows, so the retention scan is unindexed; this\n * covers exactly the rows it deletes. Skip it unless retention scans are slow —\n * it adds write/space overhead on the bulk (done) segment.\n */\nexport function createRetentionIndexSql(tableName = \"outbox\"): string {\n const t = assertIdent(tableName);\n return `\nCREATE INDEX IF NOT EXISTS idx_${t}_done_processed\n ON ${t} (processed_at)\n WHERE status = 2;\n`.trim();\n}\n","import type { Logger, Waker } from \"@eventferry/core\";\nimport { NoopLogger } from \"@eventferry/core\";\nimport { assertIdent } from \"./ident.js\";\n\n/**\n * Structural subset of `pg.Client` the waker needs. A `pg.Client` satisfies this\n * directly. A pooled client must NOT be used — a LISTENing connection is held for\n * the relay's lifetime and would never return to the pool.\n */\nexport interface NotificationConnection {\n connect(): Promise<void>;\n query(sql: string): Promise<unknown>;\n on(\n event: \"notification\",\n listener: (msg: { channel: string; payload?: string }) => void,\n ): unknown;\n on(event: \"error\", listener: (err: Error) => void): unknown;\n on(event: \"end\", listener: () => void): unknown;\n end(): Promise<void>;\n}\n\nexport interface PostgresNotifyWakerOptions {\n /**\n * Creates a fresh, unconnected notification connection (e.g.\n * `() => new pg.Client(config)`). Called on every (re)connect.\n */\n connect: () => NotificationConnection;\n /** LISTEN channel; must match `createNotifyTriggerSql`. Default \"outbox\". */\n channel?: string;\n /** Base reconnect backoff in ms after a dropped connection. Default 1000. */\n reconnectDelayMs?: number;\n /** Optional logger. Defaults to a no-op. */\n logger?: Logger;\n}\n\n/**\n * A {@link Waker} backed by Postgres LISTEN/NOTIFY. Holds a dedicated connection\n * that LISTENs on a channel; each notification wakes the relay. On a dropped\n * connection it reconnects with a fixed backoff — meanwhile the relay's polling\n * covers the gap, so no notification gap can lose an event.\n */\nexport class PostgresNotifyWaker implements Waker {\n private readonly connectFactory: () => NotificationConnection;\n private readonly channel: string;\n private readonly reconnectDelayMs: number;\n private readonly log: Logger;\n\n private conn: NotificationConnection | null = null;\n private onWake: (() => void) | null = null;\n private stopped = false;\n\n constructor(opts: PostgresNotifyWakerOptions) {\n this.connectFactory = opts.connect;\n this.channel = assertIdent(opts.channel ?? \"outbox\");\n this.reconnectDelayMs = opts.reconnectDelayMs ?? 1000;\n this.log = opts.logger ?? new NoopLogger();\n }\n\n async start(onWake: () => void): Promise<void> {\n this.onWake = onWake;\n this.stopped = false;\n await this.connectAndListen();\n }\n\n async stop(): Promise<void> {\n this.stopped = true;\n const conn = this.conn;\n this.conn = null;\n if (!conn) return;\n try {\n await conn.query(`UNLISTEN ${this.channel}`);\n } catch {\n // best effort — the connection may already be gone\n }\n try {\n await conn.end();\n } catch {\n // best effort\n }\n }\n\n private async connectAndListen(): Promise<void> {\n if (this.stopped) return;\n const conn = this.connectFactory();\n this.conn = conn;\n conn.on(\"notification\", () => this.onWake?.());\n conn.on(\"error\", (err) => this.scheduleReconnect(err));\n conn.on(\"end\", () => this.scheduleReconnect(new Error(\"connection ended\")));\n await conn.connect();\n await conn.query(`LISTEN ${this.channel}`);\n }\n\n private scheduleReconnect(err: Error): void {\n if (this.stopped) return;\n this.conn = null;\n this.log.warn(\"notify waker connection lost; reconnecting\", {\n error: err.message,\n });\n setTimeout(() => {\n if (this.stopped) return;\n this.connectAndListen().catch((e) => {\n const error = e instanceof Error ? e : new Error(String(e));\n this.log.error(\"notify waker reconnect failed\", {\n error: error.message,\n });\n this.scheduleReconnect(error);\n });\n }, this.reconnectDelayMs);\n }\n}\n","import {\n buildPublishable,\n ConsoleLogger,\n JsonSerializer,\n Relay,\n} from \"@eventferry/core\";\nimport type {\n DlqConfig,\n Logger,\n OutboxStore,\n Publisher,\n RelayHooks,\n RetryConfig,\n Serializer,\n} from \"@eventferry/core\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/** Connection + slot/publication settings for the WAL stream. */\nexport interface ReplicationConfig {\n /** A replication-capable connection (e.g. a connection string). */\n connectionString: string;\n /** Persistent logical replication slot name. Created if absent. */\n slot: string;\n /** Publication name (see createPublicationSql). */\n publication: string;\n /** Outbox table to capture. Default \"outbox\". */\n table?: string;\n}\n\nexport interface PostgresStreamingRelayOptions {\n /** Outbox store. Construct with `{ claimFailedOnly: true }` so the internal\n * retry loop only drains failures (pending rows are owned by the stream). */\n store: OutboxStore;\n publisher: Publisher;\n replication: ReplicationConfig;\n retry?: Partial<RetryConfig>;\n dlq?: DlqConfig;\n serializer?: Serializer;\n logger?: Logger;\n hooks?: RelayHooks;\n /** Poll interval (ms) for the internal failed-row retry loop. Default 5000. */\n failedPollIntervalMs?: number;\n /** Mark happy-path rows done (status=2) after publish. Default true. */\n markPublished?: boolean;\n /** Max rows published per chunk within a committed transaction. Default 100. */\n batchSize?: number;\n}\n\n/** A decoded INSERT on the outbox table, with the WAL position it occurred at. */\nexport interface DecodedInsert {\n readonly lsn: string;\n readonly row: OutboxRow;\n}\n\nexport interface ReplicationStreamHandlers {\n onInsert: (insert: DecodedInsert) => void;\n onCommit: (lsn: string) => void | Promise<void>;\n onError: (err: Error) => void;\n}\n\n/** The WAL source the streaming relay consumes. Implemented over\n * pg-logical-replication; swappable for tests. */\nexport interface ReplicationStream {\n start(handlers: ReplicationStreamHandlers): Promise<void>;\n acknowledge(lsn: string): Promise<void>;\n stop(): Promise<void>;\n}\n\n/**\n * Publishes outbox events straight from the Postgres WAL (logical replication),\n * with no claim query on the happy path. Failures are demoted to `failed` and\n * drained by an internal claim-based retry loop (the core `Relay` over a\n * `claimFailedOnly` store), reusing the existing backoff / DLQ / dead handling.\n *\n * At-least-once: a commit's LSN is acknowledged only after its batch's side\n * effects commit, so a crash re-streams and re-publishes (a duplicate idempotent\n * consumers absorb). Ordering is best-effort per aggregate — a retried failure\n * lands after later same-aggregate rows; use the polling relay for strict order.\n */\nexport class PostgresStreamingRelay {\n private readonly store: OutboxStore;\n private readonly publisher: Publisher;\n private readonly serializer: Serializer;\n private readonly log: Logger;\n private readonly hooks: RelayHooks;\n private readonly replication: ReplicationConfig;\n private readonly markPublished: boolean;\n private readonly batchSize: number;\n private readonly retryRelay: Relay;\n\n private stream: ReplicationStream | null = null;\n private buffer: DecodedInsert[] = [];\n private tail: Promise<void> = Promise.resolve();\n private running = false;\n\n constructor(opts: PostgresStreamingRelayOptions) {\n this.store = opts.store;\n this.publisher = opts.publisher;\n this.serializer = opts.serializer ?? new JsonSerializer();\n this.log = opts.logger ?? new ConsoleLogger();\n this.hooks = opts.hooks ?? {};\n this.replication = opts.replication;\n this.markPublished = opts.markPublished ?? true;\n this.batchSize = opts.batchSize ?? 100;\n\n // Internal failed-only retry loop: owns publisher connect/disconnect and\n // reuses the engine's retry/backoff/DLQ/dead/reaper for demoted rows.\n this.retryRelay = new Relay({\n store: opts.store,\n publisher: opts.publisher,\n retry: opts.retry,\n dlq: opts.dlq,\n serializer: this.serializer,\n logger: opts.logger,\n hooks: opts.hooks,\n pollIntervalMs: opts.failedPollIntervalMs ?? 5000,\n });\n }\n\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n await this.retryRelay.start(); // connects publisher + starts the failed-only loop\n this.stream = this.createReplicationStream();\n await this.stream.start({\n onInsert: (insert) => {\n this.buffer.push(insert);\n },\n onCommit: (lsn) => {\n const batch = this.buffer;\n this.buffer = [];\n this.tail = this.tail.then(() => this.processBatch(batch, lsn));\n return this.tail;\n },\n onError: (err) => {\n this.log.error(\"replication stream error\", { error: err.message });\n this.hooks.onError?.(err);\n },\n });\n this.log.info(\"streaming relay started\", { slot: this.replication.slot });\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n await this.stream?.stop();\n await this.tail; // drain any in-flight batch\n await this.retryRelay.stop(); // stops the loop + disconnects publisher\n this.log.info(\"streaming relay stopped\");\n }\n\n /** Build the WAL stream. Overridable as a test seam. */\n protected createReplicationStream(): ReplicationStream {\n return createPgLogicalStream(this.replication);\n }\n\n private async processBatch(\n batch: DecodedInsert[],\n lsn: string,\n ): Promise<void> {\n try {\n for (let i = 0; i < batch.length; i += this.batchSize) {\n await this.publishChunk(batch.slice(i, i + this.batchSize));\n }\n await this.stream?.acknowledge(lsn);\n } catch (err) {\n // A failure here (e.g. a DB write while demoting) must NOT advance the\n // LSN: the commit is re-streamed and re-published on reconnect.\n const error = err instanceof Error ? err : new Error(String(err));\n this.log.error(\"streaming batch failed; not acknowledging\", {\n error: error.message,\n });\n this.hooks.onError?.(error);\n }\n }\n\n private async publishChunk(chunk: DecodedInsert[]): Promise<void> {\n const records = chunk.map((c) => rowToRecord(c.row));\n const messages = await Promise.all(\n records.map((r) => buildPublishable(r, this.serializer)),\n );\n const results = await this.publisher.publish(messages);\n const byId = new Map(records.map((r) => [r.id, r]));\n\n const succeeded: string[] = [];\n for (const result of results) {\n const record = byId.get(result.recordId);\n if (!record) continue;\n if (result.ok) {\n succeeded.push(record.id);\n this.hooks.onPublished?.(result);\n } else {\n // Demote to failed (due now); the internal retry loop owns backoff/DLQ.\n await this.store.markFailed(record.id, null, \"failed\");\n this.hooks.onFailed?.(\n record,\n result.error ?? new Error(\"publish failed\"),\n true,\n );\n }\n }\n if (this.markPublished && succeeded.length > 0) {\n await this.store.markDone(succeeded);\n }\n }\n}\n\n/** Real WAL stream backed by pg-logical-replication + pgoutput. Covered by the\n * integration suite (it needs a live Postgres); unit tests use a fake stream. */\nfunction createPgLogicalStream(config: ReplicationConfig): ReplicationStream {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let service: any = null;\n let stopped = false;\n const table = config.table ?? \"outbox\";\n\n return {\n async start(handlers: ReplicationStreamHandlers): Promise<void> {\n await ensureSlot(config.connectionString, config.slot);\n const mod = await importPgLogical();\n service = new mod.LogicalReplicationService(\n { connectionString: config.connectionString },\n { acknowledge: { auto: false, timeoutSeconds: 0 } },\n );\n const plugin = new mod.PgoutputPlugin({\n protoVersion: 2,\n publicationNames: [config.publication],\n });\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n service.on(\"data\", (lsn: string, msg: any) => {\n if (msg?.tag === \"insert\" && msg?.relation?.name === table) {\n handlers.onInsert({ lsn, row: normalizeRow(msg.new) });\n } else if (msg?.tag === \"commit\") {\n void handlers.onCommit(lsn);\n }\n });\n service.on(\"error\", (err: Error) => {\n if (stopped) return;\n handlers.onError(err);\n setTimeout(() => {\n if (stopped) return;\n service\n .subscribe(plugin, config.slot)\n .catch((e: Error) => handlers.onError(e));\n }, 1000);\n });\n service\n .subscribe(plugin, config.slot)\n .catch((e: Error) => {\n if (!stopped) handlers.onError(e);\n });\n },\n async acknowledge(lsn: string): Promise<void> {\n if (service) await service.acknowledge(lsn);\n },\n async stop(): Promise<void> {\n stopped = true;\n if (service) await service.stop();\n },\n };\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction normalizeRow(raw: any): OutboxRow {\n const json = (v: unknown) =>\n typeof v === \"string\" ? JSON.parse(v) : v;\n return {\n id: String(raw.id),\n message_id: raw.message_id,\n aggregate_type: raw.aggregate_type,\n aggregate_id: raw.aggregate_id,\n topic: raw.topic,\n key: raw.key ?? null,\n payload: json(raw.payload),\n headers: (json(raw.headers) as Record<string, string>) ?? {},\n trace_id: raw.trace_id ?? null,\n status: Number(raw.status),\n attempts: Number(raw.attempts),\n next_retry_at: raw.next_retry_at ? new Date(raw.next_retry_at) : null,\n created_at: raw.created_at ? new Date(raw.created_at) : new Date(0),\n processed_at: raw.processed_at ? new Date(raw.processed_at) : null,\n };\n}\n\n/** Create the persistent pgoutput slot if it does not already exist. */\nasync function ensureSlot(\n connectionString: string,\n slot: string,\n): Promise<void> {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const pg: any = await import(\"pg\");\n const client = new pg.Client({ connectionString });\n await client.connect();\n try {\n const existing = await client.query(\n \"SELECT 1 FROM pg_replication_slots WHERE slot_name = $1\",\n [slot],\n );\n if (existing.rows.length === 0) {\n await client.query(\n \"SELECT pg_create_logical_replication_slot($1, 'pgoutput')\",\n [slot],\n );\n }\n } finally {\n await client.end();\n }\n}\n\nasync function importPgLogical(): Promise<{\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n LogicalReplicationService: any;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n PgoutputPlugin: any;\n}> {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return (await import(\"pg-logical-replication\")) as any;\n } catch {\n throw new Error(\n 'Streaming relay needs the \"pg-logical-replication\" package. Run: npm i pg-logical-replication',\n );\n }\n}\n"],"mappings":"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","names":["import_core","import_core","import_core"]}
package/dist/index.d.cts CHANGED
@@ -88,6 +88,12 @@ declare class PostgresStore implements OutboxStore {
88
88
  claimBatch(batchSize: number): Promise<OutboxRecord[]>;
89
89
  markDone(recordIds: string[]): Promise<void>;
90
90
  markFailed(recordId: string, nextRetryAt: Date | null, status: "failed" | "dead"): Promise<void>;
91
+ /**
92
+ * Re-queue a record to `failed` with the given `retryAt` **without
93
+ * bumping attempts** — used by the relay for backpressure handling.
94
+ * Also clears `claimed_at` so the reaper does not race the row.
95
+ */
96
+ requeue(recordId: string, retryAt: Date): Promise<void>;
91
97
  /**
92
98
  * Delete `done` rows whose `processed_at` is older than `olderThanMs`, in
93
99
  * batches (to avoid long locks / table bloat). Returns the total deleted.
package/dist/index.d.ts CHANGED
@@ -88,6 +88,12 @@ declare class PostgresStore implements OutboxStore {
88
88
  claimBatch(batchSize: number): Promise<OutboxRecord[]>;
89
89
  markDone(recordIds: string[]): Promise<void>;
90
90
  markFailed(recordId: string, nextRetryAt: Date | null, status: "failed" | "dead"): Promise<void>;
91
+ /**
92
+ * Re-queue a record to `failed` with the given `retryAt` **without
93
+ * bumping attempts** — used by the relay for backpressure handling.
94
+ * Also clears `claimed_at` so the reaper does not race the row.
95
+ */
96
+ requeue(recordId: string, retryAt: Date): Promise<void>;
91
97
  /**
92
98
  * Delete `done` rows whose `processed_at` is older than `olderThanMs`, in
93
99
  * batches (to avoid long locks / table bloat). Returns the total deleted.
package/dist/index.js CHANGED
@@ -153,6 +153,22 @@ var PostgresStore = class {
153
153
  [recordId, code, nextRetryAt]
154
154
  );
155
155
  }
156
+ /**
157
+ * Re-queue a record to `failed` with the given `retryAt` **without
158
+ * bumping attempts** — used by the relay for backpressure handling.
159
+ * Also clears `claimed_at` so the reaper does not race the row.
160
+ */
161
+ async requeue(recordId, retryAt) {
162
+ const failed = OUTBOX_STATUS_CODE.failed;
163
+ await this.pool.query(
164
+ `UPDATE ${this.table}
165
+ SET status = ${failed},
166
+ claimed_at = NULL,
167
+ next_retry_at = $2
168
+ WHERE id = $1`,
169
+ [recordId, retryAt]
170
+ );
171
+ }
156
172
  /**
157
173
  * Delete `done` rows whose `processed_at` is older than `olderThanMs`, in
158
174
  * batches (to avoid long locks / table bloat). Returns the total deleted.
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/store.ts","../src/row.ts","../src/notify-waker.ts","../src/streaming-relay.ts"],"sourcesContent":["import type {\n OutboxMessageInput,\n OutboxRecord,\n OutboxStore,\n Tracing,\n} from \"@eventferry/core\";\nimport { OUTBOX_STATUS_CODE } from \"@eventferry/core\";\nimport { assertIdent } from \"./ident.js\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/**\n * Minimal query interface satisfied by both `pg.Pool` and `pg.PoolClient`,\n * so `enqueue` can run inside a caller-supplied transaction.\n */\nexport interface Queryable {\n query(\n queryText: string,\n values?: unknown[],\n ): Promise<{ rows: Record<string, unknown>[] }>;\n}\n\nexport interface PostgresStoreOptions {\n /** A connected pg Pool used by the relay for claim/ack queries. */\n pool: Queryable;\n /** Outbox table name. Default \"outbox\". */\n table?: string;\n /**\n * Visibility timeout (ms) after which a row stuck in `processing` is\n * reclaimable by any relay. Guards against permanently-orphaned rows when\n * a relay crashes between claiming and acking. MUST be comfortably larger\n * than the worst-case publish latency, otherwise a slow-but-alive relay's\n * in-flight rows get reclaimed and re-published (a duplicate). Default 60s.\n */\n claimTimeoutMs?: number;\n /**\n * When true, `claimBatch` never claims `pending`(0) rows — only `failed`(3)\n * (retry due) and timed-out `processing`(1). Used by the streaming relay,\n * where pending rows are owned by the WAL stream and the claim loop only\n * drains failures. Default false (claims pending too).\n */\n claimFailedOnly?: boolean;\n /**\n * Optional trace-context propagator. When set, `enqueue` captures the active\n * W3C trace context into the row's headers, so it rides along to the published\n * message and the consumer can continue the trace. See {@link Tracing}.\n */\n tracing?: Tracing;\n}\n\nexport interface PurgeDoneOptions {\n /** Delete `done` rows whose processed_at is older than this (milliseconds). */\n olderThanMs: number;\n /** Rows deleted per batch. Default 1000. */\n batchSize?: number;\n /** Optional cap on the total rows deleted in this call. */\n maxRows?: number;\n}\n\nconst DEFAULT_CLAIM_TIMEOUT_MS = 60_000;\n\nexport class PostgresStore implements OutboxStore {\n private readonly pool: Queryable;\n private readonly table: string;\n private readonly claimTimeoutMs: number;\n private readonly claimFailedOnly: boolean;\n private readonly tracing: Tracing | null;\n\n constructor(opts: PostgresStoreOptions) {\n this.pool = opts.pool;\n this.table = assertIdent(opts.table ?? \"outbox\");\n this.claimTimeoutMs = opts.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS;\n this.claimFailedOnly = opts.claimFailedOnly ?? false;\n this.tracing = opts.tracing ?? null;\n }\n\n /**\n * Insert a message into the outbox. MUST be called with the same\n * transaction (`tx`) that persists the business state, so the event\n * and the state commit atomically.\n *\n * @returns the generated message id.\n */\n async enqueue(\n tx: Queryable,\n msg: OutboxMessageInput & { traceId?: string },\n ): Promise<string> {\n // Copy (never mutate the caller's object) and let tracing capture the\n // active W3C context into the headers, so it rides along to the broker.\n const headers = { ...(msg.headers ?? {}) };\n this.tracing?.inject(headers);\n\n const text = `\n INSERT INTO ${this.table}\n (message_id, aggregate_type, aggregate_id, topic, \"key\", payload, headers, trace_id, status)\n VALUES\n (COALESCE($1, gen_random_uuid()), $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, 0)\n RETURNING message_id\n `;\n const res = await tx.query(text, [\n msg.messageId ?? null,\n msg.aggregateType,\n msg.aggregateId,\n msg.topic,\n msg.key ?? null,\n JSON.stringify(msg.payload),\n JSON.stringify(headers),\n msg.traceId ?? null,\n ]);\n return res.rows[0]?.message_id as string;\n }\n\n /**\n * Claim up to `batchSize` due rows using FOR UPDATE SKIP LOCKED so that\n * concurrent relay instances never contend for the same rows. Claimed\n * rows are flipped to status=processing(1) and stamped with claimed_at,\n * atomically in the same CTE.\n *\n * Strict per-aggregate ordering is enforced by only claiming a row when it\n * is the *head* of its aggregate — i.e. no earlier row (lower id) for the\n * same aggregate_id is still unfinished (pending/processing/failed). This\n * guarantees:\n * - at most one in-flight row per aggregate at any time (across relays),\n * - a failed row blocks its successors until it is done or dead,\n * - within a single batch every aggregate appears at most once, so the\n * broker can never observe two same-key messages out of order.\n * `done`(2) and `dead`(4) are terminal and stop blocking successors, so a\n * poison message routed to the DLQ does not stall its aggregate forever.\n *\n * A row stuck in `processing` longer than claimTimeoutMs is treated as due\n * again (its owning relay is presumed dead), which is what keeps a crash\n * between claim and ack from orphaning messages.\n *\n * $1 = batchSize, $2 = claimTimeoutMs.\n */\n async claimBatch(batchSize: number): Promise<OutboxRecord[]> {\n const processing = OUTBOX_STATUS_CODE.processing;\n // Streaming mode (claimFailedOnly) leaves pending(0) rows to the WAL stream.\n const pendingClause = this.claimFailedOnly ? \"\" : \"o.status = 0\\n OR \";\n const text = `\n WITH due AS (\n SELECT o.id\n FROM ${this.table} o\n WHERE (\n ${pendingClause}(o.status = 3 AND (o.next_retry_at IS NULL OR o.next_retry_at <= now()))\n OR (o.status = 1 AND o.claimed_at IS NOT NULL\n AND o.claimed_at <= now() - ($2 * interval '1 millisecond'))\n )\n AND NOT EXISTS (\n SELECT 1\n FROM ${this.table} earlier\n WHERE earlier.aggregate_id = o.aggregate_id\n AND earlier.id < o.id\n AND earlier.status IN (0, 1, 3)\n )\n ORDER BY o.id\n LIMIT $1\n FOR UPDATE SKIP LOCKED\n )\n UPDATE ${this.table} AS o\n SET status = ${processing}, claimed_at = now()\n FROM due\n WHERE o.id = due.id\n RETURNING o.id, o.message_id, o.aggregate_type, o.aggregate_id,\n o.topic, o.\"key\", o.payload, o.headers, o.trace_id,\n o.status, o.attempts, o.next_retry_at, o.created_at, o.processed_at\n `;\n const res = await this.pool.query(text, [batchSize, this.claimTimeoutMs]);\n return (res.rows as unknown as OutboxRow[]).map(rowToRecord);\n }\n\n async markDone(recordIds: string[]): Promise<void> {\n if (recordIds.length === 0) return;\n const done = OUTBOX_STATUS_CODE.done;\n await this.pool.query(\n `UPDATE ${this.table}\n SET status = ${done}, processed_at = now()\n WHERE id = ANY($1::bigint[])`,\n [recordIds],\n );\n }\n\n async markFailed(\n recordId: string,\n nextRetryAt: Date | null,\n status: \"failed\" | \"dead\",\n ): Promise<void> {\n const code = OUTBOX_STATUS_CODE[status];\n await this.pool.query(\n `UPDATE ${this.table}\n SET status = $2,\n attempts = attempts + 1,\n next_retry_at = $3\n WHERE id = $1`,\n [recordId, code, nextRetryAt],\n );\n }\n\n /**\n * Delete `done` rows whose `processed_at` is older than `olderThanMs`, in\n * batches (to avoid long locks / table bloat). Returns the total deleted.\n * Run it periodically (e.g. from a cron) — there is no built-in scheduler.\n * Note: only `done`(2) rows are purged; `dead` rows are left in place.\n */\n async purgeDone(opts: PurgeDoneOptions): Promise<number> {\n const done = OUTBOX_STATUS_CODE.done;\n const batchSize = opts.batchSize ?? 1000;\n const text = `\n DELETE FROM ${this.table}\n WHERE id IN (\n SELECT id FROM ${this.table}\n WHERE status = ${done}\n AND processed_at IS NOT NULL\n AND processed_at < now() - ($1 * interval '1 millisecond')\n ORDER BY id\n LIMIT $2\n )\n RETURNING id\n `;\n let total = 0;\n for (;;) {\n const res = await this.pool.query(text, [opts.olderThanMs, batchSize]);\n total += res.rows.length;\n if (res.rows.length < batchSize) break;\n if (opts.maxRows !== undefined && total >= opts.maxRows) break;\n }\n return total;\n }\n}\n","import type { OutboxRecord, OutboxStatus } from \"@eventferry/core\";\nimport { OUTBOX_STATUS_FROM_CODE } from \"@eventferry/core\";\n\n/** Raw outbox row shape as read from Postgres (snake_case columns). */\nexport interface OutboxRow {\n id: string;\n message_id: string;\n aggregate_type: string;\n aggregate_id: string;\n topic: string;\n key: string | null;\n payload: unknown;\n headers: Record<string, string> | null;\n trace_id: string | null;\n status: number;\n attempts: number;\n next_retry_at: Date | null;\n created_at: Date;\n processed_at: Date | null;\n}\n\n/** Map a raw DB row to the broker-agnostic core record. */\nexport function rowToRecord(row: OutboxRow): OutboxRecord {\n const status: OutboxStatus = OUTBOX_STATUS_FROM_CODE[row.status] ?? \"pending\";\n return {\n id: String(row.id),\n messageId: row.message_id,\n topic: row.topic,\n aggregateType: row.aggregate_type,\n aggregateId: row.aggregate_id,\n key: row.key,\n payload: row.payload,\n headers: row.headers ?? {},\n traceId: row.trace_id,\n status,\n attempts: row.attempts,\n nextRetryAt: row.next_retry_at,\n createdAt: row.created_at,\n processedAt: row.processed_at,\n };\n}\n","import type { Logger, Waker } from \"@eventferry/core\";\nimport { NoopLogger } from \"@eventferry/core\";\nimport { assertIdent } from \"./ident.js\";\n\n/**\n * Structural subset of `pg.Client` the waker needs. A `pg.Client` satisfies this\n * directly. A pooled client must NOT be used — a LISTENing connection is held for\n * the relay's lifetime and would never return to the pool.\n */\nexport interface NotificationConnection {\n connect(): Promise<void>;\n query(sql: string): Promise<unknown>;\n on(\n event: \"notification\",\n listener: (msg: { channel: string; payload?: string }) => void,\n ): unknown;\n on(event: \"error\", listener: (err: Error) => void): unknown;\n on(event: \"end\", listener: () => void): unknown;\n end(): Promise<void>;\n}\n\nexport interface PostgresNotifyWakerOptions {\n /**\n * Creates a fresh, unconnected notification connection (e.g.\n * `() => new pg.Client(config)`). Called on every (re)connect.\n */\n connect: () => NotificationConnection;\n /** LISTEN channel; must match `createNotifyTriggerSql`. Default \"outbox\". */\n channel?: string;\n /** Base reconnect backoff in ms after a dropped connection. Default 1000. */\n reconnectDelayMs?: number;\n /** Optional logger. Defaults to a no-op. */\n logger?: Logger;\n}\n\n/**\n * A {@link Waker} backed by Postgres LISTEN/NOTIFY. Holds a dedicated connection\n * that LISTENs on a channel; each notification wakes the relay. On a dropped\n * connection it reconnects with a fixed backoff — meanwhile the relay's polling\n * covers the gap, so no notification gap can lose an event.\n */\nexport class PostgresNotifyWaker implements Waker {\n private readonly connectFactory: () => NotificationConnection;\n private readonly channel: string;\n private readonly reconnectDelayMs: number;\n private readonly log: Logger;\n\n private conn: NotificationConnection | null = null;\n private onWake: (() => void) | null = null;\n private stopped = false;\n\n constructor(opts: PostgresNotifyWakerOptions) {\n this.connectFactory = opts.connect;\n this.channel = assertIdent(opts.channel ?? \"outbox\");\n this.reconnectDelayMs = opts.reconnectDelayMs ?? 1000;\n this.log = opts.logger ?? new NoopLogger();\n }\n\n async start(onWake: () => void): Promise<void> {\n this.onWake = onWake;\n this.stopped = false;\n await this.connectAndListen();\n }\n\n async stop(): Promise<void> {\n this.stopped = true;\n const conn = this.conn;\n this.conn = null;\n if (!conn) return;\n try {\n await conn.query(`UNLISTEN ${this.channel}`);\n } catch {\n // best effort — the connection may already be gone\n }\n try {\n await conn.end();\n } catch {\n // best effort\n }\n }\n\n private async connectAndListen(): Promise<void> {\n if (this.stopped) return;\n const conn = this.connectFactory();\n this.conn = conn;\n conn.on(\"notification\", () => this.onWake?.());\n conn.on(\"error\", (err) => this.scheduleReconnect(err));\n conn.on(\"end\", () => this.scheduleReconnect(new Error(\"connection ended\")));\n await conn.connect();\n await conn.query(`LISTEN ${this.channel}`);\n }\n\n private scheduleReconnect(err: Error): void {\n if (this.stopped) return;\n this.conn = null;\n this.log.warn(\"notify waker connection lost; reconnecting\", {\n error: err.message,\n });\n setTimeout(() => {\n if (this.stopped) return;\n this.connectAndListen().catch((e) => {\n const error = e instanceof Error ? e : new Error(String(e));\n this.log.error(\"notify waker reconnect failed\", {\n error: error.message,\n });\n this.scheduleReconnect(error);\n });\n }, this.reconnectDelayMs);\n }\n}\n","import {\n buildPublishable,\n ConsoleLogger,\n JsonSerializer,\n Relay,\n} from \"@eventferry/core\";\nimport type {\n DlqConfig,\n Logger,\n OutboxStore,\n Publisher,\n RelayHooks,\n RetryConfig,\n Serializer,\n} from \"@eventferry/core\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/** Connection + slot/publication settings for the WAL stream. */\nexport interface ReplicationConfig {\n /** A replication-capable connection (e.g. a connection string). */\n connectionString: string;\n /** Persistent logical replication slot name. Created if absent. */\n slot: string;\n /** Publication name (see createPublicationSql). */\n publication: string;\n /** Outbox table to capture. Default \"outbox\". */\n table?: string;\n}\n\nexport interface PostgresStreamingRelayOptions {\n /** Outbox store. Construct with `{ claimFailedOnly: true }` so the internal\n * retry loop only drains failures (pending rows are owned by the stream). */\n store: OutboxStore;\n publisher: Publisher;\n replication: ReplicationConfig;\n retry?: Partial<RetryConfig>;\n dlq?: DlqConfig;\n serializer?: Serializer;\n logger?: Logger;\n hooks?: RelayHooks;\n /** Poll interval (ms) for the internal failed-row retry loop. Default 5000. */\n failedPollIntervalMs?: number;\n /** Mark happy-path rows done (status=2) after publish. Default true. */\n markPublished?: boolean;\n /** Max rows published per chunk within a committed transaction. Default 100. */\n batchSize?: number;\n}\n\n/** A decoded INSERT on the outbox table, with the WAL position it occurred at. */\nexport interface DecodedInsert {\n readonly lsn: string;\n readonly row: OutboxRow;\n}\n\nexport interface ReplicationStreamHandlers {\n onInsert: (insert: DecodedInsert) => void;\n onCommit: (lsn: string) => void | Promise<void>;\n onError: (err: Error) => void;\n}\n\n/** The WAL source the streaming relay consumes. Implemented over\n * pg-logical-replication; swappable for tests. */\nexport interface ReplicationStream {\n start(handlers: ReplicationStreamHandlers): Promise<void>;\n acknowledge(lsn: string): Promise<void>;\n stop(): Promise<void>;\n}\n\n/**\n * Publishes outbox events straight from the Postgres WAL (logical replication),\n * with no claim query on the happy path. Failures are demoted to `failed` and\n * drained by an internal claim-based retry loop (the core `Relay` over a\n * `claimFailedOnly` store), reusing the existing backoff / DLQ / dead handling.\n *\n * At-least-once: a commit's LSN is acknowledged only after its batch's side\n * effects commit, so a crash re-streams and re-publishes (a duplicate idempotent\n * consumers absorb). Ordering is best-effort per aggregate — a retried failure\n * lands after later same-aggregate rows; use the polling relay for strict order.\n */\nexport class PostgresStreamingRelay {\n private readonly store: OutboxStore;\n private readonly publisher: Publisher;\n private readonly serializer: Serializer;\n private readonly log: Logger;\n private readonly hooks: RelayHooks;\n private readonly replication: ReplicationConfig;\n private readonly markPublished: boolean;\n private readonly batchSize: number;\n private readonly retryRelay: Relay;\n\n private stream: ReplicationStream | null = null;\n private buffer: DecodedInsert[] = [];\n private tail: Promise<void> = Promise.resolve();\n private running = false;\n\n constructor(opts: PostgresStreamingRelayOptions) {\n this.store = opts.store;\n this.publisher = opts.publisher;\n this.serializer = opts.serializer ?? new JsonSerializer();\n this.log = opts.logger ?? new ConsoleLogger();\n this.hooks = opts.hooks ?? {};\n this.replication = opts.replication;\n this.markPublished = opts.markPublished ?? true;\n this.batchSize = opts.batchSize ?? 100;\n\n // Internal failed-only retry loop: owns publisher connect/disconnect and\n // reuses the engine's retry/backoff/DLQ/dead/reaper for demoted rows.\n this.retryRelay = new Relay({\n store: opts.store,\n publisher: opts.publisher,\n retry: opts.retry,\n dlq: opts.dlq,\n serializer: this.serializer,\n logger: opts.logger,\n hooks: opts.hooks,\n pollIntervalMs: opts.failedPollIntervalMs ?? 5000,\n });\n }\n\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n await this.retryRelay.start(); // connects publisher + starts the failed-only loop\n this.stream = this.createReplicationStream();\n await this.stream.start({\n onInsert: (insert) => {\n this.buffer.push(insert);\n },\n onCommit: (lsn) => {\n const batch = this.buffer;\n this.buffer = [];\n this.tail = this.tail.then(() => this.processBatch(batch, lsn));\n return this.tail;\n },\n onError: (err) => {\n this.log.error(\"replication stream error\", { error: err.message });\n this.hooks.onError?.(err);\n },\n });\n this.log.info(\"streaming relay started\", { slot: this.replication.slot });\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n await this.stream?.stop();\n await this.tail; // drain any in-flight batch\n await this.retryRelay.stop(); // stops the loop + disconnects publisher\n this.log.info(\"streaming relay stopped\");\n }\n\n /** Build the WAL stream. Overridable as a test seam. */\n protected createReplicationStream(): ReplicationStream {\n return createPgLogicalStream(this.replication);\n }\n\n private async processBatch(\n batch: DecodedInsert[],\n lsn: string,\n ): Promise<void> {\n try {\n for (let i = 0; i < batch.length; i += this.batchSize) {\n await this.publishChunk(batch.slice(i, i + this.batchSize));\n }\n await this.stream?.acknowledge(lsn);\n } catch (err) {\n // A failure here (e.g. a DB write while demoting) must NOT advance the\n // LSN: the commit is re-streamed and re-published on reconnect.\n const error = err instanceof Error ? err : new Error(String(err));\n this.log.error(\"streaming batch failed; not acknowledging\", {\n error: error.message,\n });\n this.hooks.onError?.(error);\n }\n }\n\n private async publishChunk(chunk: DecodedInsert[]): Promise<void> {\n const records = chunk.map((c) => rowToRecord(c.row));\n const messages = await Promise.all(\n records.map((r) => buildPublishable(r, this.serializer)),\n );\n const results = await this.publisher.publish(messages);\n const byId = new Map(records.map((r) => [r.id, r]));\n\n const succeeded: string[] = [];\n for (const result of results) {\n const record = byId.get(result.recordId);\n if (!record) continue;\n if (result.ok) {\n succeeded.push(record.id);\n this.hooks.onPublished?.(result);\n } else {\n // Demote to failed (due now); the internal retry loop owns backoff/DLQ.\n await this.store.markFailed(record.id, null, \"failed\");\n this.hooks.onFailed?.(\n record,\n result.error ?? new Error(\"publish failed\"),\n true,\n );\n }\n }\n if (this.markPublished && succeeded.length > 0) {\n await this.store.markDone(succeeded);\n }\n }\n}\n\n/** Real WAL stream backed by pg-logical-replication + pgoutput. Covered by the\n * integration suite (it needs a live Postgres); unit tests use a fake stream. */\nfunction createPgLogicalStream(config: ReplicationConfig): ReplicationStream {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let service: any = null;\n let stopped = false;\n const table = config.table ?? \"outbox\";\n\n return {\n async start(handlers: ReplicationStreamHandlers): Promise<void> {\n await ensureSlot(config.connectionString, config.slot);\n const mod = await importPgLogical();\n service = new mod.LogicalReplicationService(\n { connectionString: config.connectionString },\n { acknowledge: { auto: false, timeoutSeconds: 0 } },\n );\n const plugin = new mod.PgoutputPlugin({\n protoVersion: 2,\n publicationNames: [config.publication],\n });\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n service.on(\"data\", (lsn: string, msg: any) => {\n if (msg?.tag === \"insert\" && msg?.relation?.name === table) {\n handlers.onInsert({ lsn, row: normalizeRow(msg.new) });\n } else if (msg?.tag === \"commit\") {\n void handlers.onCommit(lsn);\n }\n });\n service.on(\"error\", (err: Error) => {\n if (stopped) return;\n handlers.onError(err);\n setTimeout(() => {\n if (stopped) return;\n service\n .subscribe(plugin, config.slot)\n .catch((e: Error) => handlers.onError(e));\n }, 1000);\n });\n service\n .subscribe(plugin, config.slot)\n .catch((e: Error) => {\n if (!stopped) handlers.onError(e);\n });\n },\n async acknowledge(lsn: string): Promise<void> {\n if (service) await service.acknowledge(lsn);\n },\n async stop(): Promise<void> {\n stopped = true;\n if (service) await service.stop();\n },\n };\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction normalizeRow(raw: any): OutboxRow {\n const json = (v: unknown) =>\n typeof v === \"string\" ? JSON.parse(v) : v;\n return {\n id: String(raw.id),\n message_id: raw.message_id,\n aggregate_type: raw.aggregate_type,\n aggregate_id: raw.aggregate_id,\n topic: raw.topic,\n key: raw.key ?? null,\n payload: json(raw.payload),\n headers: (json(raw.headers) as Record<string, string>) ?? {},\n trace_id: raw.trace_id ?? null,\n status: Number(raw.status),\n attempts: Number(raw.attempts),\n next_retry_at: raw.next_retry_at ? new Date(raw.next_retry_at) : null,\n created_at: raw.created_at ? new Date(raw.created_at) : new Date(0),\n processed_at: raw.processed_at ? new Date(raw.processed_at) : null,\n };\n}\n\n/** Create the persistent pgoutput slot if it does not already exist. */\nasync function ensureSlot(\n connectionString: string,\n slot: string,\n): Promise<void> {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const pg: any = await import(\"pg\");\n const client = new pg.Client({ connectionString });\n await client.connect();\n try {\n const existing = await client.query(\n \"SELECT 1 FROM pg_replication_slots WHERE slot_name = $1\",\n [slot],\n );\n if (existing.rows.length === 0) {\n await client.query(\n \"SELECT pg_create_logical_replication_slot($1, 'pgoutput')\",\n [slot],\n );\n }\n } finally {\n await client.end();\n }\n}\n\nasync function importPgLogical(): Promise<{\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n LogicalReplicationService: any;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n PgoutputPlugin: any;\n}> {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return (await import(\"pg-logical-replication\")) as any;\n } catch {\n throw new Error(\n 'Streaming relay needs the \"pg-logical-replication\" package. Run: npm i pg-logical-replication',\n );\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/store.ts","../src/row.ts","../src/notify-waker.ts","../src/streaming-relay.ts"],"sourcesContent":["import type {\n OutboxMessageInput,\n OutboxRecord,\n OutboxStore,\n Tracing,\n} from \"@eventferry/core\";\nimport { OUTBOX_STATUS_CODE } from \"@eventferry/core\";\nimport { assertIdent } from \"./ident.js\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/**\n * Minimal query interface satisfied by both `pg.Pool` and `pg.PoolClient`,\n * so `enqueue` can run inside a caller-supplied transaction.\n */\nexport interface Queryable {\n query(\n queryText: string,\n values?: unknown[],\n ): Promise<{ rows: Record<string, unknown>[] }>;\n}\n\nexport interface PostgresStoreOptions {\n /** A connected pg Pool used by the relay for claim/ack queries. */\n pool: Queryable;\n /** Outbox table name. Default \"outbox\". */\n table?: string;\n /**\n * Visibility timeout (ms) after which a row stuck in `processing` is\n * reclaimable by any relay. Guards against permanently-orphaned rows when\n * a relay crashes between claiming and acking. MUST be comfortably larger\n * than the worst-case publish latency, otherwise a slow-but-alive relay's\n * in-flight rows get reclaimed and re-published (a duplicate). Default 60s.\n */\n claimTimeoutMs?: number;\n /**\n * When true, `claimBatch` never claims `pending`(0) rows — only `failed`(3)\n * (retry due) and timed-out `processing`(1). Used by the streaming relay,\n * where pending rows are owned by the WAL stream and the claim loop only\n * drains failures. Default false (claims pending too).\n */\n claimFailedOnly?: boolean;\n /**\n * Optional trace-context propagator. When set, `enqueue` captures the active\n * W3C trace context into the row's headers, so it rides along to the published\n * message and the consumer can continue the trace. See {@link Tracing}.\n */\n tracing?: Tracing;\n}\n\nexport interface PurgeDoneOptions {\n /** Delete `done` rows whose processed_at is older than this (milliseconds). */\n olderThanMs: number;\n /** Rows deleted per batch. Default 1000. */\n batchSize?: number;\n /** Optional cap on the total rows deleted in this call. */\n maxRows?: number;\n}\n\nconst DEFAULT_CLAIM_TIMEOUT_MS = 60_000;\n\nexport class PostgresStore implements OutboxStore {\n private readonly pool: Queryable;\n private readonly table: string;\n private readonly claimTimeoutMs: number;\n private readonly claimFailedOnly: boolean;\n private readonly tracing: Tracing | null;\n\n constructor(opts: PostgresStoreOptions) {\n this.pool = opts.pool;\n this.table = assertIdent(opts.table ?? \"outbox\");\n this.claimTimeoutMs = opts.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS;\n this.claimFailedOnly = opts.claimFailedOnly ?? false;\n this.tracing = opts.tracing ?? null;\n }\n\n /**\n * Insert a message into the outbox. MUST be called with the same\n * transaction (`tx`) that persists the business state, so the event\n * and the state commit atomically.\n *\n * @returns the generated message id.\n */\n async enqueue(\n tx: Queryable,\n msg: OutboxMessageInput & { traceId?: string },\n ): Promise<string> {\n // Copy (never mutate the caller's object) and let tracing capture the\n // active W3C context into the headers, so it rides along to the broker.\n const headers = { ...(msg.headers ?? {}) };\n this.tracing?.inject(headers);\n\n const text = `\n INSERT INTO ${this.table}\n (message_id, aggregate_type, aggregate_id, topic, \"key\", payload, headers, trace_id, status)\n VALUES\n (COALESCE($1, gen_random_uuid()), $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, 0)\n RETURNING message_id\n `;\n const res = await tx.query(text, [\n msg.messageId ?? null,\n msg.aggregateType,\n msg.aggregateId,\n msg.topic,\n msg.key ?? null,\n JSON.stringify(msg.payload),\n JSON.stringify(headers),\n msg.traceId ?? null,\n ]);\n return res.rows[0]?.message_id as string;\n }\n\n /**\n * Claim up to `batchSize` due rows using FOR UPDATE SKIP LOCKED so that\n * concurrent relay instances never contend for the same rows. Claimed\n * rows are flipped to status=processing(1) and stamped with claimed_at,\n * atomically in the same CTE.\n *\n * Strict per-aggregate ordering is enforced by only claiming a row when it\n * is the *head* of its aggregate — i.e. no earlier row (lower id) for the\n * same aggregate_id is still unfinished (pending/processing/failed). This\n * guarantees:\n * - at most one in-flight row per aggregate at any time (across relays),\n * - a failed row blocks its successors until it is done or dead,\n * - within a single batch every aggregate appears at most once, so the\n * broker can never observe two same-key messages out of order.\n * `done`(2) and `dead`(4) are terminal and stop blocking successors, so a\n * poison message routed to the DLQ does not stall its aggregate forever.\n *\n * A row stuck in `processing` longer than claimTimeoutMs is treated as due\n * again (its owning relay is presumed dead), which is what keeps a crash\n * between claim and ack from orphaning messages.\n *\n * $1 = batchSize, $2 = claimTimeoutMs.\n */\n async claimBatch(batchSize: number): Promise<OutboxRecord[]> {\n const processing = OUTBOX_STATUS_CODE.processing;\n // Streaming mode (claimFailedOnly) leaves pending(0) rows to the WAL stream.\n const pendingClause = this.claimFailedOnly ? \"\" : \"o.status = 0\\n OR \";\n const text = `\n WITH due AS (\n SELECT o.id\n FROM ${this.table} o\n WHERE (\n ${pendingClause}(o.status = 3 AND (o.next_retry_at IS NULL OR o.next_retry_at <= now()))\n OR (o.status = 1 AND o.claimed_at IS NOT NULL\n AND o.claimed_at <= now() - ($2 * interval '1 millisecond'))\n )\n AND NOT EXISTS (\n SELECT 1\n FROM ${this.table} earlier\n WHERE earlier.aggregate_id = o.aggregate_id\n AND earlier.id < o.id\n AND earlier.status IN (0, 1, 3)\n )\n ORDER BY o.id\n LIMIT $1\n FOR UPDATE SKIP LOCKED\n )\n UPDATE ${this.table} AS o\n SET status = ${processing}, claimed_at = now()\n FROM due\n WHERE o.id = due.id\n RETURNING o.id, o.message_id, o.aggregate_type, o.aggregate_id,\n o.topic, o.\"key\", o.payload, o.headers, o.trace_id,\n o.status, o.attempts, o.next_retry_at, o.created_at, o.processed_at\n `;\n const res = await this.pool.query(text, [batchSize, this.claimTimeoutMs]);\n return (res.rows as unknown as OutboxRow[]).map(rowToRecord);\n }\n\n async markDone(recordIds: string[]): Promise<void> {\n if (recordIds.length === 0) return;\n const done = OUTBOX_STATUS_CODE.done;\n await this.pool.query(\n `UPDATE ${this.table}\n SET status = ${done}, processed_at = now()\n WHERE id = ANY($1::bigint[])`,\n [recordIds],\n );\n }\n\n async markFailed(\n recordId: string,\n nextRetryAt: Date | null,\n status: \"failed\" | \"dead\",\n ): Promise<void> {\n const code = OUTBOX_STATUS_CODE[status];\n await this.pool.query(\n `UPDATE ${this.table}\n SET status = $2,\n attempts = attempts + 1,\n next_retry_at = $3\n WHERE id = $1`,\n [recordId, code, nextRetryAt],\n );\n }\n\n /**\n * Re-queue a record to `failed` with the given `retryAt` **without\n * bumping attempts** — used by the relay for backpressure handling.\n * Also clears `claimed_at` so the reaper does not race the row.\n */\n async requeue(recordId: string, retryAt: Date): Promise<void> {\n const failed = OUTBOX_STATUS_CODE.failed;\n await this.pool.query(\n `UPDATE ${this.table}\n SET status = ${failed},\n claimed_at = NULL,\n next_retry_at = $2\n WHERE id = $1`,\n [recordId, retryAt],\n );\n }\n\n /**\n * Delete `done` rows whose `processed_at` is older than `olderThanMs`, in\n * batches (to avoid long locks / table bloat). Returns the total deleted.\n * Run it periodically (e.g. from a cron) — there is no built-in scheduler.\n * Note: only `done`(2) rows are purged; `dead` rows are left in place.\n */\n async purgeDone(opts: PurgeDoneOptions): Promise<number> {\n const done = OUTBOX_STATUS_CODE.done;\n const batchSize = opts.batchSize ?? 1000;\n const text = `\n DELETE FROM ${this.table}\n WHERE id IN (\n SELECT id FROM ${this.table}\n WHERE status = ${done}\n AND processed_at IS NOT NULL\n AND processed_at < now() - ($1 * interval '1 millisecond')\n ORDER BY id\n LIMIT $2\n )\n RETURNING id\n `;\n let total = 0;\n for (;;) {\n const res = await this.pool.query(text, [opts.olderThanMs, batchSize]);\n total += res.rows.length;\n if (res.rows.length < batchSize) break;\n if (opts.maxRows !== undefined && total >= opts.maxRows) break;\n }\n return total;\n }\n}\n","import type { OutboxRecord, OutboxStatus } from \"@eventferry/core\";\nimport { OUTBOX_STATUS_FROM_CODE } from \"@eventferry/core\";\n\n/** Raw outbox row shape as read from Postgres (snake_case columns). */\nexport interface OutboxRow {\n id: string;\n message_id: string;\n aggregate_type: string;\n aggregate_id: string;\n topic: string;\n key: string | null;\n payload: unknown;\n headers: Record<string, string> | null;\n trace_id: string | null;\n status: number;\n attempts: number;\n next_retry_at: Date | null;\n created_at: Date;\n processed_at: Date | null;\n}\n\n/** Map a raw DB row to the broker-agnostic core record. */\nexport function rowToRecord(row: OutboxRow): OutboxRecord {\n const status: OutboxStatus = OUTBOX_STATUS_FROM_CODE[row.status] ?? \"pending\";\n return {\n id: String(row.id),\n messageId: row.message_id,\n topic: row.topic,\n aggregateType: row.aggregate_type,\n aggregateId: row.aggregate_id,\n key: row.key,\n payload: row.payload,\n headers: row.headers ?? {},\n traceId: row.trace_id,\n status,\n attempts: row.attempts,\n nextRetryAt: row.next_retry_at,\n createdAt: row.created_at,\n processedAt: row.processed_at,\n };\n}\n","import type { Logger, Waker } from \"@eventferry/core\";\nimport { NoopLogger } from \"@eventferry/core\";\nimport { assertIdent } from \"./ident.js\";\n\n/**\n * Structural subset of `pg.Client` the waker needs. A `pg.Client` satisfies this\n * directly. A pooled client must NOT be used — a LISTENing connection is held for\n * the relay's lifetime and would never return to the pool.\n */\nexport interface NotificationConnection {\n connect(): Promise<void>;\n query(sql: string): Promise<unknown>;\n on(\n event: \"notification\",\n listener: (msg: { channel: string; payload?: string }) => void,\n ): unknown;\n on(event: \"error\", listener: (err: Error) => void): unknown;\n on(event: \"end\", listener: () => void): unknown;\n end(): Promise<void>;\n}\n\nexport interface PostgresNotifyWakerOptions {\n /**\n * Creates a fresh, unconnected notification connection (e.g.\n * `() => new pg.Client(config)`). Called on every (re)connect.\n */\n connect: () => NotificationConnection;\n /** LISTEN channel; must match `createNotifyTriggerSql`. Default \"outbox\". */\n channel?: string;\n /** Base reconnect backoff in ms after a dropped connection. Default 1000. */\n reconnectDelayMs?: number;\n /** Optional logger. Defaults to a no-op. */\n logger?: Logger;\n}\n\n/**\n * A {@link Waker} backed by Postgres LISTEN/NOTIFY. Holds a dedicated connection\n * that LISTENs on a channel; each notification wakes the relay. On a dropped\n * connection it reconnects with a fixed backoff — meanwhile the relay's polling\n * covers the gap, so no notification gap can lose an event.\n */\nexport class PostgresNotifyWaker implements Waker {\n private readonly connectFactory: () => NotificationConnection;\n private readonly channel: string;\n private readonly reconnectDelayMs: number;\n private readonly log: Logger;\n\n private conn: NotificationConnection | null = null;\n private onWake: (() => void) | null = null;\n private stopped = false;\n\n constructor(opts: PostgresNotifyWakerOptions) {\n this.connectFactory = opts.connect;\n this.channel = assertIdent(opts.channel ?? \"outbox\");\n this.reconnectDelayMs = opts.reconnectDelayMs ?? 1000;\n this.log = opts.logger ?? new NoopLogger();\n }\n\n async start(onWake: () => void): Promise<void> {\n this.onWake = onWake;\n this.stopped = false;\n await this.connectAndListen();\n }\n\n async stop(): Promise<void> {\n this.stopped = true;\n const conn = this.conn;\n this.conn = null;\n if (!conn) return;\n try {\n await conn.query(`UNLISTEN ${this.channel}`);\n } catch {\n // best effort — the connection may already be gone\n }\n try {\n await conn.end();\n } catch {\n // best effort\n }\n }\n\n private async connectAndListen(): Promise<void> {\n if (this.stopped) return;\n const conn = this.connectFactory();\n this.conn = conn;\n conn.on(\"notification\", () => this.onWake?.());\n conn.on(\"error\", (err) => this.scheduleReconnect(err));\n conn.on(\"end\", () => this.scheduleReconnect(new Error(\"connection ended\")));\n await conn.connect();\n await conn.query(`LISTEN ${this.channel}`);\n }\n\n private scheduleReconnect(err: Error): void {\n if (this.stopped) return;\n this.conn = null;\n this.log.warn(\"notify waker connection lost; reconnecting\", {\n error: err.message,\n });\n setTimeout(() => {\n if (this.stopped) return;\n this.connectAndListen().catch((e) => {\n const error = e instanceof Error ? e : new Error(String(e));\n this.log.error(\"notify waker reconnect failed\", {\n error: error.message,\n });\n this.scheduleReconnect(error);\n });\n }, this.reconnectDelayMs);\n }\n}\n","import {\n buildPublishable,\n ConsoleLogger,\n JsonSerializer,\n Relay,\n} from \"@eventferry/core\";\nimport type {\n DlqConfig,\n Logger,\n OutboxStore,\n Publisher,\n RelayHooks,\n RetryConfig,\n Serializer,\n} from \"@eventferry/core\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/** Connection + slot/publication settings for the WAL stream. */\nexport interface ReplicationConfig {\n /** A replication-capable connection (e.g. a connection string). */\n connectionString: string;\n /** Persistent logical replication slot name. Created if absent. */\n slot: string;\n /** Publication name (see createPublicationSql). */\n publication: string;\n /** Outbox table to capture. Default \"outbox\". */\n table?: string;\n}\n\nexport interface PostgresStreamingRelayOptions {\n /** Outbox store. Construct with `{ claimFailedOnly: true }` so the internal\n * retry loop only drains failures (pending rows are owned by the stream). */\n store: OutboxStore;\n publisher: Publisher;\n replication: ReplicationConfig;\n retry?: Partial<RetryConfig>;\n dlq?: DlqConfig;\n serializer?: Serializer;\n logger?: Logger;\n hooks?: RelayHooks;\n /** Poll interval (ms) for the internal failed-row retry loop. Default 5000. */\n failedPollIntervalMs?: number;\n /** Mark happy-path rows done (status=2) after publish. Default true. */\n markPublished?: boolean;\n /** Max rows published per chunk within a committed transaction. Default 100. */\n batchSize?: number;\n}\n\n/** A decoded INSERT on the outbox table, with the WAL position it occurred at. */\nexport interface DecodedInsert {\n readonly lsn: string;\n readonly row: OutboxRow;\n}\n\nexport interface ReplicationStreamHandlers {\n onInsert: (insert: DecodedInsert) => void;\n onCommit: (lsn: string) => void | Promise<void>;\n onError: (err: Error) => void;\n}\n\n/** The WAL source the streaming relay consumes. Implemented over\n * pg-logical-replication; swappable for tests. */\nexport interface ReplicationStream {\n start(handlers: ReplicationStreamHandlers): Promise<void>;\n acknowledge(lsn: string): Promise<void>;\n stop(): Promise<void>;\n}\n\n/**\n * Publishes outbox events straight from the Postgres WAL (logical replication),\n * with no claim query on the happy path. Failures are demoted to `failed` and\n * drained by an internal claim-based retry loop (the core `Relay` over a\n * `claimFailedOnly` store), reusing the existing backoff / DLQ / dead handling.\n *\n * At-least-once: a commit's LSN is acknowledged only after its batch's side\n * effects commit, so a crash re-streams and re-publishes (a duplicate idempotent\n * consumers absorb). Ordering is best-effort per aggregate — a retried failure\n * lands after later same-aggregate rows; use the polling relay for strict order.\n */\nexport class PostgresStreamingRelay {\n private readonly store: OutboxStore;\n private readonly publisher: Publisher;\n private readonly serializer: Serializer;\n private readonly log: Logger;\n private readonly hooks: RelayHooks;\n private readonly replication: ReplicationConfig;\n private readonly markPublished: boolean;\n private readonly batchSize: number;\n private readonly retryRelay: Relay;\n\n private stream: ReplicationStream | null = null;\n private buffer: DecodedInsert[] = [];\n private tail: Promise<void> = Promise.resolve();\n private running = false;\n\n constructor(opts: PostgresStreamingRelayOptions) {\n this.store = opts.store;\n this.publisher = opts.publisher;\n this.serializer = opts.serializer ?? new JsonSerializer();\n this.log = opts.logger ?? new ConsoleLogger();\n this.hooks = opts.hooks ?? {};\n this.replication = opts.replication;\n this.markPublished = opts.markPublished ?? true;\n this.batchSize = opts.batchSize ?? 100;\n\n // Internal failed-only retry loop: owns publisher connect/disconnect and\n // reuses the engine's retry/backoff/DLQ/dead/reaper for demoted rows.\n this.retryRelay = new Relay({\n store: opts.store,\n publisher: opts.publisher,\n retry: opts.retry,\n dlq: opts.dlq,\n serializer: this.serializer,\n logger: opts.logger,\n hooks: opts.hooks,\n pollIntervalMs: opts.failedPollIntervalMs ?? 5000,\n });\n }\n\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n await this.retryRelay.start(); // connects publisher + starts the failed-only loop\n this.stream = this.createReplicationStream();\n await this.stream.start({\n onInsert: (insert) => {\n this.buffer.push(insert);\n },\n onCommit: (lsn) => {\n const batch = this.buffer;\n this.buffer = [];\n this.tail = this.tail.then(() => this.processBatch(batch, lsn));\n return this.tail;\n },\n onError: (err) => {\n this.log.error(\"replication stream error\", { error: err.message });\n this.hooks.onError?.(err);\n },\n });\n this.log.info(\"streaming relay started\", { slot: this.replication.slot });\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n await this.stream?.stop();\n await this.tail; // drain any in-flight batch\n await this.retryRelay.stop(); // stops the loop + disconnects publisher\n this.log.info(\"streaming relay stopped\");\n }\n\n /** Build the WAL stream. Overridable as a test seam. */\n protected createReplicationStream(): ReplicationStream {\n return createPgLogicalStream(this.replication);\n }\n\n private async processBatch(\n batch: DecodedInsert[],\n lsn: string,\n ): Promise<void> {\n try {\n for (let i = 0; i < batch.length; i += this.batchSize) {\n await this.publishChunk(batch.slice(i, i + this.batchSize));\n }\n await this.stream?.acknowledge(lsn);\n } catch (err) {\n // A failure here (e.g. a DB write while demoting) must NOT advance the\n // LSN: the commit is re-streamed and re-published on reconnect.\n const error = err instanceof Error ? err : new Error(String(err));\n this.log.error(\"streaming batch failed; not acknowledging\", {\n error: error.message,\n });\n this.hooks.onError?.(error);\n }\n }\n\n private async publishChunk(chunk: DecodedInsert[]): Promise<void> {\n const records = chunk.map((c) => rowToRecord(c.row));\n const messages = await Promise.all(\n records.map((r) => buildPublishable(r, this.serializer)),\n );\n const results = await this.publisher.publish(messages);\n const byId = new Map(records.map((r) => [r.id, r]));\n\n const succeeded: string[] = [];\n for (const result of results) {\n const record = byId.get(result.recordId);\n if (!record) continue;\n if (result.ok) {\n succeeded.push(record.id);\n this.hooks.onPublished?.(result);\n } else {\n // Demote to failed (due now); the internal retry loop owns backoff/DLQ.\n await this.store.markFailed(record.id, null, \"failed\");\n this.hooks.onFailed?.(\n record,\n result.error ?? new Error(\"publish failed\"),\n true,\n );\n }\n }\n if (this.markPublished && succeeded.length > 0) {\n await this.store.markDone(succeeded);\n }\n }\n}\n\n/** Real WAL stream backed by pg-logical-replication + pgoutput. Covered by the\n * integration suite (it needs a live Postgres); unit tests use a fake stream. */\nfunction createPgLogicalStream(config: ReplicationConfig): ReplicationStream {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let service: any = null;\n let stopped = false;\n const table = config.table ?? \"outbox\";\n\n return {\n async start(handlers: ReplicationStreamHandlers): Promise<void> {\n await ensureSlot(config.connectionString, config.slot);\n const mod = await importPgLogical();\n service = new mod.LogicalReplicationService(\n { connectionString: config.connectionString },\n { acknowledge: { auto: false, timeoutSeconds: 0 } },\n );\n const plugin = new mod.PgoutputPlugin({\n protoVersion: 2,\n publicationNames: [config.publication],\n });\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n service.on(\"data\", (lsn: string, msg: any) => {\n if (msg?.tag === \"insert\" && msg?.relation?.name === table) {\n handlers.onInsert({ lsn, row: normalizeRow(msg.new) });\n } else if (msg?.tag === \"commit\") {\n void handlers.onCommit(lsn);\n }\n });\n service.on(\"error\", (err: Error) => {\n if (stopped) return;\n handlers.onError(err);\n setTimeout(() => {\n if (stopped) return;\n service\n .subscribe(plugin, config.slot)\n .catch((e: Error) => handlers.onError(e));\n }, 1000);\n });\n service\n .subscribe(plugin, config.slot)\n .catch((e: Error) => {\n if (!stopped) handlers.onError(e);\n });\n },\n async acknowledge(lsn: string): Promise<void> {\n if (service) await service.acknowledge(lsn);\n },\n async stop(): Promise<void> {\n stopped = true;\n if (service) await service.stop();\n },\n };\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction normalizeRow(raw: any): OutboxRow {\n const json = (v: unknown) =>\n typeof v === \"string\" ? JSON.parse(v) : v;\n return {\n id: String(raw.id),\n message_id: raw.message_id,\n aggregate_type: raw.aggregate_type,\n aggregate_id: raw.aggregate_id,\n topic: raw.topic,\n key: raw.key ?? null,\n payload: json(raw.payload),\n headers: (json(raw.headers) as Record<string, string>) ?? {},\n trace_id: raw.trace_id ?? null,\n status: Number(raw.status),\n attempts: Number(raw.attempts),\n next_retry_at: raw.next_retry_at ? new Date(raw.next_retry_at) : null,\n created_at: raw.created_at ? new Date(raw.created_at) : new Date(0),\n processed_at: raw.processed_at ? new Date(raw.processed_at) : null,\n };\n}\n\n/** Create the persistent pgoutput slot if it does not already exist. */\nasync function ensureSlot(\n connectionString: string,\n slot: string,\n): Promise<void> {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const pg: any = await import(\"pg\");\n const client = new pg.Client({ connectionString });\n await client.connect();\n try {\n const existing = await client.query(\n \"SELECT 1 FROM pg_replication_slots WHERE slot_name = $1\",\n [slot],\n );\n if (existing.rows.length === 0) {\n await client.query(\n \"SELECT pg_create_logical_replication_slot($1, 'pgoutput')\",\n [slot],\n );\n }\n } finally {\n await client.end();\n }\n}\n\nasync function importPgLogical(): Promise<{\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n LogicalReplicationService: any;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n PgoutputPlugin: any;\n}> {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return (await import(\"pg-logical-replication\")) as any;\n } catch {\n throw new Error(\n 'Streaming relay needs the \"pg-logical-replication\" package. Run: npm i pg-logical-replication',\n );\n }\n}\n"],"mappings":";;;;;;;;;AAMA,SAAS,0BAA0B;;;ACLnC,SAAS,+BAA+B;AAqBjC,SAAS,YAAY,KAA8B;AACxD,QAAM,SAAuB,wBAAwB,IAAI,MAAM,KAAK;AACpE,SAAO;AAAA,IACL,IAAI,OAAO,IAAI,EAAE;AAAA,IACjB,WAAW,IAAI;AAAA,IACf,OAAO,IAAI;AAAA,IACX,eAAe,IAAI;AAAA,IACnB,aAAa,IAAI;AAAA,IACjB,KAAK,IAAI;AAAA,IACT,SAAS,IAAI;AAAA,IACb,SAAS,IAAI,WAAW,CAAC;AAAA,IACzB,SAAS,IAAI;AAAA,IACb;AAAA,IACA,UAAU,IAAI;AAAA,IACd,aAAa,IAAI;AAAA,IACjB,WAAW,IAAI;AAAA,IACf,aAAa,IAAI;AAAA,EACnB;AACF;;;ADkBA,IAAM,2BAA2B;AAE1B,IAAM,gBAAN,MAA2C;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAEjB,YAAY,MAA4B;AACtC,SAAK,OAAO,KAAK;AACjB,SAAK,QAAQ,YAAY,KAAK,SAAS,QAAQ;AAC/C,SAAK,iBAAiB,KAAK,kBAAkB;AAC7C,SAAK,kBAAkB,KAAK,mBAAmB;AAC/C,SAAK,UAAU,KAAK,WAAW;AAAA,EACjC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAM,QACJ,IACA,KACiB;AAGjB,UAAM,UAAU,EAAE,GAAI,IAAI,WAAW,CAAC,EAAG;AACzC,SAAK,SAAS,OAAO,OAAO;AAE5B,UAAM,OAAO;AAAA,oBACG,KAAK,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAM1B,UAAM,MAAM,MAAM,GAAG,MAAM,MAAM;AAAA,MAC/B,IAAI,aAAa;AAAA,MACjB,IAAI;AAAA,MACJ,IAAI;AAAA,MACJ,IAAI;AAAA,MACJ,IAAI,OAAO;AAAA,MACX,KAAK,UAAU,IAAI,OAAO;AAAA,MAC1B,KAAK,UAAU,OAAO;AAAA,MACtB,IAAI,WAAW;AAAA,IACjB,CAAC;AACD,WAAO,IAAI,KAAK,CAAC,GAAG;AAAA,EACtB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAyBA,MAAM,WAAW,WAA4C;AAC3D,UAAM,aAAa,mBAAmB;AAEtC,UAAM,gBAAgB,KAAK,kBAAkB,KAAK;AAClD,UAAM,OAAO;AAAA;AAAA;AAAA,eAGF,KAAK,KAAK;AAAA;AAAA,gBAET,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,qBAMR,KAAK,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,eAShB,KAAK,KAAK;AAAA,qBACJ,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAO3B,UAAM,MAAM,MAAM,KAAK,KAAK,MAAM,MAAM,CAAC,WAAW,KAAK,cAAc,CAAC;AACxE,WAAQ,IAAI,KAAgC,IAAI,WAAW;AAAA,EAC7D;AAAA,EAEA,MAAM,SAAS,WAAoC;AACjD,QAAI,UAAU,WAAW,EAAG;AAC5B,UAAM,OAAO,mBAAmB;AAChC,UAAM,KAAK,KAAK;AAAA,MACd,UAAU,KAAK,KAAK;AAAA,wBACF,IAAI;AAAA;AAAA,MAEtB,CAAC,SAAS;AAAA,IACZ;AAAA,EACF;AAAA,EAEA,MAAM,WACJ,UACA,aACA,QACe;AACf,UAAM,OAAO,mBAAmB,MAAM;AACtC,UAAM,KAAK,KAAK;AAAA,MACd,UAAU,KAAK,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA,MAKpB,CAAC,UAAU,MAAM,WAAW;AAAA,IAC9B;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,QAAQ,UAAkB,SAA8B;AAC5D,UAAM,SAAS,mBAAmB;AAClC,UAAM,KAAK,KAAK;AAAA,MACd,UAAU,KAAK,KAAK;AAAA,wBACF,MAAM;AAAA;AAAA;AAAA;AAAA,MAIxB,CAAC,UAAU,OAAO;AAAA,IACpB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,UAAU,MAAyC;AACvD,UAAM,OAAO,mBAAmB;AAChC,UAAM,YAAY,KAAK,aAAa;AACpC,UAAM,OAAO;AAAA,oBACG,KAAK,KAAK;AAAA;AAAA,yBAEL,KAAK,KAAK;AAAA,yBACV,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQzB,QAAI,QAAQ;AACZ,eAAS;AACP,YAAM,MAAM,MAAM,KAAK,KAAK,MAAM,MAAM,CAAC,KAAK,aAAa,SAAS,CAAC;AACrE,eAAS,IAAI,KAAK;AAClB,UAAI,IAAI,KAAK,SAAS,UAAW;AACjC,UAAI,KAAK,YAAY,UAAa,SAAS,KAAK,QAAS;AAAA,IAC3D;AACA,WAAO;AAAA,EACT;AACF;;;AEnPA,SAAS,kBAAkB;AAwCpB,IAAM,sBAAN,MAA2C;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAET,OAAsC;AAAA,EACtC,SAA8B;AAAA,EAC9B,UAAU;AAAA,EAElB,YAAY,MAAkC;AAC5C,SAAK,iBAAiB,KAAK;AAC3B,SAAK,UAAU,YAAY,KAAK,WAAW,QAAQ;AACnD,SAAK,mBAAmB,KAAK,oBAAoB;AACjD,SAAK,MAAM,KAAK,UAAU,IAAI,WAAW;AAAA,EAC3C;AAAA,EAEA,MAAM,MAAM,QAAmC;AAC7C,SAAK,SAAS;AACd,SAAK,UAAU;AACf,UAAM,KAAK,iBAAiB;AAAA,EAC9B;AAAA,EAEA,MAAM,OAAsB;AAC1B,SAAK,UAAU;AACf,UAAM,OAAO,KAAK;AAClB,SAAK,OAAO;AACZ,QAAI,CAAC,KAAM;AACX,QAAI;AACF,YAAM,KAAK,MAAM,YAAY,KAAK,OAAO,EAAE;AAAA,IAC7C,QAAQ;AAAA,IAER;AACA,QAAI;AACF,YAAM,KAAK,IAAI;AAAA,IACjB,QAAQ;AAAA,IAER;AAAA,EACF;AAAA,EAEA,MAAc,mBAAkC;AAC9C,QAAI,KAAK,QAAS;AAClB,UAAM,OAAO,KAAK,eAAe;AACjC,SAAK,OAAO;AACZ,SAAK,GAAG,gBAAgB,MAAM,KAAK,SAAS,CAAC;AAC7C,SAAK,GAAG,SAAS,CAAC,QAAQ,KAAK,kBAAkB,GAAG,CAAC;AACrD,SAAK,GAAG,OAAO,MAAM,KAAK,kBAAkB,IAAI,MAAM,kBAAkB,CAAC,CAAC;AAC1E,UAAM,KAAK,QAAQ;AACnB,UAAM,KAAK,MAAM,UAAU,KAAK,OAAO,EAAE;AAAA,EAC3C;AAAA,EAEQ,kBAAkB,KAAkB;AAC1C,QAAI,KAAK,QAAS;AAClB,SAAK,OAAO;AACZ,SAAK,IAAI,KAAK,8CAA8C;AAAA,MAC1D,OAAO,IAAI;AAAA,IACb,CAAC;AACD,eAAW,MAAM;AACf,UAAI,KAAK,QAAS;AAClB,WAAK,iBAAiB,EAAE,MAAM,CAAC,MAAM;AACnC,cAAM,QAAQ,aAAa,QAAQ,IAAI,IAAI,MAAM,OAAO,CAAC,CAAC;AAC1D,aAAK,IAAI,MAAM,iCAAiC;AAAA,UAC9C,OAAO,MAAM;AAAA,QACf,CAAC;AACD,aAAK,kBAAkB,KAAK;AAAA,MAC9B,CAAC;AAAA,IACH,GAAG,KAAK,gBAAgB;AAAA,EAC1B;AACF;;;AC7GA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AA0EA,IAAM,yBAAN,MAA6B;AAAA,EACjB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAET,SAAmC;AAAA,EACnC,SAA0B,CAAC;AAAA,EAC3B,OAAsB,QAAQ,QAAQ;AAAA,EACtC,UAAU;AAAA,EAElB,YAAY,MAAqC;AAC/C,SAAK,QAAQ,KAAK;AAClB,SAAK,YAAY,KAAK;AACtB,SAAK,aAAa,KAAK,cAAc,IAAI,eAAe;AACxD,SAAK,MAAM,KAAK,UAAU,IAAI,cAAc;AAC5C,SAAK,QAAQ,KAAK,SAAS,CAAC;AAC5B,SAAK,cAAc,KAAK;AACxB,SAAK,gBAAgB,KAAK,iBAAiB;AAC3C,SAAK,YAAY,KAAK,aAAa;AAInC,SAAK,aAAa,IAAI,MAAM;AAAA,MAC1B,OAAO,KAAK;AAAA,MACZ,WAAW,KAAK;AAAA,MAChB,OAAO,KAAK;AAAA,MACZ,KAAK,KAAK;AAAA,MACV,YAAY,KAAK;AAAA,MACjB,QAAQ,KAAK;AAAA,MACb,OAAO,KAAK;AAAA,MACZ,gBAAgB,KAAK,wBAAwB;AAAA,IAC/C,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,QAAuB;AAC3B,QAAI,KAAK,QAAS;AAClB,SAAK,UAAU;AACf,UAAM,KAAK,WAAW,MAAM;AAC5B,SAAK,SAAS,KAAK,wBAAwB;AAC3C,UAAM,KAAK,OAAO,MAAM;AAAA,MACtB,UAAU,CAAC,WAAW;AACpB,aAAK,OAAO,KAAK,MAAM;AAAA,MACzB;AAAA,MACA,UAAU,CAAC,QAAQ;AACjB,cAAM,QAAQ,KAAK;AACnB,aAAK,SAAS,CAAC;AACf,aAAK,OAAO,KAAK,KAAK,KAAK,MAAM,KAAK,aAAa,OAAO,GAAG,CAAC;AAC9D,eAAO,KAAK;AAAA,MACd;AAAA,MACA,SAAS,CAAC,QAAQ;AAChB,aAAK,IAAI,MAAM,4BAA4B,EAAE,OAAO,IAAI,QAAQ,CAAC;AACjE,aAAK,MAAM,UAAU,GAAG;AAAA,MAC1B;AAAA,IACF,CAAC;AACD,SAAK,IAAI,KAAK,2BAA2B,EAAE,MAAM,KAAK,YAAY,KAAK,CAAC;AAAA,EAC1E;AAAA,EAEA,MAAM,OAAsB;AAC1B,QAAI,CAAC,KAAK,QAAS;AACnB,SAAK,UAAU;AACf,UAAM,KAAK,QAAQ,KAAK;AACxB,UAAM,KAAK;AACX,UAAM,KAAK,WAAW,KAAK;AAC3B,SAAK,IAAI,KAAK,yBAAyB;AAAA,EACzC;AAAA;AAAA,EAGU,0BAA6C;AACrD,WAAO,sBAAsB,KAAK,WAAW;AAAA,EAC/C;AAAA,EAEA,MAAc,aACZ,OACA,KACe;AACf,QAAI;AACF,eAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,KAAK,WAAW;AACrD,cAAM,KAAK,aAAa,MAAM,MAAM,GAAG,IAAI,KAAK,SAAS,CAAC;AAAA,MAC5D;AACA,YAAM,KAAK,QAAQ,YAAY,GAAG;AAAA,IACpC,SAAS,KAAK;AAGZ,YAAM,QAAQ,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC;AAChE,WAAK,IAAI,MAAM,6CAA6C;AAAA,QAC1D,OAAO,MAAM;AAAA,MACf,CAAC;AACD,WAAK,MAAM,UAAU,KAAK;AAAA,IAC5B;AAAA,EACF;AAAA,EAEA,MAAc,aAAa,OAAuC;AAChE,UAAM,UAAU,MAAM,IAAI,CAAC,MAAM,YAAY,EAAE,GAAG,CAAC;AACnD,UAAM,WAAW,MAAM,QAAQ;AAAA,MAC7B,QAAQ,IAAI,CAAC,MAAM,iBAAiB,GAAG,KAAK,UAAU,CAAC;AAAA,IACzD;AACA,UAAM,UAAU,MAAM,KAAK,UAAU,QAAQ,QAAQ;AACrD,UAAM,OAAO,IAAI,IAAI,QAAQ,IAAI,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;AAElD,UAAM,YAAsB,CAAC;AAC7B,eAAW,UAAU,SAAS;AAC5B,YAAM,SAAS,KAAK,IAAI,OAAO,QAAQ;AACvC,UAAI,CAAC,OAAQ;AACb,UAAI,OAAO,IAAI;AACb,kBAAU,KAAK,OAAO,EAAE;AACxB,aAAK,MAAM,cAAc,MAAM;AAAA,MACjC,OAAO;AAEL,cAAM,KAAK,MAAM,WAAW,OAAO,IAAI,MAAM,QAAQ;AACrD,aAAK,MAAM;AAAA,UACT;AAAA,UACA,OAAO,SAAS,IAAI,MAAM,gBAAgB;AAAA,UAC1C;AAAA,QACF;AAAA,MACF;AAAA,IACF;AACA,QAAI,KAAK,iBAAiB,UAAU,SAAS,GAAG;AAC9C,YAAM,KAAK,MAAM,SAAS,SAAS;AAAA,IACrC;AAAA,EACF;AACF;AAIA,SAAS,sBAAsB,QAA8C;AAE3E,MAAI,UAAe;AACnB,MAAI,UAAU;AACd,QAAM,QAAQ,OAAO,SAAS;AAE9B,SAAO;AAAA,IACL,MAAM,MAAM,UAAoD;AAC9D,YAAM,WAAW,OAAO,kBAAkB,OAAO,IAAI;AACrD,YAAM,MAAM,MAAM,gBAAgB;AAClC,gBAAU,IAAI,IAAI;AAAA,QAChB,EAAE,kBAAkB,OAAO,iBAAiB;AAAA,QAC5C,EAAE,aAAa,EAAE,MAAM,OAAO,gBAAgB,EAAE,EAAE;AAAA,MACpD;AACA,YAAM,SAAS,IAAI,IAAI,eAAe;AAAA,QACpC,cAAc;AAAA,QACd,kBAAkB,CAAC,OAAO,WAAW;AAAA,MACvC,CAAC;AAED,cAAQ,GAAG,QAAQ,CAAC,KAAa,QAAa;AAC5C,YAAI,KAAK,QAAQ,YAAY,KAAK,UAAU,SAAS,OAAO;AAC1D,mBAAS,SAAS,EAAE,KAAK,KAAK,aAAa,IAAI,GAAG,EAAE,CAAC;AAAA,QACvD,WAAW,KAAK,QAAQ,UAAU;AAChC,eAAK,SAAS,SAAS,GAAG;AAAA,QAC5B;AAAA,MACF,CAAC;AACD,cAAQ,GAAG,SAAS,CAAC,QAAe;AAClC,YAAI,QAAS;AACb,iBAAS,QAAQ,GAAG;AACpB,mBAAW,MAAM;AACf,cAAI,QAAS;AACb,kBACG,UAAU,QAAQ,OAAO,IAAI,EAC7B,MAAM,CAAC,MAAa,SAAS,QAAQ,CAAC,CAAC;AAAA,QAC5C,GAAG,GAAI;AAAA,MACT,CAAC;AACD,cACG,UAAU,QAAQ,OAAO,IAAI,EAC7B,MAAM,CAAC,MAAa;AACnB,YAAI,CAAC,QAAS,UAAS,QAAQ,CAAC;AAAA,MAClC,CAAC;AAAA,IACL;AAAA,IACA,MAAM,YAAY,KAA4B;AAC5C,UAAI,QAAS,OAAM,QAAQ,YAAY,GAAG;AAAA,IAC5C;AAAA,IACA,MAAM,OAAsB;AAC1B,gBAAU;AACV,UAAI,QAAS,OAAM,QAAQ,KAAK;AAAA,IAClC;AAAA,EACF;AACF;AAGA,SAAS,aAAa,KAAqB;AACzC,QAAM,OAAO,CAAC,MACZ,OAAO,MAAM,WAAW,KAAK,MAAM,CAAC,IAAI;AAC1C,SAAO;AAAA,IACL,IAAI,OAAO,IAAI,EAAE;AAAA,IACjB,YAAY,IAAI;AAAA,IAChB,gBAAgB,IAAI;AAAA,IACpB,cAAc,IAAI;AAAA,IAClB,OAAO,IAAI;AAAA,IACX,KAAK,IAAI,OAAO;AAAA,IAChB,SAAS,KAAK,IAAI,OAAO;AAAA,IACzB,SAAU,KAAK,IAAI,OAAO,KAAgC,CAAC;AAAA,IAC3D,UAAU,IAAI,YAAY;AAAA,IAC1B,QAAQ,OAAO,IAAI,MAAM;AAAA,IACzB,UAAU,OAAO,IAAI,QAAQ;AAAA,IAC7B,eAAe,IAAI,gBAAgB,IAAI,KAAK,IAAI,aAAa,IAAI;AAAA,IACjE,YAAY,IAAI,aAAa,IAAI,KAAK,IAAI,UAAU,IAAI,oBAAI,KAAK,CAAC;AAAA,IAClE,cAAc,IAAI,eAAe,IAAI,KAAK,IAAI,YAAY,IAAI;AAAA,EAChE;AACF;AAGA,eAAe,WACb,kBACA,MACe;AAEf,QAAM,KAAU,MAAM,OAAO,IAAI;AACjC,QAAM,SAAS,IAAI,GAAG,OAAO,EAAE,iBAAiB,CAAC;AACjD,QAAM,OAAO,QAAQ;AACrB,MAAI;AACF,UAAM,WAAW,MAAM,OAAO;AAAA,MAC5B;AAAA,MACA,CAAC,IAAI;AAAA,IACP;AACA,QAAI,SAAS,KAAK,WAAW,GAAG;AAC9B,YAAM,OAAO;AAAA,QACX;AAAA,QACA,CAAC,IAAI;AAAA,MACP;AAAA,IACF;AAAA,EACF,UAAE;AACA,UAAM,OAAO,IAAI;AAAA,EACnB;AACF;AAEA,eAAe,kBAKZ;AACD,MAAI;AAEF,WAAQ,MAAM,OAAO,wBAAwB;AAAA,EAC/C,QAAQ;AACN,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventferry/postgres",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "PostgreSQL store for @eventferry (polling + lock-free claim)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -52,10 +52,12 @@
52
52
  "engines": {
53
53
  "node": ">=18"
54
54
  },
55
+ "dependencies": {
56
+ "@eventferry/core": "3.3.0"
57
+ },
55
58
  "peerDependencies": {
56
59
  "pg": "^8.0.0",
57
- "pg-logical-replication": "^2.0.0",
58
- "@eventferry/core": "3.2.0"
60
+ "pg-logical-replication": "^2.0.0"
59
61
  },
60
62
  "peerDependenciesMeta": {
61
63
  "pg-logical-replication": {
@@ -69,7 +71,7 @@
69
71
  "tsup": "^8.3.5",
70
72
  "typescript": "^5.7.2",
71
73
  "vitest": "^2.1.8",
72
- "@eventferry/core": "3.2.0"
74
+ "@eventferry/core": "3.3.0"
73
75
  },
74
76
  "scripts": {
75
77
  "build": "tsup",