@eventferry/core 3.3.0 → 3.4.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/CHANGELOG.md ADDED
@@ -0,0 +1,315 @@
1
+ # @eventferry/core
2
+
3
+ ## 3.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fb0549d: Producer-fenced restart. `PRODUCER_FENCED` and `INVALID_PRODUCER_EPOCH` errors now classify as `errorKind: "fenced"` (previously bundled into `"fatal"`). The new kind is documented as **transient by default** — fences also fire on broker restart and network partition recovery, not only on multi-instance collisions.
8
+
9
+ New publisher option `autoRecoverFromFence: boolean` (default `false`): when on, a publish batch reporting at least one fenced result triggers exactly one `disconnect → connect → re-send same batch` cycle. Transactional producers re-run `initTransactions` as part of the reconnect. If the second send still reports any fenced record, the publisher gives up — silently retrying again would mask a real misconfiguration. Concurrent fenced publishes share a single in-flight reconnect so the producer is not torn down twice mid-restart.
10
+
11
+ New `KafkaPublisherHooks.onProducerFenced(error)` hook fires regardless of the recovery flag — informational signal so dashboards can track fence rates whether or not the publisher attempts recovery.
12
+
13
+ `@eventferry/core` minor: `PublishErrorKind` union gains `"fenced"`. The relay treats unknown / `"retriable"` / `"fenced"` identically (retry per backoff, DLQ on `attempts > maxAttempts`) — no relay-level changes required, but the new kind shows up in logs and the `errorKind` field of `PublishResult`.
14
+
15
+ Multi-instance EOS guidance: leave `autoRecoverFromFence` OFF and use a callable `transactionalId` that derives a stable, unique id per instance (pod name + replica index). Cross-instance fence is the broker telling the loser instance to stop — recovering silently creates a thrashing leadership flip. The README now spells this out in a `Producer-fenced restart` section.
16
+
17
+ ### Patch Changes
18
+
19
+ - 715523f: Consumer-side documentation. No API change. The root README gains:
20
+
21
+ - **`Consuming what eventferry produced`** — canonical loop showing `decode(message)` → `extractTraceContext(headers)` → `defineOutbox(registry).decode(topic, bytes)`. Same registry the producer used, in reverse, returns the typed validated payload.
22
+ - **`Consuming the DLQ`** — copy-paste handler that routes by `dlq-error-class` (cleaner than parsing `dlq-reason`), pulls `dlq-attempts` for retry-queue accounting, and shows the alert-vs-retry split.
23
+
24
+ The `@eventferry/kafka` README adds matching subsections under the existing `Consumer helpers` block: **`Typed payload via the producer-side registry`** and **`DLQ recipe`**.
25
+
26
+ `defineOutbox(registry).decode()` was already shipped — the round just makes the symmetric "same registry, both sides" pattern discoverable.
27
+
28
+ ## 3.3.1
29
+
30
+ ### Patch Changes
31
+
32
+ - 3c33f71: **chore: ship `CHANGELOG.md` inside the npm tarball**
33
+
34
+ Previously, each package's `files` allowlist contained only `"dist"` (and `"sql"` for `@eventferry/postgres`), so the auto-generated `CHANGELOG.md` was never published. Users browsing the package on npmjs.com or unpacking the tarball couldn't see release notes — they had to navigate to the GitHub repo.
35
+
36
+ This release adds `"CHANGELOG.md"` to the `files` array of every publishable package. Starting with this version, the per-version release notes are accessible:
37
+
38
+ - Directly in `node_modules/@eventferry/<pkg>/CHANGELOG.md` after `npm install`
39
+ - In the file listing on npmjs.com (under the "Code" / "Files" tab, depending on the npm UI)
40
+ - Inside the tarball downloaded from `https://registry.npmjs.org/...`
41
+
42
+ No code or API surface changes.
43
+
44
+ ## 3.3.0
45
+
46
+ ### Minor Changes
47
+
48
+ - cdc20cf: **feat: DLQ enrichment + backpressure runtime + quota multiplier — Tier 1 of the reliability gap closed**
49
+
50
+ ### DLQ enrichment
51
+
52
+ Records routed to the dead-letter queue now carry the full context an operator needs to triage:
53
+
54
+ | Header | Set by | Note |
55
+ | --------------------------- | --------- | ------------------------------------------------------------------------------------------------ |
56
+ | `original-topic` | relay | already existed |
57
+ | `dlq-reason` | publisher | already existed (`error.message`) |
58
+ | `dlq-failed-at` | publisher | already existed (ISO timestamp) |
59
+ | `dlq-error-class` | publisher | **new** — `error.name` / constructor name |
60
+ | `dlq-attempts` | relay | **new** — string-encoded `attempts` count |
61
+ | `dlq-original-aggregate-id` | relay | **new** — for joining with business state |
62
+ | `dlq-original-message-id` | relay | **new** — for dedup / idempotency lookups |
63
+ | `dlq-error-stack` | relay | **new** — opt-in via `DlqConfig.includeStackTraces`, truncated to `maxStackBytes` (default 4 KB) |
64
+
65
+ ```ts
66
+ new Relay({
67
+ store,
68
+ publisher,
69
+ dlq: { topic: "orders.dlq", includeStackTraces: true, maxStackBytes: 4096 },
70
+ });
71
+ ```
72
+
73
+ ### Backpressure runtime behavior
74
+
75
+ When the driver classifies a failure as `errorKind: "backpressure"` (client-side producer queue full), the relay no longer treats it like a regular retriable failure. Instead:
76
+
77
+ - The record is re-queued via the new `OutboxStore.requeue(id, retryAt)` method,
78
+ - `attempts` is **not incremented** — the buffer being full is a "slow down" signal, not the record's fault,
79
+ - The retry is scheduled `RetryConfig.backpressureDelayMs` ms ahead (default 1000 ms).
80
+
81
+ Stores that don't implement `requeue` fall back to `markFailed` (with attempts++); both `@eventferry/postgres` and `@eventferry/mysql` ship a real implementation.
82
+
83
+ ### Quota multiplier
84
+
85
+ When the driver classifies a failure as `errorKind: "quota"` (broker `THROTTLING_QUOTA_EXCEEDED`), the scheduled retry delay is multiplied by `RetryConfig.quotaMultiplier` (default 5) so the producer gives the broker breathing room. Quota failures DO count as attempts — after the budget is exhausted the record routes to DLQ + `dead`.
86
+
87
+ ### New / changed types
88
+
89
+ - `RetryConfig` gains `backpressureDelayMs?` and `quotaMultiplier?`.
90
+ - `DlqConfig` gains `includeStackTraces?` and `maxStackBytes?`.
91
+ - `OutboxStore.requeue?(recordId, retryAt)` is a new **optional** method. Stores without it fall through to `markFailed`.
92
+
93
+ ### Backward compatibility
94
+
95
+ Pure-additive everywhere. Default behavior matches the prior release:
96
+
97
+ - A `RetryConfig` without `backpressureDelayMs` uses 1000 ms (sensible default).
98
+ - A `DlqConfig` without `includeStackTraces` keeps DLQ messages small (default off).
99
+ - An `OutboxStore` without `requeue` falls back to `markFailed` — same as before, just with a documented quirk.
100
+
101
+ This closes the last three Tier 1 items in `docs/kafka-gap-analysis/reliability.md`. Phase A reliability surface is now ~100% complete.
102
+
103
+ ## 3.2.1
104
+
105
+ ### Patch Changes
106
+
107
+ - 9beb3e2: **chore: migrate to independent versioning (Astro pattern)**
108
+
109
+ Fixes the major-version inflation that produced four consecutive surprise majors (`1.0.4 → 2.0.0`, `2.0.0 → 3.0.0`, `3.0.0 → 4.0.0 corrected to 3.1.0`, `3.1.0 → 4.0.0 corrected to 3.2.0`) from changesets whose frontmatter only asked for `minor`.
110
+
111
+ **Root cause** (cited in [changesets/changesets#1759](https://github.com/changesets/changesets/issues/1759) and [docs/decisions.md](https://github.com/changesets/changesets/blob/main/docs/decisions.md)): the adapters listed `@eventferry/core` as a `peerDependency` with `workspace:*`. Changesets' documented rule is that an internal bump of a peer forces a major bump on the dependent — and the `fixed: [["@eventferry/*"]]` group reconciler then propagated that major across every package in the group.
112
+
113
+ **Fix** (exactly the [Astro config](https://github.com/withastro/astro/blob/main/.changeset/config.json)):
114
+
115
+ 1. `.changeset/config.json` — drop `fixed`, set `linked: []`, enable
116
+ `___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH.onlyUpdatePeerDependentsWhenOutOfRange: true`.
117
+ 2. Move `@eventferry/core` from `peerDependencies` to `dependencies` in
118
+ `@eventferry/postgres`, `@eventferry/mysql`, `@eventferry/kafka`, and
119
+ `@eventferry/schema-registry`. External user-facing peers (`pg`,
120
+ `mysql2`, `kafkajs`, `@confluentinc/kafka-javascript`,
121
+ `@kafkajs/confluent-schema-registry`) stay unchanged.
122
+
123
+ **Effect on releases.** Packages now evolve at independent semver tempos: a `core: minor` changeset produces `core@3.3.0` alongside `postgres@3.2.1` (patch, from "Updated dependencies"). No more major surprises. No more manual force-push corrections.
124
+
125
+ **Effect on consumers.** Pure-additive at the install boundary: `npm i @eventferry/kafka` now resolves `@eventferry/core` automatically (it's a regular dep). Previously consumers had to install it themselves as a peer; the typical flow already did this. No source-code changes required.
126
+
127
+ ## 3.2.0
128
+
129
+ ## 3.1.0
130
+
131
+ ### Minor Changes
132
+
133
+ - da39b08: **feat: producer tuning passthrough + per-message partition override + kafkajs partitioner choice**
134
+
135
+ ### Producer tuning
136
+
137
+ `KafkaPublisher` now accepts the full set of producer tuning knobs every serious Kafka deployment eventually needs:
138
+
139
+ ```ts
140
+ new KafkaPublisher({
141
+ driver: "confluent",
142
+ brokers,
143
+ lingerMs: 25, // ⚠ confluent only
144
+ batchSize: 131_072, // ⚠ confluent only
145
+ maxInFlightRequests: 5,
146
+ requestTimeoutMs: 30_000,
147
+ deliveryTimeoutMs: 120_000, // ⚠ confluent only
148
+ maxRequestSize: 2_000_000, // ⚠ confluent only
149
+ transactionTimeoutMs: 90_000,
150
+ });
151
+ ```
152
+
153
+ **Driver asymmetry:** `kafkajs` has no producer-level config for `lingerMs`, `batchSize`, `deliveryTimeoutMs`, or `maxRequestSize` — its batching is sticky-partitioner + hardcoded internals. The typed API stays uniform; on the kafkajs driver, those four knobs log a **one-time** warning (deduped process-wide) and are otherwise ignored. For fine-grained tuning, switch to the confluent driver.
154
+
155
+ ### Per-message partition override
156
+
157
+ `PublishableMessage` gains an optional `partition?: number` field. When set, the publisher routes that record to the exact partition, bypassing the configured partitioner. Use cases: compacted topics with application-managed sharding, tenant-affinity routing, geo-pinning. Both drivers honor it.
158
+
159
+ ### kafkajs partitioner choice
160
+
161
+ Silences the noisy `KafkaJSPartitionerNotSpecified` warning kafkajs v2 emits on every producer instance, by letting you pick a partitioner explicitly:
162
+
163
+ ```ts
164
+ new KafkaPublisher({
165
+ driver: "kafkajs",
166
+ brokers,
167
+ partitioner: "java-compatible", // (default) | "legacy" | "default"
168
+ });
169
+ ```
170
+
171
+ - `"java-compatible"` is the new greenfield default (matches the Java client's murmur2).
172
+ - `"legacy"` preserves pre-v2 hash continuity for existing topics.
173
+ - `"default"` follows kafkajs's current default.
174
+
175
+ ### Backward compatibility
176
+
177
+ Pure-additive. Existing call sites continue to work unchanged; the partitioner-choice default (`"java-compatible"`) is what kafkajs v2's migration guide recommends for new producers.
178
+
179
+ ## 3.0.0
180
+
181
+ ### Minor Changes
182
+
183
+ - f0c7483: **feat: error classification for smarter retry, DLQ, and pause behavior**
184
+
185
+ Publisher implementations can now tag each failed `PublishResult` with an `errorKind` so the relay knows whether the error is worth retrying.
186
+
187
+ **New in `@eventferry/core`:**
188
+
189
+ - `PublishErrorKind = "retriable" | "fatal" | "poison" | "backpressure" | "quota"` — opt-in classification surface on `PublishResult.errorKind`.
190
+ - The `Relay` now reads `errorKind`:
191
+ - `"fatal"` (auth denied, fenced epoch, transactional id rejected) and `"poison"` (oversized record, corrupt payload, schema rejected) **short-circuit retries** straight to the DLQ + `dead` status. No more burning the retry budget on errors that cannot succeed.
192
+ - `"retriable"`, `"backpressure"`, `"quota"`, and absent (`undefined`) continue to use the existing backoff schedule, preserving backward compatibility. Smarter `backpressure` / `quota` handling (pause polling, longer backoff) is planned for a follow-up release.
193
+
194
+ **New in `@eventferry/kafka`:**
195
+
196
+ - `classifyKafkajsError(err): PublishErrorKind` — maps the most-common `KafkaJSProtocolError` types/codes and the `KafkaJSConnectionError` / `KafkaJSRequestTimeoutError` / `KafkaJSNonRetriableError` subclasses to a category. Verified against `kafkajs/src/errors.js`.
197
+ - `classifyConfluentError(err): PublishErrorKind` — maps the librdkafka `RD_KAFKA_RESP_ERR_*` codes (both negative internal codes and Kafka wire-protocol codes) to a category. Verified against `librdkafka/src/rdkafka.h`. Includes the dedicated `"backpressure"` mapping for `ERR__QUEUE_FULL` (-184) and `"quota"` for `ERR_THROTTLING_QUOTA_EXCEEDED` (89).
198
+ - Both drivers (`KafkaJsDriver`, `ConfluentDriver`) now call their respective classifier in the catch path and emit the `errorKind` on every failed `PublishResult`.
199
+
200
+ **Backward compatibility:** `errorKind` is optional everywhere. Existing publisher implementations that don't set it continue to work unchanged — the relay treats absent `errorKind` as `"retriable"`, which is what the relay did before this change.
201
+
202
+ **Migration:** none required.
203
+
204
+ ## 2.0.0
205
+
206
+ ## 1.0.4
207
+
208
+ ### Patch Changes
209
+
210
+ - 64d115d: docs / metadata: expand `keywords` on all packages for better npm and LLM discoverability (outbox-pattern, dual-write, cdc, event-driven, microservices, etc.). No code changes.
211
+
212
+ ## 1.0.3
213
+
214
+ ### Patch Changes
215
+
216
+ - aaca9a2: docs: use a non-expiring `2026-present` copyright year in LICENSE and a static MIT license badge in the README
217
+
218
+ ## 1.0.2
219
+
220
+ ### Patch Changes
221
+
222
+ - 89f1867: Declare `engines.node` (>=18) so npm shows the supported Node version and tooling can warn on unsupported runtimes.
223
+
224
+ ## 1.0.1
225
+
226
+ ### Patch Changes
227
+
228
+ - docs: polish per-package READMEs (npm page content). No code changes.
229
+
230
+ ## 1.0.0
231
+
232
+ ### Minor Changes
233
+
234
+ - b06f8ec: Add a low-latency notify-driven relay (Postgres `LISTEN`/`NOTIFY`).
235
+
236
+ - **core:** new `Waker` interface and an optional `Relay({ waker })`. The relay's
237
+ idle wait is now interruptible — when the waker signals, it claims immediately
238
+ instead of sleeping out `pollIntervalMs`. With no waker, behavior is unchanged.
239
+ - **postgres:** `PostgresNotifyWaker` holds a dedicated `LISTEN` connection and
240
+ wakes the relay on each notification, reconnecting with backoff if it drops.
241
+ `createNotifyTriggerSql(table, channel)` emits an `AFTER INSERT FOR EACH STATEMENT`
242
+ trigger that `pg_notify`s on commit (empty payload — the relay re-claims).
243
+ - Polling remains the safety net: a missed notification is caught by the next poll,
244
+ so no event is lost. All ordering/retry/DLQ/crash-recovery guarantees are unchanged.
245
+ - No new dependencies (`LISTEN`/`NOTIFY` is native to `pg`).
246
+
247
+ - b06f8ec: Add a streaming relay that publishes straight from the Postgres WAL (logical replication).
248
+
249
+ - **postgres:** `PostgresStreamingRelay` consumes INSERTs on the outbox table via
250
+ `pg-logical-replication` + `pgoutput` (built-in, no DB extension) and publishes them
251
+ with no claim query on the happy path — lower DB load than the notify waker. A failed
252
+ publish is demoted to `failed`; an internal claim-based retry loop drains it with the
253
+ existing backoff / DLQ / dead handling. `pg-logical-replication` is a new **optional**
254
+ peer dependency, loaded only in streaming mode.
255
+ - **postgres:** `PostgresStore` gains `claimFailedOnly` (claims only `failed`/timed-out
256
+ `processing` rows, never `pending`) so the stream owns pending rows with no duplication.
257
+ `createPublicationSql(table, publication)` emits an idempotent insert-only publication.
258
+ - **core:** the record→message builder is extracted as `buildPublishable(record,
259
+ serializer)` and shared by `Relay` and the streaming relay (no behavior change).
260
+ - **At-least-once:** the slot's LSN is acknowledged only after a batch's side effects
261
+ commit; a crash re-streams and re-publishes (idempotent consumers absorb the duplicate).
262
+ - **Ordering:** streaming is best-effort per-aggregate (a retried failure may land after
263
+ later same-aggregate rows). Use the polling relay for the strict head-of-aggregate
264
+ guarantee. Requires `wal_level = logical`.
265
+
266
+ - b06f8ec: Strict per-aggregate ordering, crash recovery, and driver/packaging fixes.
267
+
268
+ - **postgres:** the claim query now enforces strict per-aggregate ordering by
269
+ only taking the _head_ of each aggregate (no earlier unfinished row for the
270
+ same `aggregateId`). At most one in-flight message per aggregate; failed
271
+ messages block their successors until resolved.
272
+ - **postgres:** added a `claimed_at` column and a visibility-timeout reaper
273
+ (`claimTimeoutMs`, default 60s) so rows orphaned by a crashed relay are
274
+ reclaimed instead of stuck in `processing` forever. Migration is upgrade-safe
275
+ (`ADD COLUMN IF NOT EXISTS`); the partial indexes were retuned for the new
276
+ ordered, reaper-aware claim.
277
+ - **core:** dead-lettered messages now carry the real `original-topic` header
278
+ (previously always empty); `ConsoleLogger` routes warn/error to the matching
279
+ `console` methods.
280
+ - **kafka:** the confluent driver now honors `acks` and `compression` (it
281
+ silently ignored them before), matching the kafkajs driver.
282
+ - **packaging:** the `@eventferry/postgres/migrations` subpath export now
283
+ advertises its types; `pnpm-workspace.yaml` dropped an invalid placeholder
284
+ block.
285
+
286
+ Note: `claimTimeoutMs` should exceed your worst-case publish latency. This is
287
+ an at-least-once system — pair it with idempotent producers/consumers.
288
+
289
+ - b06f8ec: Add a type-safe event registry: `defineOutbox`.
290
+
291
+ Declare each topic once (`{ aggregateType, schema }`) and get a typed, runtime-
292
+ validated `enqueue` plus a `decode` helper consumers can reuse from the same
293
+ registry. Payloads are validated before the row is inserted, so a malformed event
294
+ rolls back with the rest of your transaction instead of reaching the outbox.
295
+
296
+ - **Validator-agnostic:** any [Standard Schema](https://standardschema.dev) works
297
+ (Zod 3.24+, Valibot, ArkType, …). The spec interface is inlined, so `@eventferry/core`
298
+ gains no runtime dependency.
299
+ - **Producer + consumer:** `defineOutbox(registry, { store })` exposes typed
300
+ `enqueue`; `defineOutbox(registry)` (no store) exposes `decode`/`validate` for
301
+ consuming services.
302
+ - New `OutboxValidationError` carries the failing topic and the validator's issues.
303
+ - Purely additive — `PostgresStore`, `Relay`, and untyped `store.enqueue` are unchanged.
304
+
305
+ - b06f8ec: Add W3C trace propagation (OpenTelemetry-compatible), dependency-free.
306
+
307
+ - **core:** new `Tracing` interface (`inject(carrier)`), the shape of an OpenTelemetry
308
+ `TextMapPropagator` — the library depends on no tracing package.
309
+ - **postgres:** `PostgresStore({ tracing })` captures the active W3C
310
+ `traceparent`/`tracestate` into the row's headers at `enqueue`, so it rides along to
311
+ the published message (on every path — polling, notify, streaming — since headers
312
+ already pass through) and the consumer can continue the trace.
313
+ - The caller's `headers` object is never mutated. With no `tracing` configured,
314
+ behavior is unchanged. The existing `trace-id` header stays for simple correlation.
315
+ - OpenTelemetry/Datadog/custom integrate via a ~5-line adapter (documented, not bundled).
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/types.ts","../src/backoff.ts","../src/serializer.ts","../src/publishable.ts","../src/relay.ts","../src/errors.ts","../src/registry.ts"],"sourcesContent":["export * from \"./types.js\";\nexport * from \"./backoff.js\";\nexport * from \"./serializer.js\";\nexport * from \"./publishable.js\";\nexport * from \"./relay.js\";\nexport * from \"./standard-schema.js\";\nexport * from \"./errors.js\";\nexport * from \"./registry.js\";\n","/**\n * Status lifecycle of an outbox record.\n *\n * pending -> freshly enqueued, awaiting first publish\n * processing -> claimed by a relay instance, publish in flight\n * done -> successfully published to the broker\n * failed -> publish failed, awaiting retry (next_retry_at)\n * dead -> exhausted retries, routed to DLQ (or parked)\n */\nexport type OutboxStatus = \"pending\" | \"processing\" | \"done\" | \"failed\" | \"dead\";\n\nexport const OUTBOX_STATUS_CODE: Record<OutboxStatus, number> = {\n pending: 0,\n processing: 1,\n done: 2,\n failed: 3,\n dead: 4,\n};\n\nexport const OUTBOX_STATUS_FROM_CODE: Record<number, OutboxStatus> = {\n 0: \"pending\",\n 1: \"processing\",\n 2: \"done\",\n 3: \"failed\",\n 4: \"dead\",\n};\n\n/**\n * A message as enqueued by the application inside its own DB transaction.\n * This is the write-side input — no infra concerns leak in here.\n */\nexport interface OutboxMessageInput {\n /** Logical topic the message will be published to. */\n topic: string;\n /** Type of the aggregate that produced this event (e.g. \"order\"). */\n aggregateType: string;\n /**\n * Identifier of the aggregate instance (e.g. order id).\n * Used as the default partition key to preserve per-aggregate ordering.\n */\n aggregateId: string;\n /** Event payload. Serialized by the configured serializer. */\n payload: unknown;\n /** Optional explicit partition key. Falls back to aggregateId. */\n key?: string;\n /** Optional message headers. */\n headers?: Record<string, string>;\n /**\n * Optional client-supplied message id for idempotency / dedup.\n * If omitted, the store generates one.\n */\n messageId?: string;\n}\n\n/**\n * A persisted outbox record as read back by the relay.\n */\nexport interface OutboxRecord {\n id: string;\n messageId: string;\n topic: string;\n aggregateType: string;\n aggregateId: string;\n key: string | null;\n payload: unknown;\n headers: Record<string, string>;\n traceId: string | null;\n status: OutboxStatus;\n attempts: number;\n nextRetryAt: Date | null;\n createdAt: Date;\n processedAt: Date | null;\n}\n\n/**\n * The message handed to a Publisher after serialization.\n */\nexport interface PublishableMessage {\n topic: string;\n key: string | null;\n /** Serialized payload bytes. */\n value: Buffer;\n headers: Record<string, string>;\n /** Original record id, for correlation in publish results. */\n recordId: string;\n messageId: string;\n /**\n * Explicit partition override. When set, the publisher MUST route the\n * record to this exact partition, bypassing the configured partitioner.\n *\n * Use cases:\n * - Compacted topics with application-managed sharding.\n * - Tenant-affinity routing where you compute the partition yourself.\n * - Geo-pinning records to a specific broker.\n *\n * When omitted (the default), the underlying client's partitioner\n * decides — usually a hash of `key`, falling back to sticky round-robin\n * when `key` is null.\n */\n partition?: number;\n}\n\n/**\n * Why a publish failed, in terms the relay can act on. Drivers classify their\n * native errors into one of these buckets; the relay reads `errorKind` to\n * decide whether to retry, short-circuit to the DLQ, or pause polling. The\n * field is optional for backward compatibility — when absent, the relay\n * treats the error as `\"retriable\"`.\n *\n * - `retriable` — transient (broker unreachable, leader election, request\n * timeout); retry per the configured backoff policy. The default for any\n * unclassified error.\n * - `fatal` — the producer or the credentials are broken (fenced epoch,\n * authentication failed, ACL denied). Retrying cannot help; the relay\n * short-circuits straight to the DLQ + `dead` status.\n * - `poison` — the message itself is rejectable by every broker\n * (oversized record, corrupt payload, schema-registry refused encoding).\n * Same handling as `fatal`: DLQ + dead, no retries.\n * - `backpressure` — the *producer's own* outbound buffer is full\n * (librdkafka `__QUEUE_FULL`). The right response is to slow the relay\n * down, not to burn retries. v2.1 treats this as `retriable` for\n * compatibility; smarter handling (pause polling) is planned.\n * - `quota` — the broker is throttling us (`THROTTLING_QUOTA_EXCEEDED`).\n * Back off with longer delays. v2.1 treats as `retriable`; smarter\n * handling (longer backoff) is planned.\n */\nexport type PublishErrorKind =\n | \"retriable\"\n | \"fatal\"\n | \"poison\"\n | \"backpressure\"\n | \"quota\";\n\n/**\n * Result of attempting to publish a single message.\n */\nexport interface PublishResult {\n recordId: string;\n ok: boolean;\n error?: Error;\n /**\n * Optional classification of `error` for relay-level decision-making. Set\n * by publisher implementations that know how to inspect their native error\n * shapes. Absent value is treated as `\"retriable\"` by the relay (the safe\n * default — at worst we retry an error we should have skipped).\n */\n errorKind?: PublishErrorKind;\n}\n\n/**\n * Pluggable serializer. Default is JSON; users can swap in\n * Avro / Protobuf / Schema-Registry-backed serializers.\n */\nexport interface Serializer {\n serialize(message: OutboxRecord): Buffer | Promise<Buffer>;\n /** Content-type header value advertised for this serializer. */\n readonly contentType: string;\n}\n\n/**\n * Storage abstraction. Implemented per-database (postgres, mysql, ...).\n * The relay only talks to the store through this interface.\n */\nexport interface OutboxStore {\n /**\n * Atomically claim up to `batchSize` due messages and mark them\n * as `processing`. Implementations MUST be safe under concurrent\n * relay instances (e.g. SELECT ... FOR UPDATE SKIP LOCKED).\n */\n claimBatch(batchSize: number): Promise<OutboxRecord[]>;\n\n /** Mark records as successfully published. */\n markDone(recordIds: string[]): Promise<void>;\n\n /**\n * Mark a record as failed and schedule its next retry.\n * `nextRetryAt` of null + status \"dead\" means terminal.\n * Implementations MUST increment `attempts` so the retry budget is honored.\n */\n markFailed(\n recordId: string,\n nextRetryAt: Date | null,\n status: \"failed\" | \"dead\",\n ): Promise<void>;\n\n /**\n * Re-queue a record back to `failed` with the given `retryAt` **WITHOUT\n * incrementing attempts**. Used by the relay for backpressure handling —\n * a client-side queue-full failure is a \"slow down\" signal, not a\n * record-specific failure, and counting it as a retry would burn the\n * attempt budget unfairly.\n *\n * Optional: stores that don't implement this fall back to `markFailed`\n * (which increments attempts, with the caveat documented above).\n */\n requeue?(recordId: string, retryAt: Date): Promise<void>;\n\n /** Best-effort lifecycle hooks; no-op allowed. */\n init?(): Promise<void>;\n close?(): Promise<void>;\n}\n\n/**\n * Broker abstraction. Implemented per-driver (kafkajs, confluent, ...).\n */\nexport interface Publisher {\n connect(): Promise<void>;\n disconnect(): Promise<void>;\n /**\n * Publish a batch. Returns a per-message result so the relay can\n * mark partial success. Implementations may use a transactional\n * producer to make the batch atomic.\n */\n publish(messages: PublishableMessage[]): Promise<PublishResult[]>;\n /** Route a permanently-failed message to a dead-letter destination. */\n publishToDlq?(message: PublishableMessage, error: Error): Promise<void>;\n}\n\n/**\n * Backoff strategy for retrying failed publishes.\n */\nexport type BackoffStrategy = \"fixed\" | \"linear\" | \"exponential\";\n\nexport interface RetryConfig {\n maxAttempts: number;\n strategy: BackoffStrategy;\n baseMs: number;\n maxMs: number;\n /** Add random jitter (0..baseMs) to avoid thundering herd. Default true. */\n jitter?: boolean;\n /**\n * Delay (ms) before re-queueing a record whose publish was rejected with\n * `errorKind: \"backpressure\"` (client-side producer buffer full).\n *\n * Backpressure failures do NOT count as a failed attempt — the buffer\n * being full is a \"slow down\" signal, not a record-specific failure.\n * The record is requeued at the next interval, not promoted to dead.\n *\n * Default 1000 ms. Requires the {@link OutboxStore} to implement\n * `requeue` — stores without it fall back to {@link OutboxStore.markFailed}\n * which DOES increment attempts.\n */\n backpressureDelayMs?: number;\n /**\n * Multiplier applied to the computed backoff for records rejected with\n * `errorKind: \"quota\"` (broker is throttling the producer). Default 5 —\n * a quota signal asks for a longer breath than a generic transient error.\n * Quota failures DO count as attempts (unlike backpressure).\n */\n quotaMultiplier?: number;\n}\n\nexport interface DlqConfig {\n /** Topic to route dead messages to. If absent, dead messages are parked. */\n topic?: string;\n /**\n * Include a truncated stack trace as the `dlq-error-stack` header when\n * routing a record to the DLQ. Default false — keep DLQ messages small\n * by default; opt in if your triage workflow needs the stack.\n */\n includeStackTraces?: boolean;\n /**\n * Maximum bytes of the truncated stack trace included when\n * `includeStackTraces` is on. Default 4096.\n */\n maxStackBytes?: number;\n}\n\n/**\n * Optional trace-context propagator. `inject` writes the active trace context\n * into the carrier as W3C `traceparent`/`tracestate` (the shape of an\n * OpenTelemetry TextMapPropagator), so it is persisted with the outbox row and\n * carried to the published message. The library depends on no tracing package;\n * provide a thin adapter over yours (OpenTelemetry, Datadog, …).\n */\nexport interface Tracing {\n inject(carrier: Record<string, string>): void;\n}\n\n/**\n * Minimal structured logger. Console-backed default provided.\n */\nexport interface Logger {\n debug(msg: string, meta?: Record<string, unknown>): void;\n info(msg: string, meta?: Record<string, unknown>): void;\n warn(msg: string, meta?: Record<string, unknown>): void;\n error(msg: string, meta?: Record<string, unknown>): void;\n}\n\n/**\n * A low-latency wake source for the relay. Instead of only waking on the poll\n * interval, the relay claims immediately whenever `onWake` fires. The signal is\n * advisory: it may fire spuriously or be missed entirely, and the relay's\n * polling remains the safety net, so implementations need not deduplicate or\n * guarantee delivery. (e.g. a Postgres LISTEN/NOTIFY waker.)\n */\nexport interface Waker {\n start(onWake: () => void): Promise<void>;\n stop(): Promise<void>;\n}\n\n/**\n * Lifecycle / observability hooks emitted by the relay.\n */\nexport interface RelayHooks {\n onBatchClaimed?(count: number): void;\n onPublished?(result: PublishResult): void;\n onFailed?(record: OutboxRecord, error: Error, willRetry: boolean): void;\n onDead?(record: OutboxRecord, error: Error): void;\n onError?(error: Error): void;\n}\n","import type { RetryConfig } from \"./types.js\";\n\n/**\n * Compute the delay (ms) before the next retry attempt.\n *\n * @param attempt 1-based attempt number that just failed.\n */\nexport function computeBackoff(config: RetryConfig, attempt: number): number {\n const { strategy, baseMs, maxMs } = config;\n const jitter = config.jitter ?? true;\n\n let delay: number;\n switch (strategy) {\n case \"fixed\":\n delay = baseMs;\n break;\n case \"linear\":\n delay = baseMs * attempt;\n break;\n case \"exponential\":\n // base * 2^(attempt-1), capped to avoid overflow before clamping\n delay = baseMs * 2 ** Math.min(attempt - 1, 30);\n break;\n }\n\n delay = Math.min(delay, maxMs);\n\n if (jitter) {\n // Full jitter: random in [0, delay]. Decorrelates concurrent relays.\n delay = Math.random() * delay;\n }\n\n return Math.floor(delay);\n}\n\n/**\n * Resolve when (Date) the next retry should occur, or null if the\n * record has exhausted its attempts and should go dead.\n */\nexport function nextRetryAt(\n config: RetryConfig,\n attempts: number,\n now: Date = new Date(),\n): Date | null {\n if (attempts >= config.maxAttempts) return null;\n const delay = computeBackoff(config, attempts);\n return new Date(now.getTime() + delay);\n}\n","import type { Logger, OutboxRecord, Serializer } from \"./types.js\";\n\n/**\n * Default serializer: JSON-encodes the record payload to UTF-8 bytes.\n */\nexport class JsonSerializer implements Serializer {\n readonly contentType = \"application/json\";\n\n serialize(record: OutboxRecord): Buffer {\n return Buffer.from(JSON.stringify(record.payload), \"utf8\");\n }\n}\n\n/**\n * Minimal console-backed logger. Swap in pino/winston by implementing Logger.\n */\nexport class ConsoleLogger implements Logger {\n constructor(private readonly prefix = \"[outbox]\") {}\n\n private fmt(\n write: (line: string, meta?: Record<string, unknown>) => void,\n level: string,\n msg: string,\n meta?: Record<string, unknown>,\n ): void {\n const line = `${this.prefix} ${level} ${msg}`;\n if (meta) {\n write(line, meta);\n } else {\n write(line);\n }\n }\n\n debug(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.debug, \"DEBUG\", msg, meta);\n }\n info(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.info, \"INFO\", msg, meta);\n }\n warn(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.warn, \"WARN\", msg, meta);\n }\n error(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.error, \"ERROR\", msg, meta);\n }\n}\n\n/** Logger that discards everything. Useful for tests. */\nexport class NoopLogger implements Logger {\n debug(): void {}\n info(): void {}\n warn(): void {}\n error(): void {}\n}\n","import type { OutboxRecord, PublishableMessage, Serializer } from \"./types.js\";\n\n/**\n * Turn a persisted outbox record into a broker-ready message: serialize the\n * payload and attach the standard correlation headers. Shared by the polling\n * `Relay` and the streaming relay so both produce byte-identical messages.\n */\nexport async function buildPublishable(\n record: OutboxRecord,\n serializer: Serializer,\n): Promise<PublishableMessage> {\n const value = await serializer.serialize(record);\n const headers: Record<string, string> = {\n ...record.headers,\n \"content-type\": serializer.contentType,\n \"message-id\": record.messageId,\n \"aggregate-type\": record.aggregateType,\n \"aggregate-id\": record.aggregateId,\n };\n if (record.traceId) headers[\"trace-id\"] = record.traceId;\n\n return {\n topic: record.topic,\n key: record.key ?? record.aggregateId,\n value,\n headers,\n recordId: record.id,\n messageId: record.messageId,\n };\n}\n","import { computeBackoff } from \"./backoff.js\";\nimport { buildPublishable } from \"./publishable.js\";\nimport { ConsoleLogger, JsonSerializer } from \"./serializer.js\";\nimport type {\n DlqConfig,\n Logger,\n OutboxRecord,\n OutboxStore,\n PublishableMessage,\n PublishErrorKind,\n Publisher,\n RelayHooks,\n RetryConfig,\n Serializer,\n Waker,\n} from \"./types.js\";\n\nexport interface RelayOptions {\n store: OutboxStore;\n publisher: Publisher;\n /** Messages claimed per poll iteration. Default 100. */\n batchSize?: number;\n /** Idle wait (ms) when a poll returns no work. Default 200. */\n pollIntervalMs?: number;\n retry?: Partial<RetryConfig>;\n dlq?: DlqConfig;\n serializer?: Serializer;\n logger?: Logger;\n hooks?: RelayHooks;\n /**\n * Optional low-latency wake source. When provided, the relay claims as soon as\n * the waker signals new work, instead of waiting out `pollIntervalMs`. Polling\n * stays on as a safety net, so set `pollIntervalMs` longer when using a waker.\n */\n waker?: Waker;\n}\n\nconst DEFAULT_RETRY: RetryConfig = {\n maxAttempts: 5,\n strategy: \"exponential\",\n baseMs: 200,\n maxMs: 30_000,\n jitter: true,\n};\n\n/**\n * The Relay drains the outbox store and publishes messages to the broker.\n *\n * It is safe to run multiple Relay instances concurrently against the same\n * store as long as the store's claimBatch uses a lock-free claim strategy\n * (e.g. SELECT ... FOR UPDATE SKIP LOCKED).\n */\nexport class Relay {\n private readonly store: OutboxStore;\n private readonly publisher: Publisher;\n private readonly batchSize: number;\n private readonly pollIntervalMs: number;\n private readonly retry: RetryConfig;\n private readonly dlq: DlqConfig;\n private readonly serializer: Serializer;\n private readonly log: Logger;\n private readonly hooks: RelayHooks;\n private readonly waker: Waker | null;\n\n private running = false;\n private stopping = false;\n private loopPromise: Promise<void> | null = null;\n\n // Interruptible idle wait: `signal()` wakes a pending wait (or marks one\n // pending if none is in flight, so a wake can't be lost between cycles).\n private wakePending = false;\n private wakeResolver: (() => void) | null = null;\n\n constructor(opts: RelayOptions) {\n this.store = opts.store;\n this.publisher = opts.publisher;\n this.batchSize = opts.batchSize ?? 100;\n this.pollIntervalMs = opts.pollIntervalMs ?? 200;\n this.retry = { ...DEFAULT_RETRY, ...opts.retry };\n this.dlq = opts.dlq ?? {};\n this.serializer = opts.serializer ?? new JsonSerializer();\n this.log = opts.logger ?? new ConsoleLogger();\n this.hooks = opts.hooks ?? {};\n this.waker = opts.waker ?? null;\n }\n\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n this.stopping = false;\n\n await this.store.init?.();\n await this.publisher.connect();\n await this.waker?.start(() => this.signal());\n this.log.info(\"relay started\", {\n batchSize: this.batchSize,\n pollIntervalMs: this.pollIntervalMs,\n waker: this.waker !== null,\n });\n\n this.loopPromise = this.loop();\n }\n\n /**\n * Stop accepting new work, finish the in-flight batch, then disconnect.\n */\n async stop(): Promise<void> {\n if (!this.running) return;\n this.stopping = true;\n this.signal(); // break any in-flight idle wait so shutdown is immediate\n this.log.info(\"relay stopping, draining in-flight batch\");\n await this.loopPromise;\n await this.waker?.stop();\n await this.publisher.disconnect();\n await this.store.close?.();\n this.running = false;\n this.log.info(\"relay stopped\");\n }\n\n private async loop(): Promise<void> {\n while (!this.stopping) {\n try {\n const processed = await this.tick();\n if (processed === 0 && !this.stopping) {\n await this.waitForWork(this.pollIntervalMs);\n }\n } catch (err) {\n const error = err instanceof Error ? err : new Error(String(err));\n this.log.error(\"relay loop error\", { error: error.message });\n this.hooks.onError?.(error);\n await this.waitForWork(this.pollIntervalMs);\n }\n }\n }\n\n /**\n * Run a single claim+publish cycle. Returns number of records processed.\n * Exposed for tests / manual single-shot draining.\n */\n async tick(): Promise<number> {\n const batch = await this.store.claimBatch(this.batchSize);\n if (batch.length === 0) return 0;\n\n this.hooks.onBatchClaimed?.(batch.length);\n this.log.debug(\"batch claimed\", { count: batch.length });\n\n const messages = await this.toPublishable(batch);\n const recordsById = new Map(batch.map((r) => [r.id, r]));\n\n const results = await this.publisher.publish(messages);\n\n const succeeded: string[] = [];\n for (const result of results) {\n const record = recordsById.get(result.recordId);\n if (!record) continue;\n\n if (result.ok) {\n succeeded.push(record.id);\n this.hooks.onPublished?.(result);\n } else {\n await this.handleFailure(\n record,\n result.error ?? new Error(\"unknown publish error\"),\n result.errorKind,\n );\n }\n }\n\n if (succeeded.length > 0) {\n await this.store.markDone(succeeded);\n }\n\n return batch.length;\n }\n\n private async handleFailure(\n record: OutboxRecord,\n error: Error,\n errorKind?: PublishErrorKind,\n ): Promise<void> {\n // Backpressure: client-side producer queue is full. Re-queue the record\n // with a brief delay; do NOT increment attempts (this isn't the record's\n // fault, the producer is just full). Stores without `requeue` fall back\n // to markFailed, accepting the attempt-counter hit.\n if (errorKind === \"backpressure\") {\n const delay = this.retry.backpressureDelayMs ?? 1000;\n const retryAt = new Date(Date.now() + delay);\n this.hooks.onFailed?.(record, error, true);\n this.log.warn(\"publish backpressure — requeueing without bumping attempts\", {\n recordId: record.id,\n retryAt: retryAt.toISOString(),\n errorKind,\n error: error.message,\n });\n if (this.store.requeue) {\n await this.store.requeue(record.id, retryAt);\n } else {\n // Fallback: markFailed (will increment attempts). Documented in the\n // OutboxStore.requeue JSDoc.\n await this.store.markFailed(record.id, retryAt, \"failed\");\n }\n return;\n }\n\n const attempts = record.attempts + 1;\n // fatal/poison short-circuit: retrying cannot help (auth denied, fenced\n // epoch, oversized record, schema rejected). Skip the backoff schedule\n // entirely and go straight to DLQ + dead.\n const isTerminalKind = errorKind === \"fatal\" || errorKind === \"poison\";\n const retryAt = isTerminalKind\n ? null\n : this.nextRetryAtForKind(attempts, errorKind);\n const willRetry = retryAt !== null;\n\n this.hooks.onFailed?.(record, error, willRetry);\n this.log.warn(\"publish failed\", {\n recordId: record.id,\n attempts,\n willRetry,\n errorKind: errorKind ?? \"retriable\",\n error: error.message,\n });\n\n if (willRetry) {\n await this.store.markFailed(record.id, retryAt, \"failed\");\n return;\n }\n\n // Terminal: route to DLQ if configured, then mark dead.\n if (this.dlq.topic && this.publisher.publishToDlq) {\n try {\n const msg = (await this.toPublishable([record]))[0];\n if (msg) {\n await this.publisher.publishToDlq(\n {\n ...msg,\n topic: this.dlq.topic,\n // Per-record enrichment so the DLQ consumer has everything\n // needed for triage: original destination, aggregate / message\n // identity, the attempts count, and (opt-in) a truncated stack.\n headers: this.buildDlqHeaders(record, error, msg.headers),\n },\n error,\n );\n }\n } catch (dlqErr) {\n const e = dlqErr instanceof Error ? dlqErr : new Error(String(dlqErr));\n this.log.error(\"DLQ publish failed\", {\n recordId: record.id,\n error: e.message,\n });\n }\n }\n\n await this.store.markFailed(record.id, null, \"dead\");\n this.hooks.onDead?.(record, error);\n }\n\n /**\n * Compute the next retry instant, applying the quota multiplier when the\n * driver classified the failure as a server throttle. Quota failures DO\n * count as attempts (unlike backpressure) — the multiplier only stretches\n * the delay, not the budget.\n */\n private nextRetryAtForKind(\n attempts: number,\n errorKind: PublishErrorKind | undefined,\n ): Date | null {\n if (attempts >= this.retry.maxAttempts) return null;\n const baseDelay = computeBackoff(this.retry, attempts);\n const multiplier =\n errorKind === \"quota\" ? this.retry.quotaMultiplier ?? 5 : 1;\n const delay = Math.min(baseDelay * multiplier, this.retry.maxMs * 10);\n return new Date(Date.now() + delay);\n }\n\n /**\n * Build the per-record DLQ header bag. Operators triaging the DLQ get the\n * original destination, the aggregate / message identity, attempts count,\n * and optionally a truncated error stack — everything you need to decide\n * whether to re-enqueue, escalate, or drop.\n */\n private buildDlqHeaders(\n record: OutboxRecord,\n error: Error,\n existing: Record<string, string>,\n ): Record<string, string> {\n const out: Record<string, string> = {\n ...existing,\n \"original-topic\": record.topic,\n \"dlq-attempts\": String(record.attempts + 1),\n \"dlq-original-aggregate-id\": record.aggregateId,\n \"dlq-original-message-id\": record.messageId,\n };\n if (this.dlq.includeStackTraces && error.stack) {\n const maxBytes = this.dlq.maxStackBytes ?? 4096;\n out[\"dlq-error-stack\"] = truncateUtf8(error.stack, maxBytes);\n }\n return out;\n }\n\n private async toPublishable(\n records: OutboxRecord[],\n ): Promise<PublishableMessage[]> {\n const out: PublishableMessage[] = [];\n for (const record of records) {\n out.push(await buildPublishable(record, this.serializer));\n }\n return out;\n }\n\n /** Wake a pending idle wait, or mark one pending so the next wait is skipped. */\n private signal(): void {\n this.wakePending = true;\n this.wakeResolver?.();\n }\n\n /**\n * Idle wait that resolves after `ms`, or early when `signal()` fires. A signal\n * raised while no wait is in flight is remembered (wakePending), so a wake that\n * races a claim cycle is never lost.\n */\n private waitForWork(ms: number): Promise<void> {\n if (this.wakePending) {\n this.wakePending = false;\n return Promise.resolve();\n }\n return new Promise((resolve) => {\n const timer = setTimeout(() => {\n this.wakeResolver = null;\n resolve();\n }, ms);\n this.wakeResolver = () => {\n clearTimeout(timer);\n this.wakeResolver = null;\n this.wakePending = false;\n resolve();\n };\n });\n }\n}\n\n/**\n * Truncate a string to at most `maxBytes` of its UTF-8 encoding, appending a\n * \"…[truncated]\" marker when bytes are removed. Used to keep the DLQ stack\n * header bounded — headers in Kafka are byte-sized, not character-sized, so\n * a naive `slice` on multibyte text can blow the limit. Always returns a\n * valid (no-broken-codepoint) UTF-8 string.\n */\nfunction truncateUtf8(input: string, maxBytes: number): string {\n const marker = \"…[truncated]\";\n const markerBytes = Buffer.byteLength(marker, \"utf8\");\n const buf = Buffer.from(input, \"utf8\");\n if (buf.byteLength <= maxBytes) return input;\n const budget = Math.max(0, maxBytes - markerBytes);\n // Slice and decode; if the slice lands mid-codepoint, drop trailing bytes\n // until the decode is clean.\n let end = budget;\n while (end > 0) {\n const slice = buf.subarray(0, end).toString(\"utf8\");\n if (!slice.endsWith(\"�\")) {\n return slice + marker;\n }\n end--;\n }\n return marker;\n}\n","import type { StandardSchemaV1 } from \"./standard-schema.js\";\n\n/**\n * Thrown when an outbox payload fails its topic's schema — at `enqueue`\n * (before the DB insert) or at `decode` (malformed bytes / schema mismatch).\n * Carries the topic and the validator's structured issues for diagnostics.\n */\nexport class OutboxValidationError extends Error {\n readonly topic: string;\n readonly issues: ReadonlyArray<StandardSchemaV1.Issue>;\n\n constructor(\n topic: string,\n issues: ReadonlyArray<StandardSchemaV1.Issue>,\n options?: { cause?: unknown },\n ) {\n const first = issues[0]?.message ?? \"unknown validation error\";\n super(`Outbox payload for \"${topic}\" failed validation: ${first}`, options);\n this.name = \"OutboxValidationError\";\n this.topic = topic;\n this.issues = issues;\n }\n}\n","import { OutboxValidationError } from \"./errors.js\";\nimport type { StandardSchemaV1 } from \"./standard-schema.js\";\nimport type { OutboxMessageInput } from \"./types.js\";\n\n/** One topic's contract: the aggregate it belongs to and its payload schema. */\nexport interface TopicDefinition {\n /** Aggregate type stamped on every event of this topic (e.g. \"order\"). */\n readonly aggregateType: string;\n /** Standard Schema for the payload (Zod 3.24+/Valibot/ArkType/…). */\n readonly schema: StandardSchemaV1;\n}\n\n/** A map of topic name -> its definition. The single source of truth. */\nexport type OutboxRegistry = Record<string, TopicDefinition>;\n\n/**\n * Minimal store surface the producer facade needs. `PostgresStore` satisfies\n * this structurally, so the facade stays DB-agnostic and `tx` flows from the\n * concrete store (e.g. a pg client).\n */\nexport interface EnqueueableStore<Tx = unknown> {\n enqueue(tx: Tx, msg: OutboxMessageInput & { traceId?: string }): Promise<string>;\n}\n\ntype TxOf<S> = S extends EnqueueableStore<infer Tx> ? Tx : never;\n\ntype PayloadInput<R extends OutboxRegistry, K extends keyof R> =\n StandardSchemaV1.InferInput<R[K][\"schema\"]>;\ntype PayloadOutput<R extends OutboxRegistry, K extends keyof R> =\n StandardSchemaV1.InferOutput<R[K][\"schema\"]>;\n\n/** The write-side input for a typed enqueue (payload is the schema's input type). */\nexport interface EnqueueInput<R extends OutboxRegistry, K extends keyof R> {\n aggregateId: string;\n payload: PayloadInput<R, K>;\n key?: string;\n headers?: Record<string, string>;\n messageId?: string;\n traceId?: string;\n}\n\n/** Consumer-side facade: validate/decode without a store. */\nexport interface OutboxConsumer<R extends OutboxRegistry> {\n /** Validate an already-parsed value against the topic's schema. */\n validate<K extends keyof R & string>(\n topic: K,\n value: unknown,\n ): Promise<PayloadOutput<R, K>>;\n /** JSON-parse `bytes`, validate against the topic's schema, return the payload. */\n decode<K extends keyof R & string>(\n topic: K,\n bytes: Buffer | Uint8Array | string,\n ): Promise<PayloadOutput<R, K>>;\n}\n\n/** Producer-side facade: adds typed, validated enqueue. */\nexport interface OutboxProducer<R extends OutboxRegistry, Tx>\n extends OutboxConsumer<R> {\n /** Validate `payload`, then enqueue inside the caller's transaction `tx`. */\n enqueue<K extends keyof R & string>(\n tx: Tx,\n topic: K,\n msg: EnqueueInput<R, K>,\n ): Promise<string>;\n}\n\n// Loose shape used inside the implementation; the public types come from overloads.\ninterface LooseEnqueueInput {\n aggregateId: string;\n payload: unknown;\n key?: string;\n headers?: Record<string, string>;\n messageId?: string;\n traceId?: string;\n}\n\nexport function defineOutbox<R extends OutboxRegistry>(\n registry: R,\n): OutboxConsumer<R>;\nexport function defineOutbox<R extends OutboxRegistry, S extends EnqueueableStore>(\n registry: R,\n opts: { store: S },\n): OutboxProducer<R, TxOf<S>>;\nexport function defineOutbox<R extends OutboxRegistry>(\n registry: R,\n opts?: { store: EnqueueableStore },\n): OutboxConsumer<R> | OutboxProducer<R, unknown> {\n const validate = async (topic: string, value: unknown): Promise<unknown> => {\n const def = registry[topic];\n if (!def) {\n throw new OutboxValidationError(topic, [\n { message: `unknown topic \"${topic}\"` },\n ]);\n }\n const result = await def.schema[\"~standard\"].validate(value);\n if (result.issues) throw new OutboxValidationError(topic, result.issues);\n return result.value;\n };\n\n const decode = async (\n topic: string,\n bytes: Buffer | Uint8Array | string,\n ): Promise<unknown> => {\n const text =\n typeof bytes === \"string\" ? bytes : Buffer.from(bytes).toString(\"utf8\");\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch (err) {\n throw new OutboxValidationError(\n topic,\n [{ message: `invalid JSON: ${(err as Error).message}` }],\n { cause: err },\n );\n }\n return validate(topic, parsed);\n };\n\n const consumer = { validate, decode } as unknown as OutboxConsumer<R>;\n if (!opts?.store) return consumer;\n\n const store = opts.store;\n const enqueue = async (\n tx: unknown,\n topic: string,\n msg: LooseEnqueueInput,\n ): Promise<string> => {\n const def = registry[topic];\n if (!def) {\n throw new OutboxValidationError(topic, [\n { message: `unknown topic \"${topic}\"` },\n ]);\n }\n const payload = await validate(topic, msg.payload);\n return store.enqueue(tx, {\n topic,\n aggregateType: def.aggregateType,\n aggregateId: msg.aggregateId,\n payload,\n key: msg.key,\n headers: msg.headers,\n messageId: msg.messageId,\n traceId: msg.traceId,\n });\n };\n\n return { validate, decode, enqueue } as unknown as OutboxProducer<R, unknown>;\n}\n"],"mappings":"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","names":["retryAt"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/types.ts","../src/backoff.ts","../src/serializer.ts","../src/publishable.ts","../src/relay.ts","../src/errors.ts","../src/registry.ts"],"sourcesContent":["export * from \"./types.js\";\nexport * from \"./backoff.js\";\nexport * from \"./serializer.js\";\nexport * from \"./publishable.js\";\nexport * from \"./relay.js\";\nexport * from \"./standard-schema.js\";\nexport * from \"./errors.js\";\nexport * from \"./registry.js\";\n","/**\n * Status lifecycle of an outbox record.\n *\n * pending -> freshly enqueued, awaiting first publish\n * processing -> claimed by a relay instance, publish in flight\n * done -> successfully published to the broker\n * failed -> publish failed, awaiting retry (next_retry_at)\n * dead -> exhausted retries, routed to DLQ (or parked)\n */\nexport type OutboxStatus = \"pending\" | \"processing\" | \"done\" | \"failed\" | \"dead\";\n\nexport const OUTBOX_STATUS_CODE: Record<OutboxStatus, number> = {\n pending: 0,\n processing: 1,\n done: 2,\n failed: 3,\n dead: 4,\n};\n\nexport const OUTBOX_STATUS_FROM_CODE: Record<number, OutboxStatus> = {\n 0: \"pending\",\n 1: \"processing\",\n 2: \"done\",\n 3: \"failed\",\n 4: \"dead\",\n};\n\n/**\n * A message as enqueued by the application inside its own DB transaction.\n * This is the write-side input — no infra concerns leak in here.\n */\nexport interface OutboxMessageInput {\n /** Logical topic the message will be published to. */\n topic: string;\n /** Type of the aggregate that produced this event (e.g. \"order\"). */\n aggregateType: string;\n /**\n * Identifier of the aggregate instance (e.g. order id).\n * Used as the default partition key to preserve per-aggregate ordering.\n */\n aggregateId: string;\n /** Event payload. Serialized by the configured serializer. */\n payload: unknown;\n /** Optional explicit partition key. Falls back to aggregateId. */\n key?: string;\n /** Optional message headers. */\n headers?: Record<string, string>;\n /**\n * Optional client-supplied message id for idempotency / dedup.\n * If omitted, the store generates one.\n */\n messageId?: string;\n}\n\n/**\n * A persisted outbox record as read back by the relay.\n */\nexport interface OutboxRecord {\n id: string;\n messageId: string;\n topic: string;\n aggregateType: string;\n aggregateId: string;\n key: string | null;\n payload: unknown;\n headers: Record<string, string>;\n traceId: string | null;\n status: OutboxStatus;\n attempts: number;\n nextRetryAt: Date | null;\n createdAt: Date;\n processedAt: Date | null;\n}\n\n/**\n * The message handed to a Publisher after serialization.\n */\nexport interface PublishableMessage {\n topic: string;\n key: string | null;\n /** Serialized payload bytes. */\n value: Buffer;\n headers: Record<string, string>;\n /** Original record id, for correlation in publish results. */\n recordId: string;\n messageId: string;\n /**\n * Explicit partition override. When set, the publisher MUST route the\n * record to this exact partition, bypassing the configured partitioner.\n *\n * Use cases:\n * - Compacted topics with application-managed sharding.\n * - Tenant-affinity routing where you compute the partition yourself.\n * - Geo-pinning records to a specific broker.\n *\n * When omitted (the default), the underlying client's partitioner\n * decides — usually a hash of `key`, falling back to sticky round-robin\n * when `key` is null.\n */\n partition?: number;\n}\n\n/**\n * Why a publish failed, in terms the relay can act on. Drivers classify their\n * native errors into one of these buckets; the relay reads `errorKind` to\n * decide whether to retry, short-circuit to the DLQ, or pause polling. The\n * field is optional for backward compatibility — when absent, the relay\n * treats the error as `\"retriable\"`.\n *\n * - `retriable` — transient (broker unreachable, leader election, request\n * timeout); retry per the configured backoff policy. The default for any\n * unclassified error.\n * - `fatal` — the producer or the credentials are broken (fenced epoch,\n * authentication failed, ACL denied). Retrying cannot help; the relay\n * short-circuits straight to the DLQ + `dead` status.\n * - `poison` — the message itself is rejectable by every broker\n * (oversized record, corrupt payload, schema-registry refused encoding).\n * Same handling as `fatal`: DLQ + dead, no retries.\n * - `backpressure` — the *producer's own* outbound buffer is full\n * (librdkafka `__QUEUE_FULL`). The right response is to slow the relay\n * down, not to burn retries. v2.1 treats this as `retriable` for\n * compatibility; smarter handling (pause polling) is planned.\n * - `quota` — the broker is throttling us (`THROTTLING_QUOTA_EXCEEDED`).\n * Back off with longer delays. v2.1 treats as `retriable`; smarter\n * handling (longer backoff) is planned.\n * - `fenced` — the broker fenced this producer epoch\n * (`PRODUCER_FENCED` / `INVALID_PRODUCER_EPOCH`). Usually caused by\n * another instance taking the same `transactionalId`, but ALSO fired\n * on transient broker restart / network partition recovery. Distinct\n * from `fatal` because the `KafkaPublisher` can transparently\n * `disconnect + connect + initTransactions` once before falling back\n * to fatal (`autoRecoverFromFence: true` on the publisher, default\n * `false` to keep multi-instance behavior surprise-free).\n */\nexport type PublishErrorKind =\n | \"retriable\"\n | \"fatal\"\n | \"poison\"\n | \"backpressure\"\n | \"quota\"\n | \"fenced\";\n\n/**\n * Result of attempting to publish a single message.\n */\nexport interface PublishResult {\n recordId: string;\n ok: boolean;\n error?: Error;\n /**\n * Optional classification of `error` for relay-level decision-making. Set\n * by publisher implementations that know how to inspect their native error\n * shapes. Absent value is treated as `\"retriable\"` by the relay (the safe\n * default — at worst we retry an error we should have skipped).\n */\n errorKind?: PublishErrorKind;\n}\n\n/**\n * Pluggable serializer. Default is JSON; users can swap in\n * Avro / Protobuf / Schema-Registry-backed serializers.\n */\nexport interface Serializer {\n serialize(message: OutboxRecord): Buffer | Promise<Buffer>;\n /** Content-type header value advertised for this serializer. */\n readonly contentType: string;\n}\n\n/**\n * Storage abstraction. Implemented per-database (postgres, mysql, ...).\n * The relay only talks to the store through this interface.\n */\nexport interface OutboxStore {\n /**\n * Atomically claim up to `batchSize` due messages and mark them\n * as `processing`. Implementations MUST be safe under concurrent\n * relay instances (e.g. SELECT ... FOR UPDATE SKIP LOCKED).\n */\n claimBatch(batchSize: number): Promise<OutboxRecord[]>;\n\n /** Mark records as successfully published. */\n markDone(recordIds: string[]): Promise<void>;\n\n /**\n * Mark a record as failed and schedule its next retry.\n * `nextRetryAt` of null + status \"dead\" means terminal.\n * Implementations MUST increment `attempts` so the retry budget is honored.\n */\n markFailed(\n recordId: string,\n nextRetryAt: Date | null,\n status: \"failed\" | \"dead\",\n ): Promise<void>;\n\n /**\n * Re-queue a record back to `failed` with the given `retryAt` **WITHOUT\n * incrementing attempts**. Used by the relay for backpressure handling —\n * a client-side queue-full failure is a \"slow down\" signal, not a\n * record-specific failure, and counting it as a retry would burn the\n * attempt budget unfairly.\n *\n * Optional: stores that don't implement this fall back to `markFailed`\n * (which increments attempts, with the caveat documented above).\n */\n requeue?(recordId: string, retryAt: Date): Promise<void>;\n\n /** Best-effort lifecycle hooks; no-op allowed. */\n init?(): Promise<void>;\n close?(): Promise<void>;\n}\n\n/**\n * Broker abstraction. Implemented per-driver (kafkajs, confluent, ...).\n */\nexport interface Publisher {\n connect(): Promise<void>;\n disconnect(): Promise<void>;\n /**\n * Publish a batch. Returns a per-message result so the relay can\n * mark partial success. Implementations may use a transactional\n * producer to make the batch atomic.\n */\n publish(messages: PublishableMessage[]): Promise<PublishResult[]>;\n /** Route a permanently-failed message to a dead-letter destination. */\n publishToDlq?(message: PublishableMessage, error: Error): Promise<void>;\n}\n\n/**\n * Backoff strategy for retrying failed publishes.\n */\nexport type BackoffStrategy = \"fixed\" | \"linear\" | \"exponential\";\n\nexport interface RetryConfig {\n maxAttempts: number;\n strategy: BackoffStrategy;\n baseMs: number;\n maxMs: number;\n /** Add random jitter (0..baseMs) to avoid thundering herd. Default true. */\n jitter?: boolean;\n /**\n * Delay (ms) before re-queueing a record whose publish was rejected with\n * `errorKind: \"backpressure\"` (client-side producer buffer full).\n *\n * Backpressure failures do NOT count as a failed attempt — the buffer\n * being full is a \"slow down\" signal, not a record-specific failure.\n * The record is requeued at the next interval, not promoted to dead.\n *\n * Default 1000 ms. Requires the {@link OutboxStore} to implement\n * `requeue` — stores without it fall back to {@link OutboxStore.markFailed}\n * which DOES increment attempts.\n */\n backpressureDelayMs?: number;\n /**\n * Multiplier applied to the computed backoff for records rejected with\n * `errorKind: \"quota\"` (broker is throttling the producer). Default 5 —\n * a quota signal asks for a longer breath than a generic transient error.\n * Quota failures DO count as attempts (unlike backpressure).\n */\n quotaMultiplier?: number;\n}\n\nexport interface DlqConfig {\n /** Topic to route dead messages to. If absent, dead messages are parked. */\n topic?: string;\n /**\n * Include a truncated stack trace as the `dlq-error-stack` header when\n * routing a record to the DLQ. Default false — keep DLQ messages small\n * by default; opt in if your triage workflow needs the stack.\n */\n includeStackTraces?: boolean;\n /**\n * Maximum bytes of the truncated stack trace included when\n * `includeStackTraces` is on. Default 4096.\n */\n maxStackBytes?: number;\n}\n\n/**\n * Optional trace-context propagator. `inject` writes the active trace context\n * into the carrier as W3C `traceparent`/`tracestate` (the shape of an\n * OpenTelemetry TextMapPropagator), so it is persisted with the outbox row and\n * carried to the published message. The library depends on no tracing package;\n * provide a thin adapter over yours (OpenTelemetry, Datadog, …).\n */\nexport interface Tracing {\n inject(carrier: Record<string, string>): void;\n}\n\n/**\n * Minimal structured logger. Console-backed default provided.\n */\nexport interface Logger {\n debug(msg: string, meta?: Record<string, unknown>): void;\n info(msg: string, meta?: Record<string, unknown>): void;\n warn(msg: string, meta?: Record<string, unknown>): void;\n error(msg: string, meta?: Record<string, unknown>): void;\n}\n\n/**\n * A low-latency wake source for the relay. Instead of only waking on the poll\n * interval, the relay claims immediately whenever `onWake` fires. The signal is\n * advisory: it may fire spuriously or be missed entirely, and the relay's\n * polling remains the safety net, so implementations need not deduplicate or\n * guarantee delivery. (e.g. a Postgres LISTEN/NOTIFY waker.)\n */\nexport interface Waker {\n start(onWake: () => void): Promise<void>;\n stop(): Promise<void>;\n}\n\n/**\n * Lifecycle / observability hooks emitted by the relay.\n */\nexport interface RelayHooks {\n onBatchClaimed?(count: number): void;\n onPublished?(result: PublishResult): void;\n onFailed?(record: OutboxRecord, error: Error, willRetry: boolean): void;\n onDead?(record: OutboxRecord, error: Error): void;\n onError?(error: Error): void;\n}\n","import type { RetryConfig } from \"./types.js\";\n\n/**\n * Compute the delay (ms) before the next retry attempt.\n *\n * @param attempt 1-based attempt number that just failed.\n */\nexport function computeBackoff(config: RetryConfig, attempt: number): number {\n const { strategy, baseMs, maxMs } = config;\n const jitter = config.jitter ?? true;\n\n let delay: number;\n switch (strategy) {\n case \"fixed\":\n delay = baseMs;\n break;\n case \"linear\":\n delay = baseMs * attempt;\n break;\n case \"exponential\":\n // base * 2^(attempt-1), capped to avoid overflow before clamping\n delay = baseMs * 2 ** Math.min(attempt - 1, 30);\n break;\n }\n\n delay = Math.min(delay, maxMs);\n\n if (jitter) {\n // Full jitter: random in [0, delay]. Decorrelates concurrent relays.\n delay = Math.random() * delay;\n }\n\n return Math.floor(delay);\n}\n\n/**\n * Resolve when (Date) the next retry should occur, or null if the\n * record has exhausted its attempts and should go dead.\n */\nexport function nextRetryAt(\n config: RetryConfig,\n attempts: number,\n now: Date = new Date(),\n): Date | null {\n if (attempts >= config.maxAttempts) return null;\n const delay = computeBackoff(config, attempts);\n return new Date(now.getTime() + delay);\n}\n","import type { Logger, OutboxRecord, Serializer } from \"./types.js\";\n\n/**\n * Default serializer: JSON-encodes the record payload to UTF-8 bytes.\n */\nexport class JsonSerializer implements Serializer {\n readonly contentType = \"application/json\";\n\n serialize(record: OutboxRecord): Buffer {\n return Buffer.from(JSON.stringify(record.payload), \"utf8\");\n }\n}\n\n/**\n * Minimal console-backed logger. Swap in pino/winston by implementing Logger.\n */\nexport class ConsoleLogger implements Logger {\n constructor(private readonly prefix = \"[outbox]\") {}\n\n private fmt(\n write: (line: string, meta?: Record<string, unknown>) => void,\n level: string,\n msg: string,\n meta?: Record<string, unknown>,\n ): void {\n const line = `${this.prefix} ${level} ${msg}`;\n if (meta) {\n write(line, meta);\n } else {\n write(line);\n }\n }\n\n debug(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.debug, \"DEBUG\", msg, meta);\n }\n info(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.info, \"INFO\", msg, meta);\n }\n warn(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.warn, \"WARN\", msg, meta);\n }\n error(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.error, \"ERROR\", msg, meta);\n }\n}\n\n/** Logger that discards everything. Useful for tests. */\nexport class NoopLogger implements Logger {\n debug(): void {}\n info(): void {}\n warn(): void {}\n error(): void {}\n}\n","import type { OutboxRecord, PublishableMessage, Serializer } from \"./types.js\";\n\n/**\n * Turn a persisted outbox record into a broker-ready message: serialize the\n * payload and attach the standard correlation headers. Shared by the polling\n * `Relay` and the streaming relay so both produce byte-identical messages.\n */\nexport async function buildPublishable(\n record: OutboxRecord,\n serializer: Serializer,\n): Promise<PublishableMessage> {\n const value = await serializer.serialize(record);\n const headers: Record<string, string> = {\n ...record.headers,\n \"content-type\": serializer.contentType,\n \"message-id\": record.messageId,\n \"aggregate-type\": record.aggregateType,\n \"aggregate-id\": record.aggregateId,\n };\n if (record.traceId) headers[\"trace-id\"] = record.traceId;\n\n return {\n topic: record.topic,\n key: record.key ?? record.aggregateId,\n value,\n headers,\n recordId: record.id,\n messageId: record.messageId,\n };\n}\n","import { computeBackoff } from \"./backoff.js\";\nimport { buildPublishable } from \"./publishable.js\";\nimport { ConsoleLogger, JsonSerializer } from \"./serializer.js\";\nimport type {\n DlqConfig,\n Logger,\n OutboxRecord,\n OutboxStore,\n PublishableMessage,\n PublishErrorKind,\n Publisher,\n RelayHooks,\n RetryConfig,\n Serializer,\n Waker,\n} from \"./types.js\";\n\nexport interface RelayOptions {\n store: OutboxStore;\n publisher: Publisher;\n /** Messages claimed per poll iteration. Default 100. */\n batchSize?: number;\n /** Idle wait (ms) when a poll returns no work. Default 200. */\n pollIntervalMs?: number;\n retry?: Partial<RetryConfig>;\n dlq?: DlqConfig;\n serializer?: Serializer;\n logger?: Logger;\n hooks?: RelayHooks;\n /**\n * Optional low-latency wake source. When provided, the relay claims as soon as\n * the waker signals new work, instead of waiting out `pollIntervalMs`. Polling\n * stays on as a safety net, so set `pollIntervalMs` longer when using a waker.\n */\n waker?: Waker;\n}\n\nconst DEFAULT_RETRY: RetryConfig = {\n maxAttempts: 5,\n strategy: \"exponential\",\n baseMs: 200,\n maxMs: 30_000,\n jitter: true,\n};\n\n/**\n * The Relay drains the outbox store and publishes messages to the broker.\n *\n * It is safe to run multiple Relay instances concurrently against the same\n * store as long as the store's claimBatch uses a lock-free claim strategy\n * (e.g. SELECT ... FOR UPDATE SKIP LOCKED).\n */\nexport class Relay {\n private readonly store: OutboxStore;\n private readonly publisher: Publisher;\n private readonly batchSize: number;\n private readonly pollIntervalMs: number;\n private readonly retry: RetryConfig;\n private readonly dlq: DlqConfig;\n private readonly serializer: Serializer;\n private readonly log: Logger;\n private readonly hooks: RelayHooks;\n private readonly waker: Waker | null;\n\n private running = false;\n private stopping = false;\n private loopPromise: Promise<void> | null = null;\n\n // Interruptible idle wait: `signal()` wakes a pending wait (or marks one\n // pending if none is in flight, so a wake can't be lost between cycles).\n private wakePending = false;\n private wakeResolver: (() => void) | null = null;\n\n constructor(opts: RelayOptions) {\n this.store = opts.store;\n this.publisher = opts.publisher;\n this.batchSize = opts.batchSize ?? 100;\n this.pollIntervalMs = opts.pollIntervalMs ?? 200;\n this.retry = { ...DEFAULT_RETRY, ...opts.retry };\n this.dlq = opts.dlq ?? {};\n this.serializer = opts.serializer ?? new JsonSerializer();\n this.log = opts.logger ?? new ConsoleLogger();\n this.hooks = opts.hooks ?? {};\n this.waker = opts.waker ?? null;\n }\n\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n this.stopping = false;\n\n await this.store.init?.();\n await this.publisher.connect();\n await this.waker?.start(() => this.signal());\n this.log.info(\"relay started\", {\n batchSize: this.batchSize,\n pollIntervalMs: this.pollIntervalMs,\n waker: this.waker !== null,\n });\n\n this.loopPromise = this.loop();\n }\n\n /**\n * Stop accepting new work, finish the in-flight batch, then disconnect.\n */\n async stop(): Promise<void> {\n if (!this.running) return;\n this.stopping = true;\n this.signal(); // break any in-flight idle wait so shutdown is immediate\n this.log.info(\"relay stopping, draining in-flight batch\");\n await this.loopPromise;\n await this.waker?.stop();\n await this.publisher.disconnect();\n await this.store.close?.();\n this.running = false;\n this.log.info(\"relay stopped\");\n }\n\n private async loop(): Promise<void> {\n while (!this.stopping) {\n try {\n const processed = await this.tick();\n if (processed === 0 && !this.stopping) {\n await this.waitForWork(this.pollIntervalMs);\n }\n } catch (err) {\n const error = err instanceof Error ? err : new Error(String(err));\n this.log.error(\"relay loop error\", { error: error.message });\n this.hooks.onError?.(error);\n await this.waitForWork(this.pollIntervalMs);\n }\n }\n }\n\n /**\n * Run a single claim+publish cycle. Returns number of records processed.\n * Exposed for tests / manual single-shot draining.\n */\n async tick(): Promise<number> {\n const batch = await this.store.claimBatch(this.batchSize);\n if (batch.length === 0) return 0;\n\n this.hooks.onBatchClaimed?.(batch.length);\n this.log.debug(\"batch claimed\", { count: batch.length });\n\n const messages = await this.toPublishable(batch);\n const recordsById = new Map(batch.map((r) => [r.id, r]));\n\n const results = await this.publisher.publish(messages);\n\n const succeeded: string[] = [];\n for (const result of results) {\n const record = recordsById.get(result.recordId);\n if (!record) continue;\n\n if (result.ok) {\n succeeded.push(record.id);\n this.hooks.onPublished?.(result);\n } else {\n await this.handleFailure(\n record,\n result.error ?? new Error(\"unknown publish error\"),\n result.errorKind,\n );\n }\n }\n\n if (succeeded.length > 0) {\n await this.store.markDone(succeeded);\n }\n\n return batch.length;\n }\n\n private async handleFailure(\n record: OutboxRecord,\n error: Error,\n errorKind?: PublishErrorKind,\n ): Promise<void> {\n // Backpressure: client-side producer queue is full. Re-queue the record\n // with a brief delay; do NOT increment attempts (this isn't the record's\n // fault, the producer is just full). Stores without `requeue` fall back\n // to markFailed, accepting the attempt-counter hit.\n if (errorKind === \"backpressure\") {\n const delay = this.retry.backpressureDelayMs ?? 1000;\n const retryAt = new Date(Date.now() + delay);\n this.hooks.onFailed?.(record, error, true);\n this.log.warn(\"publish backpressure — requeueing without bumping attempts\", {\n recordId: record.id,\n retryAt: retryAt.toISOString(),\n errorKind,\n error: error.message,\n });\n if (this.store.requeue) {\n await this.store.requeue(record.id, retryAt);\n } else {\n // Fallback: markFailed (will increment attempts). Documented in the\n // OutboxStore.requeue JSDoc.\n await this.store.markFailed(record.id, retryAt, \"failed\");\n }\n return;\n }\n\n const attempts = record.attempts + 1;\n // fatal/poison short-circuit: retrying cannot help (auth denied, fenced\n // epoch, oversized record, schema rejected). Skip the backoff schedule\n // entirely and go straight to DLQ + dead.\n const isTerminalKind = errorKind === \"fatal\" || errorKind === \"poison\";\n const retryAt = isTerminalKind\n ? null\n : this.nextRetryAtForKind(attempts, errorKind);\n const willRetry = retryAt !== null;\n\n this.hooks.onFailed?.(record, error, willRetry);\n this.log.warn(\"publish failed\", {\n recordId: record.id,\n attempts,\n willRetry,\n errorKind: errorKind ?? \"retriable\",\n error: error.message,\n });\n\n if (willRetry) {\n await this.store.markFailed(record.id, retryAt, \"failed\");\n return;\n }\n\n // Terminal: route to DLQ if configured, then mark dead.\n if (this.dlq.topic && this.publisher.publishToDlq) {\n try {\n const msg = (await this.toPublishable([record]))[0];\n if (msg) {\n await this.publisher.publishToDlq(\n {\n ...msg,\n topic: this.dlq.topic,\n // Per-record enrichment so the DLQ consumer has everything\n // needed for triage: original destination, aggregate / message\n // identity, the attempts count, and (opt-in) a truncated stack.\n headers: this.buildDlqHeaders(record, error, msg.headers),\n },\n error,\n );\n }\n } catch (dlqErr) {\n const e = dlqErr instanceof Error ? dlqErr : new Error(String(dlqErr));\n this.log.error(\"DLQ publish failed\", {\n recordId: record.id,\n error: e.message,\n });\n }\n }\n\n await this.store.markFailed(record.id, null, \"dead\");\n this.hooks.onDead?.(record, error);\n }\n\n /**\n * Compute the next retry instant, applying the quota multiplier when the\n * driver classified the failure as a server throttle. Quota failures DO\n * count as attempts (unlike backpressure) — the multiplier only stretches\n * the delay, not the budget.\n */\n private nextRetryAtForKind(\n attempts: number,\n errorKind: PublishErrorKind | undefined,\n ): Date | null {\n if (attempts >= this.retry.maxAttempts) return null;\n const baseDelay = computeBackoff(this.retry, attempts);\n const multiplier =\n errorKind === \"quota\" ? this.retry.quotaMultiplier ?? 5 : 1;\n const delay = Math.min(baseDelay * multiplier, this.retry.maxMs * 10);\n return new Date(Date.now() + delay);\n }\n\n /**\n * Build the per-record DLQ header bag. Operators triaging the DLQ get the\n * original destination, the aggregate / message identity, attempts count,\n * and optionally a truncated error stack — everything you need to decide\n * whether to re-enqueue, escalate, or drop.\n */\n private buildDlqHeaders(\n record: OutboxRecord,\n error: Error,\n existing: Record<string, string>,\n ): Record<string, string> {\n const out: Record<string, string> = {\n ...existing,\n \"original-topic\": record.topic,\n \"dlq-attempts\": String(record.attempts + 1),\n \"dlq-original-aggregate-id\": record.aggregateId,\n \"dlq-original-message-id\": record.messageId,\n };\n if (this.dlq.includeStackTraces && error.stack) {\n const maxBytes = this.dlq.maxStackBytes ?? 4096;\n out[\"dlq-error-stack\"] = truncateUtf8(error.stack, maxBytes);\n }\n return out;\n }\n\n private async toPublishable(\n records: OutboxRecord[],\n ): Promise<PublishableMessage[]> {\n const out: PublishableMessage[] = [];\n for (const record of records) {\n out.push(await buildPublishable(record, this.serializer));\n }\n return out;\n }\n\n /** Wake a pending idle wait, or mark one pending so the next wait is skipped. */\n private signal(): void {\n this.wakePending = true;\n this.wakeResolver?.();\n }\n\n /**\n * Idle wait that resolves after `ms`, or early when `signal()` fires. A signal\n * raised while no wait is in flight is remembered (wakePending), so a wake that\n * races a claim cycle is never lost.\n */\n private waitForWork(ms: number): Promise<void> {\n if (this.wakePending) {\n this.wakePending = false;\n return Promise.resolve();\n }\n return new Promise((resolve) => {\n const timer = setTimeout(() => {\n this.wakeResolver = null;\n resolve();\n }, ms);\n this.wakeResolver = () => {\n clearTimeout(timer);\n this.wakeResolver = null;\n this.wakePending = false;\n resolve();\n };\n });\n }\n}\n\n/**\n * Truncate a string to at most `maxBytes` of its UTF-8 encoding, appending a\n * \"…[truncated]\" marker when bytes are removed. Used to keep the DLQ stack\n * header bounded — headers in Kafka are byte-sized, not character-sized, so\n * a naive `slice` on multibyte text can blow the limit. Always returns a\n * valid (no-broken-codepoint) UTF-8 string.\n */\nfunction truncateUtf8(input: string, maxBytes: number): string {\n const marker = \"…[truncated]\";\n const markerBytes = Buffer.byteLength(marker, \"utf8\");\n const buf = Buffer.from(input, \"utf8\");\n if (buf.byteLength <= maxBytes) return input;\n const budget = Math.max(0, maxBytes - markerBytes);\n // Slice and decode; if the slice lands mid-codepoint, drop trailing bytes\n // until the decode is clean.\n let end = budget;\n while (end > 0) {\n const slice = buf.subarray(0, end).toString(\"utf8\");\n if (!slice.endsWith(\"�\")) {\n return slice + marker;\n }\n end--;\n }\n return marker;\n}\n","import type { StandardSchemaV1 } from \"./standard-schema.js\";\n\n/**\n * Thrown when an outbox payload fails its topic's schema — at `enqueue`\n * (before the DB insert) or at `decode` (malformed bytes / schema mismatch).\n * Carries the topic and the validator's structured issues for diagnostics.\n */\nexport class OutboxValidationError extends Error {\n readonly topic: string;\n readonly issues: ReadonlyArray<StandardSchemaV1.Issue>;\n\n constructor(\n topic: string,\n issues: ReadonlyArray<StandardSchemaV1.Issue>,\n options?: { cause?: unknown },\n ) {\n const first = issues[0]?.message ?? \"unknown validation error\";\n super(`Outbox payload for \"${topic}\" failed validation: ${first}`, options);\n this.name = \"OutboxValidationError\";\n this.topic = topic;\n this.issues = issues;\n }\n}\n","import { OutboxValidationError } from \"./errors.js\";\nimport type { StandardSchemaV1 } from \"./standard-schema.js\";\nimport type { OutboxMessageInput } from \"./types.js\";\n\n/** One topic's contract: the aggregate it belongs to and its payload schema. */\nexport interface TopicDefinition {\n /** Aggregate type stamped on every event of this topic (e.g. \"order\"). */\n readonly aggregateType: string;\n /** Standard Schema for the payload (Zod 3.24+/Valibot/ArkType/…). */\n readonly schema: StandardSchemaV1;\n}\n\n/** A map of topic name -> its definition. The single source of truth. */\nexport type OutboxRegistry = Record<string, TopicDefinition>;\n\n/**\n * Minimal store surface the producer facade needs. `PostgresStore` satisfies\n * this structurally, so the facade stays DB-agnostic and `tx` flows from the\n * concrete store (e.g. a pg client).\n */\nexport interface EnqueueableStore<Tx = unknown> {\n enqueue(tx: Tx, msg: OutboxMessageInput & { traceId?: string }): Promise<string>;\n}\n\ntype TxOf<S> = S extends EnqueueableStore<infer Tx> ? Tx : never;\n\ntype PayloadInput<R extends OutboxRegistry, K extends keyof R> =\n StandardSchemaV1.InferInput<R[K][\"schema\"]>;\ntype PayloadOutput<R extends OutboxRegistry, K extends keyof R> =\n StandardSchemaV1.InferOutput<R[K][\"schema\"]>;\n\n/** The write-side input for a typed enqueue (payload is the schema's input type). */\nexport interface EnqueueInput<R extends OutboxRegistry, K extends keyof R> {\n aggregateId: string;\n payload: PayloadInput<R, K>;\n key?: string;\n headers?: Record<string, string>;\n messageId?: string;\n traceId?: string;\n}\n\n/** Consumer-side facade: validate/decode without a store. */\nexport interface OutboxConsumer<R extends OutboxRegistry> {\n /** Validate an already-parsed value against the topic's schema. */\n validate<K extends keyof R & string>(\n topic: K,\n value: unknown,\n ): Promise<PayloadOutput<R, K>>;\n /** JSON-parse `bytes`, validate against the topic's schema, return the payload. */\n decode<K extends keyof R & string>(\n topic: K,\n bytes: Buffer | Uint8Array | string,\n ): Promise<PayloadOutput<R, K>>;\n}\n\n/** Producer-side facade: adds typed, validated enqueue. */\nexport interface OutboxProducer<R extends OutboxRegistry, Tx>\n extends OutboxConsumer<R> {\n /** Validate `payload`, then enqueue inside the caller's transaction `tx`. */\n enqueue<K extends keyof R & string>(\n tx: Tx,\n topic: K,\n msg: EnqueueInput<R, K>,\n ): Promise<string>;\n}\n\n// Loose shape used inside the implementation; the public types come from overloads.\ninterface LooseEnqueueInput {\n aggregateId: string;\n payload: unknown;\n key?: string;\n headers?: Record<string, string>;\n messageId?: string;\n traceId?: string;\n}\n\nexport function defineOutbox<R extends OutboxRegistry>(\n registry: R,\n): OutboxConsumer<R>;\nexport function defineOutbox<R extends OutboxRegistry, S extends EnqueueableStore>(\n registry: R,\n opts: { store: S },\n): OutboxProducer<R, TxOf<S>>;\nexport function defineOutbox<R extends OutboxRegistry>(\n registry: R,\n opts?: { store: EnqueueableStore },\n): OutboxConsumer<R> | OutboxProducer<R, unknown> {\n const validate = async (topic: string, value: unknown): Promise<unknown> => {\n const def = registry[topic];\n if (!def) {\n throw new OutboxValidationError(topic, [\n { message: `unknown topic \"${topic}\"` },\n ]);\n }\n const result = await def.schema[\"~standard\"].validate(value);\n if (result.issues) throw new OutboxValidationError(topic, result.issues);\n return result.value;\n };\n\n const decode = async (\n topic: string,\n bytes: Buffer | Uint8Array | string,\n ): Promise<unknown> => {\n const text =\n typeof bytes === \"string\" ? bytes : Buffer.from(bytes).toString(\"utf8\");\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch (err) {\n throw new OutboxValidationError(\n topic,\n [{ message: `invalid JSON: ${(err as Error).message}` }],\n { cause: err },\n );\n }\n return validate(topic, parsed);\n };\n\n const consumer = { validate, decode } as unknown as OutboxConsumer<R>;\n if (!opts?.store) return consumer;\n\n const store = opts.store;\n const enqueue = async (\n tx: unknown,\n topic: string,\n msg: LooseEnqueueInput,\n ): Promise<string> => {\n const def = registry[topic];\n if (!def) {\n throw new OutboxValidationError(topic, [\n { message: `unknown topic \"${topic}\"` },\n ]);\n }\n const payload = await validate(topic, msg.payload);\n return store.enqueue(tx, {\n topic,\n aggregateType: def.aggregateType,\n aggregateId: msg.aggregateId,\n payload,\n key: msg.key,\n headers: msg.headers,\n messageId: msg.messageId,\n traceId: msg.traceId,\n });\n };\n\n return { validate, decode, enqueue } as unknown as OutboxProducer<R, unknown>;\n}\n"],"mappings":"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","names":["retryAt"]}
package/dist/index.d.cts CHANGED
@@ -105,8 +105,16 @@ interface PublishableMessage {
105
105
  * - `quota` — the broker is throttling us (`THROTTLING_QUOTA_EXCEEDED`).
106
106
  * Back off with longer delays. v2.1 treats as `retriable`; smarter
107
107
  * handling (longer backoff) is planned.
108
+ * - `fenced` — the broker fenced this producer epoch
109
+ * (`PRODUCER_FENCED` / `INVALID_PRODUCER_EPOCH`). Usually caused by
110
+ * another instance taking the same `transactionalId`, but ALSO fired
111
+ * on transient broker restart / network partition recovery. Distinct
112
+ * from `fatal` because the `KafkaPublisher` can transparently
113
+ * `disconnect + connect + initTransactions` once before falling back
114
+ * to fatal (`autoRecoverFromFence: true` on the publisher, default
115
+ * `false` to keep multi-instance behavior surprise-free).
108
116
  */
109
- type PublishErrorKind = "retriable" | "fatal" | "poison" | "backpressure" | "quota";
117
+ type PublishErrorKind = "retriable" | "fatal" | "poison" | "backpressure" | "quota" | "fenced";
110
118
  /**
111
119
  * Result of attempting to publish a single message.
112
120
  */
package/dist/index.d.ts CHANGED
@@ -105,8 +105,16 @@ interface PublishableMessage {
105
105
  * - `quota` — the broker is throttling us (`THROTTLING_QUOTA_EXCEEDED`).
106
106
  * Back off with longer delays. v2.1 treats as `retriable`; smarter
107
107
  * handling (longer backoff) is planned.
108
+ * - `fenced` — the broker fenced this producer epoch
109
+ * (`PRODUCER_FENCED` / `INVALID_PRODUCER_EPOCH`). Usually caused by
110
+ * another instance taking the same `transactionalId`, but ALSO fired
111
+ * on transient broker restart / network partition recovery. Distinct
112
+ * from `fatal` because the `KafkaPublisher` can transparently
113
+ * `disconnect + connect + initTransactions` once before falling back
114
+ * to fatal (`autoRecoverFromFence: true` on the publisher, default
115
+ * `false` to keep multi-instance behavior surprise-free).
108
116
  */
109
- type PublishErrorKind = "retriable" | "fatal" | "poison" | "backpressure" | "quota";
117
+ type PublishErrorKind = "retriable" | "fatal" | "poison" | "backpressure" | "quota" | "fenced";
110
118
  /**
111
119
  * Result of attempting to publish a single message.
112
120
  */
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts","../src/backoff.ts","../src/serializer.ts","../src/publishable.ts","../src/relay.ts","../src/errors.ts","../src/registry.ts"],"sourcesContent":["/**\n * Status lifecycle of an outbox record.\n *\n * pending -> freshly enqueued, awaiting first publish\n * processing -> claimed by a relay instance, publish in flight\n * done -> successfully published to the broker\n * failed -> publish failed, awaiting retry (next_retry_at)\n * dead -> exhausted retries, routed to DLQ (or parked)\n */\nexport type OutboxStatus = \"pending\" | \"processing\" | \"done\" | \"failed\" | \"dead\";\n\nexport const OUTBOX_STATUS_CODE: Record<OutboxStatus, number> = {\n pending: 0,\n processing: 1,\n done: 2,\n failed: 3,\n dead: 4,\n};\n\nexport const OUTBOX_STATUS_FROM_CODE: Record<number, OutboxStatus> = {\n 0: \"pending\",\n 1: \"processing\",\n 2: \"done\",\n 3: \"failed\",\n 4: \"dead\",\n};\n\n/**\n * A message as enqueued by the application inside its own DB transaction.\n * This is the write-side input — no infra concerns leak in here.\n */\nexport interface OutboxMessageInput {\n /** Logical topic the message will be published to. */\n topic: string;\n /** Type of the aggregate that produced this event (e.g. \"order\"). */\n aggregateType: string;\n /**\n * Identifier of the aggregate instance (e.g. order id).\n * Used as the default partition key to preserve per-aggregate ordering.\n */\n aggregateId: string;\n /** Event payload. Serialized by the configured serializer. */\n payload: unknown;\n /** Optional explicit partition key. Falls back to aggregateId. */\n key?: string;\n /** Optional message headers. */\n headers?: Record<string, string>;\n /**\n * Optional client-supplied message id for idempotency / dedup.\n * If omitted, the store generates one.\n */\n messageId?: string;\n}\n\n/**\n * A persisted outbox record as read back by the relay.\n */\nexport interface OutboxRecord {\n id: string;\n messageId: string;\n topic: string;\n aggregateType: string;\n aggregateId: string;\n key: string | null;\n payload: unknown;\n headers: Record<string, string>;\n traceId: string | null;\n status: OutboxStatus;\n attempts: number;\n nextRetryAt: Date | null;\n createdAt: Date;\n processedAt: Date | null;\n}\n\n/**\n * The message handed to a Publisher after serialization.\n */\nexport interface PublishableMessage {\n topic: string;\n key: string | null;\n /** Serialized payload bytes. */\n value: Buffer;\n headers: Record<string, string>;\n /** Original record id, for correlation in publish results. */\n recordId: string;\n messageId: string;\n /**\n * Explicit partition override. When set, the publisher MUST route the\n * record to this exact partition, bypassing the configured partitioner.\n *\n * Use cases:\n * - Compacted topics with application-managed sharding.\n * - Tenant-affinity routing where you compute the partition yourself.\n * - Geo-pinning records to a specific broker.\n *\n * When omitted (the default), the underlying client's partitioner\n * decides — usually a hash of `key`, falling back to sticky round-robin\n * when `key` is null.\n */\n partition?: number;\n}\n\n/**\n * Why a publish failed, in terms the relay can act on. Drivers classify their\n * native errors into one of these buckets; the relay reads `errorKind` to\n * decide whether to retry, short-circuit to the DLQ, or pause polling. The\n * field is optional for backward compatibility — when absent, the relay\n * treats the error as `\"retriable\"`.\n *\n * - `retriable` — transient (broker unreachable, leader election, request\n * timeout); retry per the configured backoff policy. The default for any\n * unclassified error.\n * - `fatal` — the producer or the credentials are broken (fenced epoch,\n * authentication failed, ACL denied). Retrying cannot help; the relay\n * short-circuits straight to the DLQ + `dead` status.\n * - `poison` — the message itself is rejectable by every broker\n * (oversized record, corrupt payload, schema-registry refused encoding).\n * Same handling as `fatal`: DLQ + dead, no retries.\n * - `backpressure` — the *producer's own* outbound buffer is full\n * (librdkafka `__QUEUE_FULL`). The right response is to slow the relay\n * down, not to burn retries. v2.1 treats this as `retriable` for\n * compatibility; smarter handling (pause polling) is planned.\n * - `quota` — the broker is throttling us (`THROTTLING_QUOTA_EXCEEDED`).\n * Back off with longer delays. v2.1 treats as `retriable`; smarter\n * handling (longer backoff) is planned.\n */\nexport type PublishErrorKind =\n | \"retriable\"\n | \"fatal\"\n | \"poison\"\n | \"backpressure\"\n | \"quota\";\n\n/**\n * Result of attempting to publish a single message.\n */\nexport interface PublishResult {\n recordId: string;\n ok: boolean;\n error?: Error;\n /**\n * Optional classification of `error` for relay-level decision-making. Set\n * by publisher implementations that know how to inspect their native error\n * shapes. Absent value is treated as `\"retriable\"` by the relay (the safe\n * default — at worst we retry an error we should have skipped).\n */\n errorKind?: PublishErrorKind;\n}\n\n/**\n * Pluggable serializer. Default is JSON; users can swap in\n * Avro / Protobuf / Schema-Registry-backed serializers.\n */\nexport interface Serializer {\n serialize(message: OutboxRecord): Buffer | Promise<Buffer>;\n /** Content-type header value advertised for this serializer. */\n readonly contentType: string;\n}\n\n/**\n * Storage abstraction. Implemented per-database (postgres, mysql, ...).\n * The relay only talks to the store through this interface.\n */\nexport interface OutboxStore {\n /**\n * Atomically claim up to `batchSize` due messages and mark them\n * as `processing`. Implementations MUST be safe under concurrent\n * relay instances (e.g. SELECT ... FOR UPDATE SKIP LOCKED).\n */\n claimBatch(batchSize: number): Promise<OutboxRecord[]>;\n\n /** Mark records as successfully published. */\n markDone(recordIds: string[]): Promise<void>;\n\n /**\n * Mark a record as failed and schedule its next retry.\n * `nextRetryAt` of null + status \"dead\" means terminal.\n * Implementations MUST increment `attempts` so the retry budget is honored.\n */\n markFailed(\n recordId: string,\n nextRetryAt: Date | null,\n status: \"failed\" | \"dead\",\n ): Promise<void>;\n\n /**\n * Re-queue a record back to `failed` with the given `retryAt` **WITHOUT\n * incrementing attempts**. Used by the relay for backpressure handling —\n * a client-side queue-full failure is a \"slow down\" signal, not a\n * record-specific failure, and counting it as a retry would burn the\n * attempt budget unfairly.\n *\n * Optional: stores that don't implement this fall back to `markFailed`\n * (which increments attempts, with the caveat documented above).\n */\n requeue?(recordId: string, retryAt: Date): Promise<void>;\n\n /** Best-effort lifecycle hooks; no-op allowed. */\n init?(): Promise<void>;\n close?(): Promise<void>;\n}\n\n/**\n * Broker abstraction. Implemented per-driver (kafkajs, confluent, ...).\n */\nexport interface Publisher {\n connect(): Promise<void>;\n disconnect(): Promise<void>;\n /**\n * Publish a batch. Returns a per-message result so the relay can\n * mark partial success. Implementations may use a transactional\n * producer to make the batch atomic.\n */\n publish(messages: PublishableMessage[]): Promise<PublishResult[]>;\n /** Route a permanently-failed message to a dead-letter destination. */\n publishToDlq?(message: PublishableMessage, error: Error): Promise<void>;\n}\n\n/**\n * Backoff strategy for retrying failed publishes.\n */\nexport type BackoffStrategy = \"fixed\" | \"linear\" | \"exponential\";\n\nexport interface RetryConfig {\n maxAttempts: number;\n strategy: BackoffStrategy;\n baseMs: number;\n maxMs: number;\n /** Add random jitter (0..baseMs) to avoid thundering herd. Default true. */\n jitter?: boolean;\n /**\n * Delay (ms) before re-queueing a record whose publish was rejected with\n * `errorKind: \"backpressure\"` (client-side producer buffer full).\n *\n * Backpressure failures do NOT count as a failed attempt — the buffer\n * being full is a \"slow down\" signal, not a record-specific failure.\n * The record is requeued at the next interval, not promoted to dead.\n *\n * Default 1000 ms. Requires the {@link OutboxStore} to implement\n * `requeue` — stores without it fall back to {@link OutboxStore.markFailed}\n * which DOES increment attempts.\n */\n backpressureDelayMs?: number;\n /**\n * Multiplier applied to the computed backoff for records rejected with\n * `errorKind: \"quota\"` (broker is throttling the producer). Default 5 —\n * a quota signal asks for a longer breath than a generic transient error.\n * Quota failures DO count as attempts (unlike backpressure).\n */\n quotaMultiplier?: number;\n}\n\nexport interface DlqConfig {\n /** Topic to route dead messages to. If absent, dead messages are parked. */\n topic?: string;\n /**\n * Include a truncated stack trace as the `dlq-error-stack` header when\n * routing a record to the DLQ. Default false — keep DLQ messages small\n * by default; opt in if your triage workflow needs the stack.\n */\n includeStackTraces?: boolean;\n /**\n * Maximum bytes of the truncated stack trace included when\n * `includeStackTraces` is on. Default 4096.\n */\n maxStackBytes?: number;\n}\n\n/**\n * Optional trace-context propagator. `inject` writes the active trace context\n * into the carrier as W3C `traceparent`/`tracestate` (the shape of an\n * OpenTelemetry TextMapPropagator), so it is persisted with the outbox row and\n * carried to the published message. The library depends on no tracing package;\n * provide a thin adapter over yours (OpenTelemetry, Datadog, …).\n */\nexport interface Tracing {\n inject(carrier: Record<string, string>): void;\n}\n\n/**\n * Minimal structured logger. Console-backed default provided.\n */\nexport interface Logger {\n debug(msg: string, meta?: Record<string, unknown>): void;\n info(msg: string, meta?: Record<string, unknown>): void;\n warn(msg: string, meta?: Record<string, unknown>): void;\n error(msg: string, meta?: Record<string, unknown>): void;\n}\n\n/**\n * A low-latency wake source for the relay. Instead of only waking on the poll\n * interval, the relay claims immediately whenever `onWake` fires. The signal is\n * advisory: it may fire spuriously or be missed entirely, and the relay's\n * polling remains the safety net, so implementations need not deduplicate or\n * guarantee delivery. (e.g. a Postgres LISTEN/NOTIFY waker.)\n */\nexport interface Waker {\n start(onWake: () => void): Promise<void>;\n stop(): Promise<void>;\n}\n\n/**\n * Lifecycle / observability hooks emitted by the relay.\n */\nexport interface RelayHooks {\n onBatchClaimed?(count: number): void;\n onPublished?(result: PublishResult): void;\n onFailed?(record: OutboxRecord, error: Error, willRetry: boolean): void;\n onDead?(record: OutboxRecord, error: Error): void;\n onError?(error: Error): void;\n}\n","import type { RetryConfig } from \"./types.js\";\n\n/**\n * Compute the delay (ms) before the next retry attempt.\n *\n * @param attempt 1-based attempt number that just failed.\n */\nexport function computeBackoff(config: RetryConfig, attempt: number): number {\n const { strategy, baseMs, maxMs } = config;\n const jitter = config.jitter ?? true;\n\n let delay: number;\n switch (strategy) {\n case \"fixed\":\n delay = baseMs;\n break;\n case \"linear\":\n delay = baseMs * attempt;\n break;\n case \"exponential\":\n // base * 2^(attempt-1), capped to avoid overflow before clamping\n delay = baseMs * 2 ** Math.min(attempt - 1, 30);\n break;\n }\n\n delay = Math.min(delay, maxMs);\n\n if (jitter) {\n // Full jitter: random in [0, delay]. Decorrelates concurrent relays.\n delay = Math.random() * delay;\n }\n\n return Math.floor(delay);\n}\n\n/**\n * Resolve when (Date) the next retry should occur, or null if the\n * record has exhausted its attempts and should go dead.\n */\nexport function nextRetryAt(\n config: RetryConfig,\n attempts: number,\n now: Date = new Date(),\n): Date | null {\n if (attempts >= config.maxAttempts) return null;\n const delay = computeBackoff(config, attempts);\n return new Date(now.getTime() + delay);\n}\n","import type { Logger, OutboxRecord, Serializer } from \"./types.js\";\n\n/**\n * Default serializer: JSON-encodes the record payload to UTF-8 bytes.\n */\nexport class JsonSerializer implements Serializer {\n readonly contentType = \"application/json\";\n\n serialize(record: OutboxRecord): Buffer {\n return Buffer.from(JSON.stringify(record.payload), \"utf8\");\n }\n}\n\n/**\n * Minimal console-backed logger. Swap in pino/winston by implementing Logger.\n */\nexport class ConsoleLogger implements Logger {\n constructor(private readonly prefix = \"[outbox]\") {}\n\n private fmt(\n write: (line: string, meta?: Record<string, unknown>) => void,\n level: string,\n msg: string,\n meta?: Record<string, unknown>,\n ): void {\n const line = `${this.prefix} ${level} ${msg}`;\n if (meta) {\n write(line, meta);\n } else {\n write(line);\n }\n }\n\n debug(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.debug, \"DEBUG\", msg, meta);\n }\n info(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.info, \"INFO\", msg, meta);\n }\n warn(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.warn, \"WARN\", msg, meta);\n }\n error(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.error, \"ERROR\", msg, meta);\n }\n}\n\n/** Logger that discards everything. Useful for tests. */\nexport class NoopLogger implements Logger {\n debug(): void {}\n info(): void {}\n warn(): void {}\n error(): void {}\n}\n","import type { OutboxRecord, PublishableMessage, Serializer } from \"./types.js\";\n\n/**\n * Turn a persisted outbox record into a broker-ready message: serialize the\n * payload and attach the standard correlation headers. Shared by the polling\n * `Relay` and the streaming relay so both produce byte-identical messages.\n */\nexport async function buildPublishable(\n record: OutboxRecord,\n serializer: Serializer,\n): Promise<PublishableMessage> {\n const value = await serializer.serialize(record);\n const headers: Record<string, string> = {\n ...record.headers,\n \"content-type\": serializer.contentType,\n \"message-id\": record.messageId,\n \"aggregate-type\": record.aggregateType,\n \"aggregate-id\": record.aggregateId,\n };\n if (record.traceId) headers[\"trace-id\"] = record.traceId;\n\n return {\n topic: record.topic,\n key: record.key ?? record.aggregateId,\n value,\n headers,\n recordId: record.id,\n messageId: record.messageId,\n };\n}\n","import { computeBackoff } from \"./backoff.js\";\nimport { buildPublishable } from \"./publishable.js\";\nimport { ConsoleLogger, JsonSerializer } from \"./serializer.js\";\nimport type {\n DlqConfig,\n Logger,\n OutboxRecord,\n OutboxStore,\n PublishableMessage,\n PublishErrorKind,\n Publisher,\n RelayHooks,\n RetryConfig,\n Serializer,\n Waker,\n} from \"./types.js\";\n\nexport interface RelayOptions {\n store: OutboxStore;\n publisher: Publisher;\n /** Messages claimed per poll iteration. Default 100. */\n batchSize?: number;\n /** Idle wait (ms) when a poll returns no work. Default 200. */\n pollIntervalMs?: number;\n retry?: Partial<RetryConfig>;\n dlq?: DlqConfig;\n serializer?: Serializer;\n logger?: Logger;\n hooks?: RelayHooks;\n /**\n * Optional low-latency wake source. When provided, the relay claims as soon as\n * the waker signals new work, instead of waiting out `pollIntervalMs`. Polling\n * stays on as a safety net, so set `pollIntervalMs` longer when using a waker.\n */\n waker?: Waker;\n}\n\nconst DEFAULT_RETRY: RetryConfig = {\n maxAttempts: 5,\n strategy: \"exponential\",\n baseMs: 200,\n maxMs: 30_000,\n jitter: true,\n};\n\n/**\n * The Relay drains the outbox store and publishes messages to the broker.\n *\n * It is safe to run multiple Relay instances concurrently against the same\n * store as long as the store's claimBatch uses a lock-free claim strategy\n * (e.g. SELECT ... FOR UPDATE SKIP LOCKED).\n */\nexport class Relay {\n private readonly store: OutboxStore;\n private readonly publisher: Publisher;\n private readonly batchSize: number;\n private readonly pollIntervalMs: number;\n private readonly retry: RetryConfig;\n private readonly dlq: DlqConfig;\n private readonly serializer: Serializer;\n private readonly log: Logger;\n private readonly hooks: RelayHooks;\n private readonly waker: Waker | null;\n\n private running = false;\n private stopping = false;\n private loopPromise: Promise<void> | null = null;\n\n // Interruptible idle wait: `signal()` wakes a pending wait (or marks one\n // pending if none is in flight, so a wake can't be lost between cycles).\n private wakePending = false;\n private wakeResolver: (() => void) | null = null;\n\n constructor(opts: RelayOptions) {\n this.store = opts.store;\n this.publisher = opts.publisher;\n this.batchSize = opts.batchSize ?? 100;\n this.pollIntervalMs = opts.pollIntervalMs ?? 200;\n this.retry = { ...DEFAULT_RETRY, ...opts.retry };\n this.dlq = opts.dlq ?? {};\n this.serializer = opts.serializer ?? new JsonSerializer();\n this.log = opts.logger ?? new ConsoleLogger();\n this.hooks = opts.hooks ?? {};\n this.waker = opts.waker ?? null;\n }\n\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n this.stopping = false;\n\n await this.store.init?.();\n await this.publisher.connect();\n await this.waker?.start(() => this.signal());\n this.log.info(\"relay started\", {\n batchSize: this.batchSize,\n pollIntervalMs: this.pollIntervalMs,\n waker: this.waker !== null,\n });\n\n this.loopPromise = this.loop();\n }\n\n /**\n * Stop accepting new work, finish the in-flight batch, then disconnect.\n */\n async stop(): Promise<void> {\n if (!this.running) return;\n this.stopping = true;\n this.signal(); // break any in-flight idle wait so shutdown is immediate\n this.log.info(\"relay stopping, draining in-flight batch\");\n await this.loopPromise;\n await this.waker?.stop();\n await this.publisher.disconnect();\n await this.store.close?.();\n this.running = false;\n this.log.info(\"relay stopped\");\n }\n\n private async loop(): Promise<void> {\n while (!this.stopping) {\n try {\n const processed = await this.tick();\n if (processed === 0 && !this.stopping) {\n await this.waitForWork(this.pollIntervalMs);\n }\n } catch (err) {\n const error = err instanceof Error ? err : new Error(String(err));\n this.log.error(\"relay loop error\", { error: error.message });\n this.hooks.onError?.(error);\n await this.waitForWork(this.pollIntervalMs);\n }\n }\n }\n\n /**\n * Run a single claim+publish cycle. Returns number of records processed.\n * Exposed for tests / manual single-shot draining.\n */\n async tick(): Promise<number> {\n const batch = await this.store.claimBatch(this.batchSize);\n if (batch.length === 0) return 0;\n\n this.hooks.onBatchClaimed?.(batch.length);\n this.log.debug(\"batch claimed\", { count: batch.length });\n\n const messages = await this.toPublishable(batch);\n const recordsById = new Map(batch.map((r) => [r.id, r]));\n\n const results = await this.publisher.publish(messages);\n\n const succeeded: string[] = [];\n for (const result of results) {\n const record = recordsById.get(result.recordId);\n if (!record) continue;\n\n if (result.ok) {\n succeeded.push(record.id);\n this.hooks.onPublished?.(result);\n } else {\n await this.handleFailure(\n record,\n result.error ?? new Error(\"unknown publish error\"),\n result.errorKind,\n );\n }\n }\n\n if (succeeded.length > 0) {\n await this.store.markDone(succeeded);\n }\n\n return batch.length;\n }\n\n private async handleFailure(\n record: OutboxRecord,\n error: Error,\n errorKind?: PublishErrorKind,\n ): Promise<void> {\n // Backpressure: client-side producer queue is full. Re-queue the record\n // with a brief delay; do NOT increment attempts (this isn't the record's\n // fault, the producer is just full). Stores without `requeue` fall back\n // to markFailed, accepting the attempt-counter hit.\n if (errorKind === \"backpressure\") {\n const delay = this.retry.backpressureDelayMs ?? 1000;\n const retryAt = new Date(Date.now() + delay);\n this.hooks.onFailed?.(record, error, true);\n this.log.warn(\"publish backpressure — requeueing without bumping attempts\", {\n recordId: record.id,\n retryAt: retryAt.toISOString(),\n errorKind,\n error: error.message,\n });\n if (this.store.requeue) {\n await this.store.requeue(record.id, retryAt);\n } else {\n // Fallback: markFailed (will increment attempts). Documented in the\n // OutboxStore.requeue JSDoc.\n await this.store.markFailed(record.id, retryAt, \"failed\");\n }\n return;\n }\n\n const attempts = record.attempts + 1;\n // fatal/poison short-circuit: retrying cannot help (auth denied, fenced\n // epoch, oversized record, schema rejected). Skip the backoff schedule\n // entirely and go straight to DLQ + dead.\n const isTerminalKind = errorKind === \"fatal\" || errorKind === \"poison\";\n const retryAt = isTerminalKind\n ? null\n : this.nextRetryAtForKind(attempts, errorKind);\n const willRetry = retryAt !== null;\n\n this.hooks.onFailed?.(record, error, willRetry);\n this.log.warn(\"publish failed\", {\n recordId: record.id,\n attempts,\n willRetry,\n errorKind: errorKind ?? \"retriable\",\n error: error.message,\n });\n\n if (willRetry) {\n await this.store.markFailed(record.id, retryAt, \"failed\");\n return;\n }\n\n // Terminal: route to DLQ if configured, then mark dead.\n if (this.dlq.topic && this.publisher.publishToDlq) {\n try {\n const msg = (await this.toPublishable([record]))[0];\n if (msg) {\n await this.publisher.publishToDlq(\n {\n ...msg,\n topic: this.dlq.topic,\n // Per-record enrichment so the DLQ consumer has everything\n // needed for triage: original destination, aggregate / message\n // identity, the attempts count, and (opt-in) a truncated stack.\n headers: this.buildDlqHeaders(record, error, msg.headers),\n },\n error,\n );\n }\n } catch (dlqErr) {\n const e = dlqErr instanceof Error ? dlqErr : new Error(String(dlqErr));\n this.log.error(\"DLQ publish failed\", {\n recordId: record.id,\n error: e.message,\n });\n }\n }\n\n await this.store.markFailed(record.id, null, \"dead\");\n this.hooks.onDead?.(record, error);\n }\n\n /**\n * Compute the next retry instant, applying the quota multiplier when the\n * driver classified the failure as a server throttle. Quota failures DO\n * count as attempts (unlike backpressure) — the multiplier only stretches\n * the delay, not the budget.\n */\n private nextRetryAtForKind(\n attempts: number,\n errorKind: PublishErrorKind | undefined,\n ): Date | null {\n if (attempts >= this.retry.maxAttempts) return null;\n const baseDelay = computeBackoff(this.retry, attempts);\n const multiplier =\n errorKind === \"quota\" ? this.retry.quotaMultiplier ?? 5 : 1;\n const delay = Math.min(baseDelay * multiplier, this.retry.maxMs * 10);\n return new Date(Date.now() + delay);\n }\n\n /**\n * Build the per-record DLQ header bag. Operators triaging the DLQ get the\n * original destination, the aggregate / message identity, attempts count,\n * and optionally a truncated error stack — everything you need to decide\n * whether to re-enqueue, escalate, or drop.\n */\n private buildDlqHeaders(\n record: OutboxRecord,\n error: Error,\n existing: Record<string, string>,\n ): Record<string, string> {\n const out: Record<string, string> = {\n ...existing,\n \"original-topic\": record.topic,\n \"dlq-attempts\": String(record.attempts + 1),\n \"dlq-original-aggregate-id\": record.aggregateId,\n \"dlq-original-message-id\": record.messageId,\n };\n if (this.dlq.includeStackTraces && error.stack) {\n const maxBytes = this.dlq.maxStackBytes ?? 4096;\n out[\"dlq-error-stack\"] = truncateUtf8(error.stack, maxBytes);\n }\n return out;\n }\n\n private async toPublishable(\n records: OutboxRecord[],\n ): Promise<PublishableMessage[]> {\n const out: PublishableMessage[] = [];\n for (const record of records) {\n out.push(await buildPublishable(record, this.serializer));\n }\n return out;\n }\n\n /** Wake a pending idle wait, or mark one pending so the next wait is skipped. */\n private signal(): void {\n this.wakePending = true;\n this.wakeResolver?.();\n }\n\n /**\n * Idle wait that resolves after `ms`, or early when `signal()` fires. A signal\n * raised while no wait is in flight is remembered (wakePending), so a wake that\n * races a claim cycle is never lost.\n */\n private waitForWork(ms: number): Promise<void> {\n if (this.wakePending) {\n this.wakePending = false;\n return Promise.resolve();\n }\n return new Promise((resolve) => {\n const timer = setTimeout(() => {\n this.wakeResolver = null;\n resolve();\n }, ms);\n this.wakeResolver = () => {\n clearTimeout(timer);\n this.wakeResolver = null;\n this.wakePending = false;\n resolve();\n };\n });\n }\n}\n\n/**\n * Truncate a string to at most `maxBytes` of its UTF-8 encoding, appending a\n * \"…[truncated]\" marker when bytes are removed. Used to keep the DLQ stack\n * header bounded — headers in Kafka are byte-sized, not character-sized, so\n * a naive `slice` on multibyte text can blow the limit. Always returns a\n * valid (no-broken-codepoint) UTF-8 string.\n */\nfunction truncateUtf8(input: string, maxBytes: number): string {\n const marker = \"…[truncated]\";\n const markerBytes = Buffer.byteLength(marker, \"utf8\");\n const buf = Buffer.from(input, \"utf8\");\n if (buf.byteLength <= maxBytes) return input;\n const budget = Math.max(0, maxBytes - markerBytes);\n // Slice and decode; if the slice lands mid-codepoint, drop trailing bytes\n // until the decode is clean.\n let end = budget;\n while (end > 0) {\n const slice = buf.subarray(0, end).toString(\"utf8\");\n if (!slice.endsWith(\"�\")) {\n return slice + marker;\n }\n end--;\n }\n return marker;\n}\n","import type { StandardSchemaV1 } from \"./standard-schema.js\";\n\n/**\n * Thrown when an outbox payload fails its topic's schema — at `enqueue`\n * (before the DB insert) or at `decode` (malformed bytes / schema mismatch).\n * Carries the topic and the validator's structured issues for diagnostics.\n */\nexport class OutboxValidationError extends Error {\n readonly topic: string;\n readonly issues: ReadonlyArray<StandardSchemaV1.Issue>;\n\n constructor(\n topic: string,\n issues: ReadonlyArray<StandardSchemaV1.Issue>,\n options?: { cause?: unknown },\n ) {\n const first = issues[0]?.message ?? \"unknown validation error\";\n super(`Outbox payload for \"${topic}\" failed validation: ${first}`, options);\n this.name = \"OutboxValidationError\";\n this.topic = topic;\n this.issues = issues;\n }\n}\n","import { OutboxValidationError } from \"./errors.js\";\nimport type { StandardSchemaV1 } from \"./standard-schema.js\";\nimport type { OutboxMessageInput } from \"./types.js\";\n\n/** One topic's contract: the aggregate it belongs to and its payload schema. */\nexport interface TopicDefinition {\n /** Aggregate type stamped on every event of this topic (e.g. \"order\"). */\n readonly aggregateType: string;\n /** Standard Schema for the payload (Zod 3.24+/Valibot/ArkType/…). */\n readonly schema: StandardSchemaV1;\n}\n\n/** A map of topic name -> its definition. The single source of truth. */\nexport type OutboxRegistry = Record<string, TopicDefinition>;\n\n/**\n * Minimal store surface the producer facade needs. `PostgresStore` satisfies\n * this structurally, so the facade stays DB-agnostic and `tx` flows from the\n * concrete store (e.g. a pg client).\n */\nexport interface EnqueueableStore<Tx = unknown> {\n enqueue(tx: Tx, msg: OutboxMessageInput & { traceId?: string }): Promise<string>;\n}\n\ntype TxOf<S> = S extends EnqueueableStore<infer Tx> ? Tx : never;\n\ntype PayloadInput<R extends OutboxRegistry, K extends keyof R> =\n StandardSchemaV1.InferInput<R[K][\"schema\"]>;\ntype PayloadOutput<R extends OutboxRegistry, K extends keyof R> =\n StandardSchemaV1.InferOutput<R[K][\"schema\"]>;\n\n/** The write-side input for a typed enqueue (payload is the schema's input type). */\nexport interface EnqueueInput<R extends OutboxRegistry, K extends keyof R> {\n aggregateId: string;\n payload: PayloadInput<R, K>;\n key?: string;\n headers?: Record<string, string>;\n messageId?: string;\n traceId?: string;\n}\n\n/** Consumer-side facade: validate/decode without a store. */\nexport interface OutboxConsumer<R extends OutboxRegistry> {\n /** Validate an already-parsed value against the topic's schema. */\n validate<K extends keyof R & string>(\n topic: K,\n value: unknown,\n ): Promise<PayloadOutput<R, K>>;\n /** JSON-parse `bytes`, validate against the topic's schema, return the payload. */\n decode<K extends keyof R & string>(\n topic: K,\n bytes: Buffer | Uint8Array | string,\n ): Promise<PayloadOutput<R, K>>;\n}\n\n/** Producer-side facade: adds typed, validated enqueue. */\nexport interface OutboxProducer<R extends OutboxRegistry, Tx>\n extends OutboxConsumer<R> {\n /** Validate `payload`, then enqueue inside the caller's transaction `tx`. */\n enqueue<K extends keyof R & string>(\n tx: Tx,\n topic: K,\n msg: EnqueueInput<R, K>,\n ): Promise<string>;\n}\n\n// Loose shape used inside the implementation; the public types come from overloads.\ninterface LooseEnqueueInput {\n aggregateId: string;\n payload: unknown;\n key?: string;\n headers?: Record<string, string>;\n messageId?: string;\n traceId?: string;\n}\n\nexport function defineOutbox<R extends OutboxRegistry>(\n registry: R,\n): OutboxConsumer<R>;\nexport function defineOutbox<R extends OutboxRegistry, S extends EnqueueableStore>(\n registry: R,\n opts: { store: S },\n): OutboxProducer<R, TxOf<S>>;\nexport function defineOutbox<R extends OutboxRegistry>(\n registry: R,\n opts?: { store: EnqueueableStore },\n): OutboxConsumer<R> | OutboxProducer<R, unknown> {\n const validate = async (topic: string, value: unknown): Promise<unknown> => {\n const def = registry[topic];\n if (!def) {\n throw new OutboxValidationError(topic, [\n { message: `unknown topic \"${topic}\"` },\n ]);\n }\n const result = await def.schema[\"~standard\"].validate(value);\n if (result.issues) throw new OutboxValidationError(topic, result.issues);\n return result.value;\n };\n\n const decode = async (\n topic: string,\n bytes: Buffer | Uint8Array | string,\n ): Promise<unknown> => {\n const text =\n typeof bytes === \"string\" ? bytes : Buffer.from(bytes).toString(\"utf8\");\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch (err) {\n throw new OutboxValidationError(\n topic,\n [{ message: `invalid JSON: ${(err as Error).message}` }],\n { cause: err },\n );\n }\n return validate(topic, parsed);\n };\n\n const consumer = { validate, decode } as unknown as OutboxConsumer<R>;\n if (!opts?.store) return consumer;\n\n const store = opts.store;\n const enqueue = async (\n tx: unknown,\n topic: string,\n msg: LooseEnqueueInput,\n ): Promise<string> => {\n const def = registry[topic];\n if (!def) {\n throw new OutboxValidationError(topic, [\n { message: `unknown topic \"${topic}\"` },\n ]);\n }\n const payload = await validate(topic, msg.payload);\n return store.enqueue(tx, {\n topic,\n aggregateType: def.aggregateType,\n aggregateId: msg.aggregateId,\n payload,\n key: msg.key,\n headers: msg.headers,\n messageId: msg.messageId,\n traceId: msg.traceId,\n });\n };\n\n return { validate, decode, enqueue } as unknown as OutboxProducer<R, unknown>;\n}\n"],"mappings":"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","names":["retryAt"]}
1
+ {"version":3,"sources":["../src/types.ts","../src/backoff.ts","../src/serializer.ts","../src/publishable.ts","../src/relay.ts","../src/errors.ts","../src/registry.ts"],"sourcesContent":["/**\n * Status lifecycle of an outbox record.\n *\n * pending -> freshly enqueued, awaiting first publish\n * processing -> claimed by a relay instance, publish in flight\n * done -> successfully published to the broker\n * failed -> publish failed, awaiting retry (next_retry_at)\n * dead -> exhausted retries, routed to DLQ (or parked)\n */\nexport type OutboxStatus = \"pending\" | \"processing\" | \"done\" | \"failed\" | \"dead\";\n\nexport const OUTBOX_STATUS_CODE: Record<OutboxStatus, number> = {\n pending: 0,\n processing: 1,\n done: 2,\n failed: 3,\n dead: 4,\n};\n\nexport const OUTBOX_STATUS_FROM_CODE: Record<number, OutboxStatus> = {\n 0: \"pending\",\n 1: \"processing\",\n 2: \"done\",\n 3: \"failed\",\n 4: \"dead\",\n};\n\n/**\n * A message as enqueued by the application inside its own DB transaction.\n * This is the write-side input — no infra concerns leak in here.\n */\nexport interface OutboxMessageInput {\n /** Logical topic the message will be published to. */\n topic: string;\n /** Type of the aggregate that produced this event (e.g. \"order\"). */\n aggregateType: string;\n /**\n * Identifier of the aggregate instance (e.g. order id).\n * Used as the default partition key to preserve per-aggregate ordering.\n */\n aggregateId: string;\n /** Event payload. Serialized by the configured serializer. */\n payload: unknown;\n /** Optional explicit partition key. Falls back to aggregateId. */\n key?: string;\n /** Optional message headers. */\n headers?: Record<string, string>;\n /**\n * Optional client-supplied message id for idempotency / dedup.\n * If omitted, the store generates one.\n */\n messageId?: string;\n}\n\n/**\n * A persisted outbox record as read back by the relay.\n */\nexport interface OutboxRecord {\n id: string;\n messageId: string;\n topic: string;\n aggregateType: string;\n aggregateId: string;\n key: string | null;\n payload: unknown;\n headers: Record<string, string>;\n traceId: string | null;\n status: OutboxStatus;\n attempts: number;\n nextRetryAt: Date | null;\n createdAt: Date;\n processedAt: Date | null;\n}\n\n/**\n * The message handed to a Publisher after serialization.\n */\nexport interface PublishableMessage {\n topic: string;\n key: string | null;\n /** Serialized payload bytes. */\n value: Buffer;\n headers: Record<string, string>;\n /** Original record id, for correlation in publish results. */\n recordId: string;\n messageId: string;\n /**\n * Explicit partition override. When set, the publisher MUST route the\n * record to this exact partition, bypassing the configured partitioner.\n *\n * Use cases:\n * - Compacted topics with application-managed sharding.\n * - Tenant-affinity routing where you compute the partition yourself.\n * - Geo-pinning records to a specific broker.\n *\n * When omitted (the default), the underlying client's partitioner\n * decides — usually a hash of `key`, falling back to sticky round-robin\n * when `key` is null.\n */\n partition?: number;\n}\n\n/**\n * Why a publish failed, in terms the relay can act on. Drivers classify their\n * native errors into one of these buckets; the relay reads `errorKind` to\n * decide whether to retry, short-circuit to the DLQ, or pause polling. The\n * field is optional for backward compatibility — when absent, the relay\n * treats the error as `\"retriable\"`.\n *\n * - `retriable` — transient (broker unreachable, leader election, request\n * timeout); retry per the configured backoff policy. The default for any\n * unclassified error.\n * - `fatal` — the producer or the credentials are broken (fenced epoch,\n * authentication failed, ACL denied). Retrying cannot help; the relay\n * short-circuits straight to the DLQ + `dead` status.\n * - `poison` — the message itself is rejectable by every broker\n * (oversized record, corrupt payload, schema-registry refused encoding).\n * Same handling as `fatal`: DLQ + dead, no retries.\n * - `backpressure` — the *producer's own* outbound buffer is full\n * (librdkafka `__QUEUE_FULL`). The right response is to slow the relay\n * down, not to burn retries. v2.1 treats this as `retriable` for\n * compatibility; smarter handling (pause polling) is planned.\n * - `quota` — the broker is throttling us (`THROTTLING_QUOTA_EXCEEDED`).\n * Back off with longer delays. v2.1 treats as `retriable`; smarter\n * handling (longer backoff) is planned.\n * - `fenced` — the broker fenced this producer epoch\n * (`PRODUCER_FENCED` / `INVALID_PRODUCER_EPOCH`). Usually caused by\n * another instance taking the same `transactionalId`, but ALSO fired\n * on transient broker restart / network partition recovery. Distinct\n * from `fatal` because the `KafkaPublisher` can transparently\n * `disconnect + connect + initTransactions` once before falling back\n * to fatal (`autoRecoverFromFence: true` on the publisher, default\n * `false` to keep multi-instance behavior surprise-free).\n */\nexport type PublishErrorKind =\n | \"retriable\"\n | \"fatal\"\n | \"poison\"\n | \"backpressure\"\n | \"quota\"\n | \"fenced\";\n\n/**\n * Result of attempting to publish a single message.\n */\nexport interface PublishResult {\n recordId: string;\n ok: boolean;\n error?: Error;\n /**\n * Optional classification of `error` for relay-level decision-making. Set\n * by publisher implementations that know how to inspect their native error\n * shapes. Absent value is treated as `\"retriable\"` by the relay (the safe\n * default — at worst we retry an error we should have skipped).\n */\n errorKind?: PublishErrorKind;\n}\n\n/**\n * Pluggable serializer. Default is JSON; users can swap in\n * Avro / Protobuf / Schema-Registry-backed serializers.\n */\nexport interface Serializer {\n serialize(message: OutboxRecord): Buffer | Promise<Buffer>;\n /** Content-type header value advertised for this serializer. */\n readonly contentType: string;\n}\n\n/**\n * Storage abstraction. Implemented per-database (postgres, mysql, ...).\n * The relay only talks to the store through this interface.\n */\nexport interface OutboxStore {\n /**\n * Atomically claim up to `batchSize` due messages and mark them\n * as `processing`. Implementations MUST be safe under concurrent\n * relay instances (e.g. SELECT ... FOR UPDATE SKIP LOCKED).\n */\n claimBatch(batchSize: number): Promise<OutboxRecord[]>;\n\n /** Mark records as successfully published. */\n markDone(recordIds: string[]): Promise<void>;\n\n /**\n * Mark a record as failed and schedule its next retry.\n * `nextRetryAt` of null + status \"dead\" means terminal.\n * Implementations MUST increment `attempts` so the retry budget is honored.\n */\n markFailed(\n recordId: string,\n nextRetryAt: Date | null,\n status: \"failed\" | \"dead\",\n ): Promise<void>;\n\n /**\n * Re-queue a record back to `failed` with the given `retryAt` **WITHOUT\n * incrementing attempts**. Used by the relay for backpressure handling —\n * a client-side queue-full failure is a \"slow down\" signal, not a\n * record-specific failure, and counting it as a retry would burn the\n * attempt budget unfairly.\n *\n * Optional: stores that don't implement this fall back to `markFailed`\n * (which increments attempts, with the caveat documented above).\n */\n requeue?(recordId: string, retryAt: Date): Promise<void>;\n\n /** Best-effort lifecycle hooks; no-op allowed. */\n init?(): Promise<void>;\n close?(): Promise<void>;\n}\n\n/**\n * Broker abstraction. Implemented per-driver (kafkajs, confluent, ...).\n */\nexport interface Publisher {\n connect(): Promise<void>;\n disconnect(): Promise<void>;\n /**\n * Publish a batch. Returns a per-message result so the relay can\n * mark partial success. Implementations may use a transactional\n * producer to make the batch atomic.\n */\n publish(messages: PublishableMessage[]): Promise<PublishResult[]>;\n /** Route a permanently-failed message to a dead-letter destination. */\n publishToDlq?(message: PublishableMessage, error: Error): Promise<void>;\n}\n\n/**\n * Backoff strategy for retrying failed publishes.\n */\nexport type BackoffStrategy = \"fixed\" | \"linear\" | \"exponential\";\n\nexport interface RetryConfig {\n maxAttempts: number;\n strategy: BackoffStrategy;\n baseMs: number;\n maxMs: number;\n /** Add random jitter (0..baseMs) to avoid thundering herd. Default true. */\n jitter?: boolean;\n /**\n * Delay (ms) before re-queueing a record whose publish was rejected with\n * `errorKind: \"backpressure\"` (client-side producer buffer full).\n *\n * Backpressure failures do NOT count as a failed attempt — the buffer\n * being full is a \"slow down\" signal, not a record-specific failure.\n * The record is requeued at the next interval, not promoted to dead.\n *\n * Default 1000 ms. Requires the {@link OutboxStore} to implement\n * `requeue` — stores without it fall back to {@link OutboxStore.markFailed}\n * which DOES increment attempts.\n */\n backpressureDelayMs?: number;\n /**\n * Multiplier applied to the computed backoff for records rejected with\n * `errorKind: \"quota\"` (broker is throttling the producer). Default 5 —\n * a quota signal asks for a longer breath than a generic transient error.\n * Quota failures DO count as attempts (unlike backpressure).\n */\n quotaMultiplier?: number;\n}\n\nexport interface DlqConfig {\n /** Topic to route dead messages to. If absent, dead messages are parked. */\n topic?: string;\n /**\n * Include a truncated stack trace as the `dlq-error-stack` header when\n * routing a record to the DLQ. Default false — keep DLQ messages small\n * by default; opt in if your triage workflow needs the stack.\n */\n includeStackTraces?: boolean;\n /**\n * Maximum bytes of the truncated stack trace included when\n * `includeStackTraces` is on. Default 4096.\n */\n maxStackBytes?: number;\n}\n\n/**\n * Optional trace-context propagator. `inject` writes the active trace context\n * into the carrier as W3C `traceparent`/`tracestate` (the shape of an\n * OpenTelemetry TextMapPropagator), so it is persisted with the outbox row and\n * carried to the published message. The library depends on no tracing package;\n * provide a thin adapter over yours (OpenTelemetry, Datadog, …).\n */\nexport interface Tracing {\n inject(carrier: Record<string, string>): void;\n}\n\n/**\n * Minimal structured logger. Console-backed default provided.\n */\nexport interface Logger {\n debug(msg: string, meta?: Record<string, unknown>): void;\n info(msg: string, meta?: Record<string, unknown>): void;\n warn(msg: string, meta?: Record<string, unknown>): void;\n error(msg: string, meta?: Record<string, unknown>): void;\n}\n\n/**\n * A low-latency wake source for the relay. Instead of only waking on the poll\n * interval, the relay claims immediately whenever `onWake` fires. The signal is\n * advisory: it may fire spuriously or be missed entirely, and the relay's\n * polling remains the safety net, so implementations need not deduplicate or\n * guarantee delivery. (e.g. a Postgres LISTEN/NOTIFY waker.)\n */\nexport interface Waker {\n start(onWake: () => void): Promise<void>;\n stop(): Promise<void>;\n}\n\n/**\n * Lifecycle / observability hooks emitted by the relay.\n */\nexport interface RelayHooks {\n onBatchClaimed?(count: number): void;\n onPublished?(result: PublishResult): void;\n onFailed?(record: OutboxRecord, error: Error, willRetry: boolean): void;\n onDead?(record: OutboxRecord, error: Error): void;\n onError?(error: Error): void;\n}\n","import type { RetryConfig } from \"./types.js\";\n\n/**\n * Compute the delay (ms) before the next retry attempt.\n *\n * @param attempt 1-based attempt number that just failed.\n */\nexport function computeBackoff(config: RetryConfig, attempt: number): number {\n const { strategy, baseMs, maxMs } = config;\n const jitter = config.jitter ?? true;\n\n let delay: number;\n switch (strategy) {\n case \"fixed\":\n delay = baseMs;\n break;\n case \"linear\":\n delay = baseMs * attempt;\n break;\n case \"exponential\":\n // base * 2^(attempt-1), capped to avoid overflow before clamping\n delay = baseMs * 2 ** Math.min(attempt - 1, 30);\n break;\n }\n\n delay = Math.min(delay, maxMs);\n\n if (jitter) {\n // Full jitter: random in [0, delay]. Decorrelates concurrent relays.\n delay = Math.random() * delay;\n }\n\n return Math.floor(delay);\n}\n\n/**\n * Resolve when (Date) the next retry should occur, or null if the\n * record has exhausted its attempts and should go dead.\n */\nexport function nextRetryAt(\n config: RetryConfig,\n attempts: number,\n now: Date = new Date(),\n): Date | null {\n if (attempts >= config.maxAttempts) return null;\n const delay = computeBackoff(config, attempts);\n return new Date(now.getTime() + delay);\n}\n","import type { Logger, OutboxRecord, Serializer } from \"./types.js\";\n\n/**\n * Default serializer: JSON-encodes the record payload to UTF-8 bytes.\n */\nexport class JsonSerializer implements Serializer {\n readonly contentType = \"application/json\";\n\n serialize(record: OutboxRecord): Buffer {\n return Buffer.from(JSON.stringify(record.payload), \"utf8\");\n }\n}\n\n/**\n * Minimal console-backed logger. Swap in pino/winston by implementing Logger.\n */\nexport class ConsoleLogger implements Logger {\n constructor(private readonly prefix = \"[outbox]\") {}\n\n private fmt(\n write: (line: string, meta?: Record<string, unknown>) => void,\n level: string,\n msg: string,\n meta?: Record<string, unknown>,\n ): void {\n const line = `${this.prefix} ${level} ${msg}`;\n if (meta) {\n write(line, meta);\n } else {\n write(line);\n }\n }\n\n debug(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.debug, \"DEBUG\", msg, meta);\n }\n info(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.info, \"INFO\", msg, meta);\n }\n warn(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.warn, \"WARN\", msg, meta);\n }\n error(msg: string, meta?: Record<string, unknown>): void {\n // eslint-disable-next-line no-console\n this.fmt(console.error, \"ERROR\", msg, meta);\n }\n}\n\n/** Logger that discards everything. Useful for tests. */\nexport class NoopLogger implements Logger {\n debug(): void {}\n info(): void {}\n warn(): void {}\n error(): void {}\n}\n","import type { OutboxRecord, PublishableMessage, Serializer } from \"./types.js\";\n\n/**\n * Turn a persisted outbox record into a broker-ready message: serialize the\n * payload and attach the standard correlation headers. Shared by the polling\n * `Relay` and the streaming relay so both produce byte-identical messages.\n */\nexport async function buildPublishable(\n record: OutboxRecord,\n serializer: Serializer,\n): Promise<PublishableMessage> {\n const value = await serializer.serialize(record);\n const headers: Record<string, string> = {\n ...record.headers,\n \"content-type\": serializer.contentType,\n \"message-id\": record.messageId,\n \"aggregate-type\": record.aggregateType,\n \"aggregate-id\": record.aggregateId,\n };\n if (record.traceId) headers[\"trace-id\"] = record.traceId;\n\n return {\n topic: record.topic,\n key: record.key ?? record.aggregateId,\n value,\n headers,\n recordId: record.id,\n messageId: record.messageId,\n };\n}\n","import { computeBackoff } from \"./backoff.js\";\nimport { buildPublishable } from \"./publishable.js\";\nimport { ConsoleLogger, JsonSerializer } from \"./serializer.js\";\nimport type {\n DlqConfig,\n Logger,\n OutboxRecord,\n OutboxStore,\n PublishableMessage,\n PublishErrorKind,\n Publisher,\n RelayHooks,\n RetryConfig,\n Serializer,\n Waker,\n} from \"./types.js\";\n\nexport interface RelayOptions {\n store: OutboxStore;\n publisher: Publisher;\n /** Messages claimed per poll iteration. Default 100. */\n batchSize?: number;\n /** Idle wait (ms) when a poll returns no work. Default 200. */\n pollIntervalMs?: number;\n retry?: Partial<RetryConfig>;\n dlq?: DlqConfig;\n serializer?: Serializer;\n logger?: Logger;\n hooks?: RelayHooks;\n /**\n * Optional low-latency wake source. When provided, the relay claims as soon as\n * the waker signals new work, instead of waiting out `pollIntervalMs`. Polling\n * stays on as a safety net, so set `pollIntervalMs` longer when using a waker.\n */\n waker?: Waker;\n}\n\nconst DEFAULT_RETRY: RetryConfig = {\n maxAttempts: 5,\n strategy: \"exponential\",\n baseMs: 200,\n maxMs: 30_000,\n jitter: true,\n};\n\n/**\n * The Relay drains the outbox store and publishes messages to the broker.\n *\n * It is safe to run multiple Relay instances concurrently against the same\n * store as long as the store's claimBatch uses a lock-free claim strategy\n * (e.g. SELECT ... FOR UPDATE SKIP LOCKED).\n */\nexport class Relay {\n private readonly store: OutboxStore;\n private readonly publisher: Publisher;\n private readonly batchSize: number;\n private readonly pollIntervalMs: number;\n private readonly retry: RetryConfig;\n private readonly dlq: DlqConfig;\n private readonly serializer: Serializer;\n private readonly log: Logger;\n private readonly hooks: RelayHooks;\n private readonly waker: Waker | null;\n\n private running = false;\n private stopping = false;\n private loopPromise: Promise<void> | null = null;\n\n // Interruptible idle wait: `signal()` wakes a pending wait (or marks one\n // pending if none is in flight, so a wake can't be lost between cycles).\n private wakePending = false;\n private wakeResolver: (() => void) | null = null;\n\n constructor(opts: RelayOptions) {\n this.store = opts.store;\n this.publisher = opts.publisher;\n this.batchSize = opts.batchSize ?? 100;\n this.pollIntervalMs = opts.pollIntervalMs ?? 200;\n this.retry = { ...DEFAULT_RETRY, ...opts.retry };\n this.dlq = opts.dlq ?? {};\n this.serializer = opts.serializer ?? new JsonSerializer();\n this.log = opts.logger ?? new ConsoleLogger();\n this.hooks = opts.hooks ?? {};\n this.waker = opts.waker ?? null;\n }\n\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n this.stopping = false;\n\n await this.store.init?.();\n await this.publisher.connect();\n await this.waker?.start(() => this.signal());\n this.log.info(\"relay started\", {\n batchSize: this.batchSize,\n pollIntervalMs: this.pollIntervalMs,\n waker: this.waker !== null,\n });\n\n this.loopPromise = this.loop();\n }\n\n /**\n * Stop accepting new work, finish the in-flight batch, then disconnect.\n */\n async stop(): Promise<void> {\n if (!this.running) return;\n this.stopping = true;\n this.signal(); // break any in-flight idle wait so shutdown is immediate\n this.log.info(\"relay stopping, draining in-flight batch\");\n await this.loopPromise;\n await this.waker?.stop();\n await this.publisher.disconnect();\n await this.store.close?.();\n this.running = false;\n this.log.info(\"relay stopped\");\n }\n\n private async loop(): Promise<void> {\n while (!this.stopping) {\n try {\n const processed = await this.tick();\n if (processed === 0 && !this.stopping) {\n await this.waitForWork(this.pollIntervalMs);\n }\n } catch (err) {\n const error = err instanceof Error ? err : new Error(String(err));\n this.log.error(\"relay loop error\", { error: error.message });\n this.hooks.onError?.(error);\n await this.waitForWork(this.pollIntervalMs);\n }\n }\n }\n\n /**\n * Run a single claim+publish cycle. Returns number of records processed.\n * Exposed for tests / manual single-shot draining.\n */\n async tick(): Promise<number> {\n const batch = await this.store.claimBatch(this.batchSize);\n if (batch.length === 0) return 0;\n\n this.hooks.onBatchClaimed?.(batch.length);\n this.log.debug(\"batch claimed\", { count: batch.length });\n\n const messages = await this.toPublishable(batch);\n const recordsById = new Map(batch.map((r) => [r.id, r]));\n\n const results = await this.publisher.publish(messages);\n\n const succeeded: string[] = [];\n for (const result of results) {\n const record = recordsById.get(result.recordId);\n if (!record) continue;\n\n if (result.ok) {\n succeeded.push(record.id);\n this.hooks.onPublished?.(result);\n } else {\n await this.handleFailure(\n record,\n result.error ?? new Error(\"unknown publish error\"),\n result.errorKind,\n );\n }\n }\n\n if (succeeded.length > 0) {\n await this.store.markDone(succeeded);\n }\n\n return batch.length;\n }\n\n private async handleFailure(\n record: OutboxRecord,\n error: Error,\n errorKind?: PublishErrorKind,\n ): Promise<void> {\n // Backpressure: client-side producer queue is full. Re-queue the record\n // with a brief delay; do NOT increment attempts (this isn't the record's\n // fault, the producer is just full). Stores without `requeue` fall back\n // to markFailed, accepting the attempt-counter hit.\n if (errorKind === \"backpressure\") {\n const delay = this.retry.backpressureDelayMs ?? 1000;\n const retryAt = new Date(Date.now() + delay);\n this.hooks.onFailed?.(record, error, true);\n this.log.warn(\"publish backpressure — requeueing without bumping attempts\", {\n recordId: record.id,\n retryAt: retryAt.toISOString(),\n errorKind,\n error: error.message,\n });\n if (this.store.requeue) {\n await this.store.requeue(record.id, retryAt);\n } else {\n // Fallback: markFailed (will increment attempts). Documented in the\n // OutboxStore.requeue JSDoc.\n await this.store.markFailed(record.id, retryAt, \"failed\");\n }\n return;\n }\n\n const attempts = record.attempts + 1;\n // fatal/poison short-circuit: retrying cannot help (auth denied, fenced\n // epoch, oversized record, schema rejected). Skip the backoff schedule\n // entirely and go straight to DLQ + dead.\n const isTerminalKind = errorKind === \"fatal\" || errorKind === \"poison\";\n const retryAt = isTerminalKind\n ? null\n : this.nextRetryAtForKind(attempts, errorKind);\n const willRetry = retryAt !== null;\n\n this.hooks.onFailed?.(record, error, willRetry);\n this.log.warn(\"publish failed\", {\n recordId: record.id,\n attempts,\n willRetry,\n errorKind: errorKind ?? \"retriable\",\n error: error.message,\n });\n\n if (willRetry) {\n await this.store.markFailed(record.id, retryAt, \"failed\");\n return;\n }\n\n // Terminal: route to DLQ if configured, then mark dead.\n if (this.dlq.topic && this.publisher.publishToDlq) {\n try {\n const msg = (await this.toPublishable([record]))[0];\n if (msg) {\n await this.publisher.publishToDlq(\n {\n ...msg,\n topic: this.dlq.topic,\n // Per-record enrichment so the DLQ consumer has everything\n // needed for triage: original destination, aggregate / message\n // identity, the attempts count, and (opt-in) a truncated stack.\n headers: this.buildDlqHeaders(record, error, msg.headers),\n },\n error,\n );\n }\n } catch (dlqErr) {\n const e = dlqErr instanceof Error ? dlqErr : new Error(String(dlqErr));\n this.log.error(\"DLQ publish failed\", {\n recordId: record.id,\n error: e.message,\n });\n }\n }\n\n await this.store.markFailed(record.id, null, \"dead\");\n this.hooks.onDead?.(record, error);\n }\n\n /**\n * Compute the next retry instant, applying the quota multiplier when the\n * driver classified the failure as a server throttle. Quota failures DO\n * count as attempts (unlike backpressure) — the multiplier only stretches\n * the delay, not the budget.\n */\n private nextRetryAtForKind(\n attempts: number,\n errorKind: PublishErrorKind | undefined,\n ): Date | null {\n if (attempts >= this.retry.maxAttempts) return null;\n const baseDelay = computeBackoff(this.retry, attempts);\n const multiplier =\n errorKind === \"quota\" ? this.retry.quotaMultiplier ?? 5 : 1;\n const delay = Math.min(baseDelay * multiplier, this.retry.maxMs * 10);\n return new Date(Date.now() + delay);\n }\n\n /**\n * Build the per-record DLQ header bag. Operators triaging the DLQ get the\n * original destination, the aggregate / message identity, attempts count,\n * and optionally a truncated error stack — everything you need to decide\n * whether to re-enqueue, escalate, or drop.\n */\n private buildDlqHeaders(\n record: OutboxRecord,\n error: Error,\n existing: Record<string, string>,\n ): Record<string, string> {\n const out: Record<string, string> = {\n ...existing,\n \"original-topic\": record.topic,\n \"dlq-attempts\": String(record.attempts + 1),\n \"dlq-original-aggregate-id\": record.aggregateId,\n \"dlq-original-message-id\": record.messageId,\n };\n if (this.dlq.includeStackTraces && error.stack) {\n const maxBytes = this.dlq.maxStackBytes ?? 4096;\n out[\"dlq-error-stack\"] = truncateUtf8(error.stack, maxBytes);\n }\n return out;\n }\n\n private async toPublishable(\n records: OutboxRecord[],\n ): Promise<PublishableMessage[]> {\n const out: PublishableMessage[] = [];\n for (const record of records) {\n out.push(await buildPublishable(record, this.serializer));\n }\n return out;\n }\n\n /** Wake a pending idle wait, or mark one pending so the next wait is skipped. */\n private signal(): void {\n this.wakePending = true;\n this.wakeResolver?.();\n }\n\n /**\n * Idle wait that resolves after `ms`, or early when `signal()` fires. A signal\n * raised while no wait is in flight is remembered (wakePending), so a wake that\n * races a claim cycle is never lost.\n */\n private waitForWork(ms: number): Promise<void> {\n if (this.wakePending) {\n this.wakePending = false;\n return Promise.resolve();\n }\n return new Promise((resolve) => {\n const timer = setTimeout(() => {\n this.wakeResolver = null;\n resolve();\n }, ms);\n this.wakeResolver = () => {\n clearTimeout(timer);\n this.wakeResolver = null;\n this.wakePending = false;\n resolve();\n };\n });\n }\n}\n\n/**\n * Truncate a string to at most `maxBytes` of its UTF-8 encoding, appending a\n * \"…[truncated]\" marker when bytes are removed. Used to keep the DLQ stack\n * header bounded — headers in Kafka are byte-sized, not character-sized, so\n * a naive `slice` on multibyte text can blow the limit. Always returns a\n * valid (no-broken-codepoint) UTF-8 string.\n */\nfunction truncateUtf8(input: string, maxBytes: number): string {\n const marker = \"…[truncated]\";\n const markerBytes = Buffer.byteLength(marker, \"utf8\");\n const buf = Buffer.from(input, \"utf8\");\n if (buf.byteLength <= maxBytes) return input;\n const budget = Math.max(0, maxBytes - markerBytes);\n // Slice and decode; if the slice lands mid-codepoint, drop trailing bytes\n // until the decode is clean.\n let end = budget;\n while (end > 0) {\n const slice = buf.subarray(0, end).toString(\"utf8\");\n if (!slice.endsWith(\"�\")) {\n return slice + marker;\n }\n end--;\n }\n return marker;\n}\n","import type { StandardSchemaV1 } from \"./standard-schema.js\";\n\n/**\n * Thrown when an outbox payload fails its topic's schema — at `enqueue`\n * (before the DB insert) or at `decode` (malformed bytes / schema mismatch).\n * Carries the topic and the validator's structured issues for diagnostics.\n */\nexport class OutboxValidationError extends Error {\n readonly topic: string;\n readonly issues: ReadonlyArray<StandardSchemaV1.Issue>;\n\n constructor(\n topic: string,\n issues: ReadonlyArray<StandardSchemaV1.Issue>,\n options?: { cause?: unknown },\n ) {\n const first = issues[0]?.message ?? \"unknown validation error\";\n super(`Outbox payload for \"${topic}\" failed validation: ${first}`, options);\n this.name = \"OutboxValidationError\";\n this.topic = topic;\n this.issues = issues;\n }\n}\n","import { OutboxValidationError } from \"./errors.js\";\nimport type { StandardSchemaV1 } from \"./standard-schema.js\";\nimport type { OutboxMessageInput } from \"./types.js\";\n\n/** One topic's contract: the aggregate it belongs to and its payload schema. */\nexport interface TopicDefinition {\n /** Aggregate type stamped on every event of this topic (e.g. \"order\"). */\n readonly aggregateType: string;\n /** Standard Schema for the payload (Zod 3.24+/Valibot/ArkType/…). */\n readonly schema: StandardSchemaV1;\n}\n\n/** A map of topic name -> its definition. The single source of truth. */\nexport type OutboxRegistry = Record<string, TopicDefinition>;\n\n/**\n * Minimal store surface the producer facade needs. `PostgresStore` satisfies\n * this structurally, so the facade stays DB-agnostic and `tx` flows from the\n * concrete store (e.g. a pg client).\n */\nexport interface EnqueueableStore<Tx = unknown> {\n enqueue(tx: Tx, msg: OutboxMessageInput & { traceId?: string }): Promise<string>;\n}\n\ntype TxOf<S> = S extends EnqueueableStore<infer Tx> ? Tx : never;\n\ntype PayloadInput<R extends OutboxRegistry, K extends keyof R> =\n StandardSchemaV1.InferInput<R[K][\"schema\"]>;\ntype PayloadOutput<R extends OutboxRegistry, K extends keyof R> =\n StandardSchemaV1.InferOutput<R[K][\"schema\"]>;\n\n/** The write-side input for a typed enqueue (payload is the schema's input type). */\nexport interface EnqueueInput<R extends OutboxRegistry, K extends keyof R> {\n aggregateId: string;\n payload: PayloadInput<R, K>;\n key?: string;\n headers?: Record<string, string>;\n messageId?: string;\n traceId?: string;\n}\n\n/** Consumer-side facade: validate/decode without a store. */\nexport interface OutboxConsumer<R extends OutboxRegistry> {\n /** Validate an already-parsed value against the topic's schema. */\n validate<K extends keyof R & string>(\n topic: K,\n value: unknown,\n ): Promise<PayloadOutput<R, K>>;\n /** JSON-parse `bytes`, validate against the topic's schema, return the payload. */\n decode<K extends keyof R & string>(\n topic: K,\n bytes: Buffer | Uint8Array | string,\n ): Promise<PayloadOutput<R, K>>;\n}\n\n/** Producer-side facade: adds typed, validated enqueue. */\nexport interface OutboxProducer<R extends OutboxRegistry, Tx>\n extends OutboxConsumer<R> {\n /** Validate `payload`, then enqueue inside the caller's transaction `tx`. */\n enqueue<K extends keyof R & string>(\n tx: Tx,\n topic: K,\n msg: EnqueueInput<R, K>,\n ): Promise<string>;\n}\n\n// Loose shape used inside the implementation; the public types come from overloads.\ninterface LooseEnqueueInput {\n aggregateId: string;\n payload: unknown;\n key?: string;\n headers?: Record<string, string>;\n messageId?: string;\n traceId?: string;\n}\n\nexport function defineOutbox<R extends OutboxRegistry>(\n registry: R,\n): OutboxConsumer<R>;\nexport function defineOutbox<R extends OutboxRegistry, S extends EnqueueableStore>(\n registry: R,\n opts: { store: S },\n): OutboxProducer<R, TxOf<S>>;\nexport function defineOutbox<R extends OutboxRegistry>(\n registry: R,\n opts?: { store: EnqueueableStore },\n): OutboxConsumer<R> | OutboxProducer<R, unknown> {\n const validate = async (topic: string, value: unknown): Promise<unknown> => {\n const def = registry[topic];\n if (!def) {\n throw new OutboxValidationError(topic, [\n { message: `unknown topic \"${topic}\"` },\n ]);\n }\n const result = await def.schema[\"~standard\"].validate(value);\n if (result.issues) throw new OutboxValidationError(topic, result.issues);\n return result.value;\n };\n\n const decode = async (\n topic: string,\n bytes: Buffer | Uint8Array | string,\n ): Promise<unknown> => {\n const text =\n typeof bytes === \"string\" ? bytes : Buffer.from(bytes).toString(\"utf8\");\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch (err) {\n throw new OutboxValidationError(\n topic,\n [{ message: `invalid JSON: ${(err as Error).message}` }],\n { cause: err },\n );\n }\n return validate(topic, parsed);\n };\n\n const consumer = { validate, decode } as unknown as OutboxConsumer<R>;\n if (!opts?.store) return consumer;\n\n const store = opts.store;\n const enqueue = async (\n tx: unknown,\n topic: string,\n msg: LooseEnqueueInput,\n ): Promise<string> => {\n const def = registry[topic];\n if (!def) {\n throw new OutboxValidationError(topic, [\n { message: `unknown topic \"${topic}\"` },\n ]);\n }\n const payload = await validate(topic, msg.payload);\n return store.enqueue(tx, {\n topic,\n aggregateType: def.aggregateType,\n aggregateId: msg.aggregateId,\n payload,\n key: msg.key,\n headers: msg.headers,\n messageId: msg.messageId,\n traceId: msg.traceId,\n });\n };\n\n return { validate, decode, enqueue } as unknown as OutboxProducer<R, unknown>;\n}\n"],"mappings":"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","names":["retryAt"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventferry/core",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "DB- and broker-agnostic core for the transactional outbox pattern",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -14,8 +14,12 @@
14
14
  }
15
15
  },
16
16
  "files": [
17
- "dist"
17
+ "dist",
18
+ "CHANGELOG.md"
18
19
  ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
19
23
  "keywords": [
20
24
  "outbox",
21
25
  "outbox-pattern",
@@ -51,7 +55,7 @@
51
55
  "devDependencies": {
52
56
  "tsup": "^8.3.5",
53
57
  "typescript": "^5.7.2",
54
- "vitest": "^2.1.8"
58
+ "vitest": "^3.2.6"
55
59
  },
56
60
  "scripts": {
57
61
  "build": "tsup",