@babelqueue/sqs 1.2.1 → 1.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/README.md CHANGED
@@ -71,20 +71,42 @@ is purely additive.
71
71
  **Version gate (§3.7).** Before the body is decoded the consumer reads the `bq-schema-version`
72
72
  message attribute. A missing or blank value (empty or only ASCII whitespace) changes nothing; any
73
73
  other value that is not exactly `"1"` (`"2"`, `"x"`, `"01"`, `" 1"` — never trimmed) rejects the
74
- message **undecoded**: it reaches `onError` (with an empty envelope) and is left undeleted, so it is
75
- redelivered and the `RedrivePolicy` below moves it to the DLQ.
74
+ message **undecoded**: it reaches `onError` (with an empty envelope) and is settled on its **first
75
+ delivery** (ADR-0037 decision 1) — never decoded, never given to a handler or `onUnknownUrn`, never retried
76
+ by the SDK. With a `deadLetterQueueUrl` the body is sent to it exactly as received (no `dead_letter` block)
77
+ and the message is deleted only after the send succeeded (a failed send leaves it undeleted, so visibility
78
+ expiry redelivers it and the write is tried again). Under `unknownUrn: "delete"` it is deleted. Without a
79
+ `deadLetterQueueUrl` it is left to the **native** mechanism: not deleted, so visibility expiry redelivers it
80
+ and the `RedrivePolicy` below moves it to the DLQ. Only the explicitly configured `unknownUrn` strategy is
81
+ read — an `onUnknownUrn` hook (and the `delete` it implies when no strategy is set) never applies to a
82
+ version-gated delivery.
83
+
84
+ **Poison (ADR-0037 decision 2, ADR-0035).** A body that does not decode into a valid envelope is never given
85
+ to a handler and never retried. With a `deadLetterQueueUrl` the **ADR-0035 record** (`reason: "poison"`, built
86
+ with the core's `buildPoisonRecord`: an object body spliced byte for byte, anything else wrapped in `raw`) is
87
+ sent to the DLQ and the message deleted only after the send; `unknownUrn: "delete"` deletes it; without a DLQ
88
+ URL it is left to the native `RedrivePolicy`, as above. Like the version gate, the poison route reads only the explicitly configured `unknownUrn` strategy: an `onUnknownUrn` hook (and the `delete` it implies when no strategy is set) never drops a poison body. A `.fifo` DLQ gets the source queue as
89
+ `MessageGroupId` and the source `MessageId` as `MessageDeduplicationId`.
76
90
 
77
91
  > **Poison messages — configure a `RedrivePolicy`.** With the `0 s` default, a handler
78
92
  > that always fails is retried back-to-back until the queue's native `RedrivePolicy`
79
93
  > (`maxReceiveCount`) moves the message to its dead-letter queue. Without one, such a
80
94
  > message loops forever. Give every consumed queue a `RedrivePolicy` (e.g.
81
- > `maxReceiveCount` 3–5 → `<queue>.dlq`), and/or pass a backoff:
95
+ > `maxReceiveCount` 3–5 → `<queue>-dlq`), and/or pass a backoff:
82
96
  >
83
97
  > ```ts
84
98
  > new SqsConsumer(sqs, url, handlers, {
85
- > releaseDelay: (attempts) => Math.min(2 ** attempts * 5, 900),
99
+ > retryBackoff: 5, // seconds
100
+ > retryBackoffMultiplier: 2,
101
+ > retryBackoffMax: 900,
102
+ > retryJitter: 'full', // optional: uniform(0, d(n))
86
103
  > });
87
104
  > ```
105
+ >
106
+ > The shared A9 options compute `d(n) = min(max, base * multiplier^(n-1))` with
107
+ > `n = ApproximateReceiveCount`. `releaseDelay` (a number or a function of `attempts`) is the
108
+ > SQS-specific alias and wins when both are set. Either way the visibility timeout is clamped to
109
+ > `0…43200`. Needs `@babelqueue/core` ≥ 1.13.0.
88
110
 
89
111
  An unmapped URN follows `unknownUrn`: `fail` (report, leave), `delete`, `release`
90
112
  (`ChangeMessageVisibility` after `unknownUrnReleaseDelay`, default `0`) or
package/dist/index.cjs CHANGED
@@ -29,6 +29,7 @@ __export(index_exports, {
29
29
  toMessageAttributes: () => toMessageAttributes
30
30
  });
31
31
  module.exports = __toCommonJS(index_exports);
32
+ var import_node_crypto = require("crypto");
32
33
  var import_core = require("@babelqueue/core");
33
34
 
34
35
  // src/headers.ts
@@ -137,6 +138,9 @@ var SqsDeleteError = class extends import_core.BabelQueueError {
137
138
  this.cause = cause;
138
139
  }
139
140
  };
141
+ function hasRetryBackoff(options) {
142
+ return options.retryBackoff !== void 0 || options.retryBackoffMultiplier !== void 0 || options.retryBackoffMax !== void 0 || options.retryJitter !== void 0;
143
+ }
140
144
  var SqsConsumer = class {
141
145
  constructor(client, queueUrl, handlers, options = {}) {
142
146
  this.client = client;
@@ -147,7 +151,11 @@ var SqsConsumer = class {
147
151
  if (strategy !== void 0 && !Object.values(import_core.UnknownUrnStrategy).includes(strategy)) {
148
152
  throw new import_core.BabelQueueError(`Unknown unknownUrn strategy "${strategy}".`);
149
153
  }
150
- const needsRelease = options.releaseDelay != null || strategy === import_core.UnknownUrnStrategy.RELEASE;
154
+ (0, import_core.retryBackoffBound)(options, 1);
155
+ if (options.retryJitter !== void 0 && options.retryJitter !== "none" && options.retryJitter !== "full") {
156
+ throw new import_core.BabelQueueError(`retryJitter must be "none" or "full", got ${String(options.retryJitter)}.`);
157
+ }
158
+ const needsRelease = options.releaseDelay != null || hasRetryBackoff(options) || strategy === import_core.UnknownUrnStrategy.RELEASE;
151
159
  if (needsRelease && typeof client.changeMessageVisibility !== "function") {
152
160
  throw new import_core.BabelQueueError(
153
161
  "SqsConsumer release requires a client with changeMessageVisibility (contract \xA73.5)."
@@ -186,12 +194,11 @@ var SqsConsumer = class {
186
194
  async handle(message) {
187
195
  const rejectedVersion = unsupportedSchemaVersion(message);
188
196
  if (rejectedVersion !== void 0) {
189
- this.options.onError?.(
197
+ await this.settleVersionGated(
198
+ message,
190
199
  new import_core.BabelQueueError(
191
200
  `Rejected a message from SQS with unsupported bq-schema-version "${rejectedVersion}" (supported: ${import_core.SCHEMA_VERSION}).`
192
- ),
193
- {},
194
- message
201
+ )
195
202
  );
196
203
  return;
197
204
  }
@@ -205,11 +212,7 @@ var SqsConsumer = class {
205
212
  }
206
213
  }
207
214
  if (!import_core.EnvelopeCodec.accepts(envelope)) {
208
- this.options.onError?.(
209
- new import_core.BabelQueueError("Rejected a non-conformant BabelQueue envelope from SQS."),
210
- envelope,
211
- message
212
- );
215
+ await this.settlePoison(message, envelope, new import_core.BabelQueueError("Rejected a non-conformant BabelQueue envelope from SQS."));
213
216
  return;
214
217
  }
215
218
  const urn = import_core.EnvelopeCodec.urn(envelope);
@@ -223,14 +226,73 @@ var SqsConsumer = class {
223
226
  } catch (error) {
224
227
  this.options.onError?.(error, envelope, message);
225
228
  await this.guard(envelope, message, async () => {
226
- const delay = this.options.releaseDelay ?? 0;
227
229
  const attempts = typeof envelope.attempts === "number" ? envelope.attempts : 0;
228
- await this.release(message, typeof delay === "function" ? delay(attempts) : delay);
230
+ await this.release(message, this.failureDelay(attempts));
229
231
  });
230
232
  return;
231
233
  }
232
234
  await this.guard(envelope, message, () => this.deleteHandled(message));
233
235
  }
236
+ /**
237
+ * Version-gated delivery (ADR-0037 decision 1). `unknownUrn: "delete"` deletes it; with a DLQ the raw body goes
238
+ * to `<queue>-dlq` and the message is deleted only after the send; otherwise it is reported and left for the
239
+ * queue's native mechanism (never deleted, never released by the SDK). Only the configured strategy is read.
240
+ */
241
+ async settleVersionGated(message, gateError) {
242
+ this.options.onError?.(gateError, {}, message);
243
+ const dlq = this.options.deadLetterQueueUrl;
244
+ if (this.options.unknownUrn === import_core.UnknownUrnStrategy.DELETE) {
245
+ await this.guard({}, message, () => this.deleteHandled(message));
246
+ return;
247
+ }
248
+ if (!dlq) return;
249
+ await this.guard({}, message, async () => {
250
+ await this.sendRaw(dlq, message, message.Body ?? "");
251
+ await this.deleteHandled(message);
252
+ });
253
+ }
254
+ /**
255
+ * Poison body (ADR-0037 decision 2). `unknownUrn: "delete"` deletes it; with a DLQ the ADR-0035 record is sent
256
+ * and the message deleted only after the send; otherwise it is reported and left for the native mechanism.
257
+ */
258
+ async settlePoison(message, envelope, error) {
259
+ this.options.onError?.(error, envelope, message);
260
+ const dlq = this.options.deadLetterQueueUrl;
261
+ if (this.options.unknownUrn === import_core.UnknownUrnStrategy.DELETE) {
262
+ await this.guard(envelope, message, () => this.deleteHandled(message));
263
+ return;
264
+ }
265
+ if (!dlq) return;
266
+ await this.guard(envelope, message, async () => {
267
+ const source = queueNameFromUrl(this.queueUrl);
268
+ const count = Number.parseInt(message.Attributes?.["ApproximateReceiveCount"] ?? "", 10);
269
+ const delivered = Number.isFinite(count) && count > 1 ? count - 1 : 0;
270
+ const deadLetter = (0, import_core.annotate)({ attempts: delivered }, "poison", source, {
271
+ attempts: delivered,
272
+ error: error.message,
273
+ exception: error.name
274
+ }).dead_letter;
275
+ const record = (0, import_core.buildPoisonRecord)(Buffer.from(message.Body ?? "", "utf8"), JSON.stringify(deadLetter));
276
+ await this.sendRaw(dlq, message, record);
277
+ await this.deleteHandled(message);
278
+ });
279
+ }
280
+ /** Send `body` to the DLQ as is (a `.fifo` DLQ gets the source queue as group and a stable dedup id). */
281
+ async sendRaw(dlq, message, body) {
282
+ const input = { QueueUrl: dlq, MessageBody: body };
283
+ if (dlq.endsWith(".fifo")) {
284
+ input.MessageGroupId = queueNameFromUrl(this.queueUrl);
285
+ input.MessageDeduplicationId = message.MessageId ?? (0, import_node_crypto.createHash)("sha256").update(message.Body ?? "").digest("hex");
286
+ }
287
+ await this.client.sendMessage(input);
288
+ }
289
+ /** The release delay (seconds) for a failed handler: `releaseDelay` (alias) wins, else the A9 backoff, else 0. */
290
+ failureDelay(attempts) {
291
+ const delay = this.options.releaseDelay;
292
+ if (delay != null) return typeof delay === "function" ? delay(attempts) : delay;
293
+ if (!hasRetryBackoff(this.options)) return 0;
294
+ return (0, import_core.retryBackoffDelay)(this.options, Math.max(0, Math.floor(attempts)) + 1);
295
+ }
234
296
  /**
235
297
  * Run a release / dead-letter step; a failure (e.g. `MessageNotInflight`, a missing IAM grant, a
236
298
  * throwing `releaseDelay`) is reported to `onError` and the message is left undeleted, so
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/headers.ts"],"sourcesContent":["/**\n * Amazon SQS adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed **consumer** over Amazon SQS,\n * so an SQS-based Node service speaks the same contract (envelope shape, URN\n * identity, trace propagation) as the PHP/Laravel, Python, Go, Java and .NET SDKs.\n *\n * import { SQS } from \"@aws-sdk/client-sqs\";\n * import { SqsPublisher, SqsConsumer } from \"@babelqueue/sqs\";\n *\n * const sqs = new SQS({ region: \"eu-central-1\" });\n * const url = \"https://sqs.eu-central-1.amazonaws.com/123456789012/orders\";\n *\n * await new SqsPublisher(sqs, url).publish(\"urn:babel:orders:created\", { order_id: 1042 });\n *\n * const consumer = new SqsConsumer(sqs, url, {\n * \"urn:babel:orders:created\": async (env) => { ... },\n * });\n * await consumer.run();\n *\n * This implements §3 of the broker-bindings contract: the canonical envelope is the\n * message body, projected onto native SQS `MessageAttributes`. The envelope is\n * unchanged (`schema_version` stays 1); SQS is purely additive. Retry is SQS-native\n * (a failed handler's message is released via `ChangeMessageVisibility` after\n * `releaseDelay`, default 0 s — pair the queue with a `RedrivePolicy`); the\n * authoritative attempt count is `ApproximateReceiveCount`, surfaced to handlers as\n * `attempts = count − 1`.\n *\n * **Version gate (§3.7).** Before the body is decoded the consumer reads the `bq-schema-version`\n * message attribute. A missing or blank value (empty or only ASCII whitespace) changes nothing; any\n * value other than the core's supported `SCHEMA_VERSION` (`\"2\"`, `\"x\"`, `\"01\"`, ...) rejects the\n * message undecoded through the same path as a non-conformant envelope — `onError` (with an error\n * naming the version, and an empty envelope since nothing was decoded) and the message is left\n * undeleted, so it is redelivered and the queue's `RedrivePolicy` moves it to the DLQ.\n *\n * **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop\n * span linkage) rides as additional String `MessageAttributes` **beside** the contract `bq-*`\n * attributes where `bq-trace-id` already lives — the contract attributes win a key collision, and\n * the merged set is bounded by SQS's **10-attribute limit** (contract attributes are seeded first,\n * so a rider only lands while headroom remains). On consume the inbound `MessageAttributes` are\n * surfaced as a `Record<string,string>` so the core's `otel` extract sees the `traceparent` and\n * links the consumer span as a child. A header-less publish is byte-identical. This mirrors the Go\n * `/sqs` and PHP `SqsTransport` wiring. GR-1: the wire envelope body is never touched.\n */\n\nimport {\n BabelQueueError,\n EnvelopeCodec,\n SCHEMA_VERSION,\n UnknownUrnError,\n UnknownUrnStrategy,\n annotate,\n} from \"@babelqueue/core\";\nimport type { Envelope, HeaderCarrier, IncomingEnvelope } from \"@babelqueue/core\";\n\nimport { sanitizeHeaders } from \"./headers.js\";\n\n/** SQS allows at most 10 user message attributes per message. */\nconst MAX_ATTRIBUTES = 10;\n\n/** The SQS ceiling for a `VisibilityTimeout` (12 hours, in seconds). */\nexport const MAX_VISIBILITY_TIMEOUT = 43_200;\n\n// --- Minimal SQS shapes (a structural subset of @aws-sdk/client-sqs) -----------\n\nexport interface SqsMessageAttributeValue {\n DataType: string;\n StringValue?: string;\n}\n\nexport interface SqsMessage {\n Body?: string;\n ReceiptHandle?: string;\n MessageAttributes?: Record<string, SqsMessageAttributeValue>;\n Attributes?: Record<string, string>;\n}\n\nexport interface SendMessageInput {\n QueueUrl: string;\n MessageBody: string;\n MessageAttributes?: Record<string, SqsMessageAttributeValue>;\n MessageGroupId?: string;\n MessageDeduplicationId?: string;\n}\n\nexport interface ReceiveMessageInput {\n QueueUrl: string;\n MaxNumberOfMessages?: number;\n WaitTimeSeconds?: number;\n VisibilityTimeout?: number;\n MessageAttributeNames?: string[];\n AttributeNames?: string[];\n}\n\n/**\n * The subset of the AWS SQS client this adapter calls. The aggregated `SQS` class\n * from `@aws-sdk/client-sqs` satisfies it structurally; a fake satisfies it in tests.\n */\nexport interface SqsApi {\n sendMessage(input: SendMessageInput): Promise<{ MessageId?: string } | unknown>;\n receiveMessage(input: ReceiveMessageInput): Promise<{ Messages?: SqsMessage[] }>;\n deleteMessage(input: { QueueUrl: string; ReceiptHandle: string }): Promise<unknown>;\n /**\n * The §3.5 release primitive. Optional so existing structural clients keep compiling; it is\n * required (checked at construction) when a consumer sets `releaseDelay` or the `release`\n * unknown-URN strategy explicitly. Without it the default handler-failure release degrades to\n * leaving the message for visibility-timeout redelivery. The aggregated `SQS` class from\n * `@aws-sdk/client-sqs` provides it.\n */\n changeMessageVisibility?(input: ChangeMessageVisibilityInput): Promise<unknown>;\n}\n\nexport interface ChangeMessageVisibilityInput {\n QueueUrl: string;\n ReceiptHandle: string;\n VisibilityTimeout: number;\n}\n\n// --- Attribute projection (contract §3.2) --------------------------------------\n\nconst str = (value: unknown): SqsMessageAttributeValue => ({\n DataType: \"String\",\n StringValue: String(value),\n});\nconst num = (value: unknown): SqsMessageAttributeValue => ({\n DataType: \"Number\",\n StringValue: String(value),\n});\n\n/**\n * Project the envelope's contract fields onto native SQS `MessageAttributes` — a\n * redundant, routable view of the body (the body stays authoritative).\n */\nexport function toMessageAttributes(envelope: Envelope): Record<string, SqsMessageAttributeValue> {\n const attrs: Record<string, SqsMessageAttributeValue> = {};\n if (envelope.job) attrs[\"bq-job\"] = str(envelope.job);\n if (envelope.trace_id) attrs[\"bq-trace-id\"] = str(envelope.trace_id);\n if (envelope.meta.id) attrs[\"bq-message-id\"] = str(envelope.meta.id);\n if (envelope.meta.schema_version != null) {\n attrs[\"bq-schema-version\"] = num(envelope.meta.schema_version);\n }\n if (envelope.meta.lang) attrs[\"bq-source-lang\"] = str(envelope.meta.lang);\n if (envelope.meta.created_at != null) {\n attrs[\"bq-created-at\"] = num(envelope.meta.created_at);\n }\n return attrs;\n}\n\n/**\n * Overlay the out-of-band `headers` onto the contract attribute projection as String\n * `MessageAttributes`, without overwriting an existing `bq-*` attribute (the contract wins a key\n * collision) and skipping blanks. Keys are merged in sorted order and the merge stops at the\n * 10-attribute SQS ceiling, so unbounded riders can never push the message past the limit (SQS\n * rejects the whole send otherwise) — the contract attributes are always preserved first. Mirrors\n * Go's `mergeAttributes` / PHP's `SqsTransport::attributes`.\n */\nexport function mergeAttributes(\n base: Record<string, SqsMessageAttributeValue>,\n headers: HeaderCarrier | null | undefined,\n): Record<string, SqsMessageAttributeValue> {\n const clean = sanitizeHeaders(headers);\n for (const key of Object.keys(clean).sort()) {\n if (key in base) continue; // never clobber a contract bq-* attribute\n if (Object.keys(base).length >= MAX_ATTRIBUTES) break; // respect the SQS 10-attribute cap\n base[key] = str(clean[key]);\n }\n return base;\n}\n\n/**\n * Map inbound SQS `MessageAttributes` onto a flat {@link HeaderCarrier} (the consume-side\n * counterpart of {@link mergeAttributes}), reading each attribute's `StringValue`. Returns an empty\n * object when there are none. Both the contract `bq-*` attributes and any out-of-band rider (e.g.\n * `traceparent`) surface — the core's `otel` extract reads only the keys it knows.\n */\nexport function headersOf(message: SqsMessage): HeaderCarrier {\n const attrs = message.MessageAttributes;\n const out: HeaderCarrier = {};\n if (!attrs) return out;\n for (const key of Object.keys(attrs)) {\n const value = attrs[key]?.StringValue;\n if (value != null && value !== \"\") out[key] = value;\n }\n return out;\n}\n\n/** Blank = empty or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) — not `String.prototype.trim()`'s wider set. */\nconst BLANK_ASCII = /^[ \\t\\n\\v\\f\\r]*$/;\n\n/**\n * The `bq-schema-version` message attribute when it is present, non-blank and not the supported\n * version (compared against the core's `SCHEMA_VERSION`, exact string match: `\"01\"`, `\" 1\"`, `\"1.0\"`\n * are rejected). Blank means the empty string or only ASCII whitespace (the same set in every SDK,\n * GR-5); NBSP, U+0085, U+FEFF, U+3000 and the like are not blank. `undefined` when the message may\n * be decoded.\n */\nfunction unsupportedSchemaVersion(message: SqsMessage): string | undefined {\n const raw: unknown = message.MessageAttributes?.[\"bq-schema-version\"]?.StringValue;\n if (typeof raw !== \"string\" || BLANK_ASCII.test(raw)) return undefined;\n return raw === String(SCHEMA_VERSION) ? undefined : raw;\n}\n\nfunction queueNameFromUrl(queueUrl: string): string {\n const segments = queueUrl.split(\"/\").filter(Boolean);\n return segments[segments.length - 1] ?? \"default\";\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link SqsPublisher}. */\nexport interface SqsPublisherOptions {\n /** Treat the queue as FIFO: set `MessageGroupId` and (unless content dedup) `MessageDeduplicationId`. */\n fifo?: boolean;\n /** FIFO ordering group (default: the queue name from the URL). */\n messageGroupId?: string;\n /** Use the queue's content-based dedup instead of `meta.id` as the dedup id. */\n contentDedup?: boolean;\n}\n\n/** Options for {@link SqsPublisher.publish}. */\nexport interface PublishOptions {\n /** Reuse an existing trace id (trace continuation). */\n traceId?: string;\n /**\n * Out-of-band transport headers carried as String `MessageAttributes` beside the contract `bq-*`\n * attributes (ADR-0028) — e.g. a W3C `traceparent` written by `@babelqueue/core/otel`'s `publish`.\n * The contract attributes win a key collision and the merged set is capped at SQS's 10-attribute\n * limit; an empty/omitted carrier leaves the publish unchanged.\n */\n headers?: HeaderCarrier;\n}\n\n/** Sends canonical-envelope messages to one SQS queue with the §3 attribute projection. */\nexport class SqsPublisher {\n constructor(\n private readonly client: SqsApi,\n private readonly queueUrl: string,\n private readonly options: SqsPublisherOptions = {},\n ) {}\n\n /**\n * Build the canonical envelope for `(urn, data)`, send it as the message body with\n * the projected `MessageAttributes`, and return the message id (`meta.id`).\n */\n async publish(\n urn: string,\n data: Record<string, unknown>,\n options: PublishOptions = {},\n ): Promise<string> {\n const envelope = EnvelopeCodec.make(urn, data, {\n queue: queueNameFromUrl(this.queueUrl),\n traceId: options.traceId,\n });\n const input: SendMessageInput = {\n QueueUrl: this.queueUrl,\n MessageBody: EnvelopeCodec.encode(envelope),\n MessageAttributes: mergeAttributes(toMessageAttributes(envelope), options.headers),\n };\n if (this.options.fifo) {\n input.MessageGroupId = this.options.messageGroupId ?? queueNameFromUrl(this.queueUrl);\n if (!this.options.contentDedup) {\n input.MessageDeduplicationId = envelope.meta.id;\n }\n }\n await this.client.sendMessage(input);\n return envelope.meta.id;\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/**\n * A URN handler. Receives the validated envelope, the raw SQS message, and the out-of-band\n * {@link HeaderCarrier} read from the message's `MessageAttributes` (empty when there are none).\n * Pass `headers` to `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of\n * the producer span (ADR-0028).\n */\nexport type BabelHandler = (envelope: Envelope, message: SqsMessage, headers: HeaderCarrier) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link SqsConsumer}. */\nexport interface SqsConsumerOptions {\n /**\n * Called when a message's URN has no handler. Without `unknownUrn` the message is then deleted\n * (the original behaviour); with `unknownUrn` set it is a notification hook and the strategy\n * decides the message's fate.\n */\n onUnknownUrn?: (envelope: IncomingEnvelope, message: SqsMessage) => unknown | Promise<unknown>;\n /**\n * Unknown-URN strategy per contract §3.5 — `fail` | `delete` | `release` | `dead_letter`\n * ({@link UnknownUrnStrategy}). Omitted keeps the original behaviour (`onUnknownUrn` → delete,\n * otherwise report and leave for visibility-timeout redelivery, i.e. `fail`). `release` uses\n * `ChangeMessageVisibility` with {@link unknownUrnReleaseDelay}; `dead_letter` sends the annotated\n * envelope to {@link deadLetterQueueUrl} then deletes, degrading to `delete` when no DLQ is set.\n */\n unknownUrn?: string;\n /** Backoff (seconds) for the `release` unknown-URN strategy (default 0 = redeliver now). */\n unknownUrnReleaseDelay?: number;\n /**\n * Backoff before a failed message is redelivered. A failed handler always releases the message\n * via `ChangeMessageVisibility(ReceiptHandle, VisibilityTimeout)` (contract §3.5); this sets the\n * `VisibilityTimeout` — a number of seconds, or a function of the (reconciled) `attempts`\n * returning seconds (e.g. an exponential backoff). Clamped to `0…43200`. Default `0`: the message\n * is visible again immediately. A permanently failing handler therefore retries without pause\n * until the queue's `RedrivePolicy` (`maxReceiveCount`) moves it to the DLQ — configure one.\n */\n releaseDelay?: number | ((attempts: number) => number);\n /**\n * The cross-language `<queue>.dlq` URL for the `dead_letter` strategy (opt-in; default none). A\n * `.fifo` DLQ is sent with `MessageGroupId` (the source queue name) and `MessageDeduplicationId`\n * (`meta.id`).\n */\n deadLetterQueueUrl?: string | null;\n /**\n * Called for a non-conformant envelope, an unmapped URN (no `onUnknownUrn`), a throwing handler,\n * a throwing `onUnknownUrn` under an `unknownUrn` strategy, a failed release / dead-letter send, or\n * a failed delete after successful processing (an {@link SqsDeleteError} wrapping the broker\n * error; never released). The message is then left for visibility-timeout redelivery. The loop\n * never stops on these.\n */\n onError?: (error: unknown, envelope: IncomingEnvelope, message: SqsMessage) => void;\n /** Long-poll wait seconds (default 20). */\n waitTimeSeconds?: number;\n /** Reservation window applied on receive (seconds). */\n visibilityTimeout?: number;\n /** Max messages per receive (default 10). */\n maxMessages?: number;\n}\n\n/**\n * Reported via `onError` when `DeleteMessage` fails for a message whose processing already succeeded\n * (a handler returned normally, or the unknown-URN strategy chose `delete` / `dead_letter`). The\n * broker error is `cause`. Distinct from a handler failure: the message is NOT released, so it is\n * redelivered only after its visibility timeout expires (at-least-once; dedupe on `meta.id` with the\n * idempotency helper if the side effect must not repeat).\n */\nexport class SqsDeleteError extends BabelQueueError {\n constructor(cause: unknown) {\n super(\"Failed to delete a processed SQS message; it will be redelivered after its visibility timeout.\");\n this.name = \"SqsDeleteError\";\n this.cause = cause;\n }\n}\n\n/**\n * Polls an SQS queue, decodes + validates each message, routes it to the handler\n * registered for its URN, and deletes it on success. A throwing handler's message is released via\n * `ChangeMessageVisibility` after `releaseDelay` (default 0 s, contract §3.5); if the release itself\n * fails the message is left for visibility-timeout redelivery (at-least-once);\n * `attempts` is reconciled to `ApproximateReceiveCount − 1` for the handler. An unmapped URN\n * follows the `unknownUrn` strategy (`fail` | `delete` | `release` | `dead_letter`).\n */\nexport class SqsConsumer {\n constructor(\n private readonly client: SqsApi,\n private readonly queueUrl: string,\n private readonly handlers: BabelHandlers,\n private readonly options: SqsConsumerOptions = {},\n ) {\n const strategy = options.unknownUrn;\n if (strategy !== undefined && !(Object.values(UnknownUrnStrategy) as string[]).includes(strategy)) {\n throw new BabelQueueError(`Unknown unknownUrn strategy \"${strategy}\".`);\n }\n const needsRelease = options.releaseDelay != null || strategy === UnknownUrnStrategy.RELEASE;\n if (needsRelease && typeof client.changeMessageVisibility !== \"function\") {\n throw new BabelQueueError(\n \"SqsConsumer release requires a client with changeMessageVisibility (contract §3.5).\",\n );\n }\n }\n\n /** Receive one batch, route each message, delete the ones handled. Returns the batch size. */\n async poll(): Promise<number> {\n const input: ReceiveMessageInput = {\n QueueUrl: this.queueUrl,\n MaxNumberOfMessages: this.options.maxMessages ?? 10,\n WaitTimeSeconds: this.options.waitTimeSeconds ?? 20,\n MessageAttributeNames: [\"All\"],\n AttributeNames: [\"ApproximateReceiveCount\"],\n };\n if (this.options.visibilityTimeout != null) {\n input.VisibilityTimeout = this.options.visibilityTimeout;\n }\n const result = await this.client.receiveMessage(input);\n const messages = result.Messages ?? [];\n for (const message of messages) {\n await this.handle(message);\n }\n return messages.length;\n }\n\n /** Poll until `signal` aborts (each poll long-polls, so this does not busy-loop). */\n async run(signal?: AbortSignal): Promise<void> {\n while (signal?.aborted !== true) {\n await this.poll();\n }\n }\n\n private async handle(message: SqsMessage): Promise<void> {\n // §3.7: gate on the bq-schema-version attribute before the body is decoded. The message is\n // not deleted, so SQS redelivers it and the queue's RedrivePolicy moves it to the DLQ.\n const rejectedVersion = unsupportedSchemaVersion(message);\n if (rejectedVersion !== undefined) {\n this.options.onError?.(\n new BabelQueueError(\n `Rejected a message from SQS with unsupported bq-schema-version \"${rejectedVersion}\" (supported: ${SCHEMA_VERSION}).`,\n ),\n {},\n message,\n );\n return;\n }\n\n const envelope = EnvelopeCodec.decode(message.Body ?? \"\");\n\n const receiveCount = message.Attributes?.[\"ApproximateReceiveCount\"];\n if (receiveCount !== undefined) {\n const native = Number.parseInt(receiveCount, 10) - 1;\n const current = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n if (Number.isFinite(native) && native > current) {\n envelope.attempts = native;\n }\n }\n\n if (!EnvelopeCodec.accepts(envelope)) {\n this.options.onError?.(\n new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from SQS.\"),\n envelope,\n message,\n );\n return;\n }\n\n const urn = EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n await this.unknown(urn, envelope as Envelope, message);\n return;\n }\n\n try {\n await handler(envelope, message, headersOf(message));\n } catch (error) {\n this.options.onError?.(error, envelope, message);\n await this.guard(envelope, message, async () => {\n const delay = this.options.releaseDelay ?? 0;\n const attempts = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n await this.release(message, typeof delay === \"function\" ? delay(attempts) : delay);\n });\n return;\n }\n // Outside the handler's try: a failed delete is not a handler failure. It is reported as an\n // SqsDeleteError and the message is NOT released — releasing at the (default 0 s) backoff would\n // redeliver an already-processed message immediately; it returns on visibility expiry instead.\n await this.guard(envelope, message, () => this.deleteHandled(message));\n }\n\n /**\n * Run a release / dead-letter step; a failure (e.g. `MessageNotInflight`, a missing IAM grant, a\n * throwing `releaseDelay`) is reported to `onError` and the message is left undeleted, so\n * visibility expiry still redelivers it. Never lets the error stop the consume loop.\n */\n private async guard(envelope: IncomingEnvelope, message: SqsMessage, step: () => Promise<void>): Promise<void> {\n try {\n await step();\n } catch (error) {\n this.options.onError?.(error, envelope, message);\n }\n }\n\n /** Apply the unknown-URN strategy (contract §3.5). */\n private async unknown(urn: string, envelope: Envelope, message: SqsMessage): Promise<void> {\n const strategy = this.options.unknownUrn;\n if (strategy === undefined) {\n if (this.options.onUnknownUrn) {\n await this.options.onUnknownUrn(envelope, message);\n await this.delete(message);\n } else {\n this.options.onError?.(new UnknownUrnError(urn), envelope, message);\n }\n return;\n }\n\n await this.guard(envelope, message, async () => {\n await this.options.onUnknownUrn?.(envelope, message);\n });\n switch (strategy) {\n case UnknownUrnStrategy.DELETE:\n await this.guard(envelope, message, () => this.deleteHandled(message));\n return;\n case UnknownUrnStrategy.RELEASE:\n await this.guard(envelope, message, () => this.release(message, this.options.unknownUrnReleaseDelay ?? 0));\n return;\n case UnknownUrnStrategy.DEAD_LETTER:\n await this.guard(envelope, message, async () => {\n await this.deadLetter(envelope, \"unknown_urn\");\n await this.deleteHandled(message);\n });\n return;\n default:\n // FAIL: surface and do NOT delete — visibility expiry redelivers, native redrive quarantines.\n this.options.onError?.(new UnknownUrnError(urn), envelope, message);\n }\n }\n\n /** Make the message visible again after `seconds` via `ChangeMessageVisibility`; never deletes. */\n private async release(message: SqsMessage, seconds: number): Promise<void> {\n if (!message.ReceiptHandle || !this.client.changeMessageVisibility) return;\n const timeout = Number.isFinite(seconds)\n ? Math.min(MAX_VISIBILITY_TIMEOUT, Math.max(0, Math.floor(seconds)))\n : 0;\n await this.client.changeMessageVisibility({\n QueueUrl: this.queueUrl,\n ReceiptHandle: message.ReceiptHandle,\n VisibilityTimeout: timeout,\n });\n }\n\n /** Send the annotated envelope to the opt-in DLQ; a no-op (→ plain delete) when none is set. */\n private async deadLetter(envelope: Envelope, reason: string): Promise<void> {\n const dlq = this.options.deadLetterQueueUrl;\n if (!dlq) return;\n const source = envelope.meta?.queue ?? queueNameFromUrl(this.queueUrl);\n const annotated = annotate(envelope, reason, source, {\n attempts: envelope.attempts ?? 0,\n });\n const input: SendMessageInput = {\n QueueUrl: dlq,\n MessageBody: EnvelopeCodec.encode(annotated),\n MessageAttributes: toMessageAttributes(annotated),\n };\n if (dlq.endsWith(\".fifo\")) {\n // A FIFO DLQ (`<queue>.dlq.fifo`) rejects a send without a group; dedup on meta.id (§3.2).\n input.MessageGroupId = source;\n if (annotated.meta?.id) input.MessageDeduplicationId = annotated.meta.id;\n }\n await this.client.sendMessage(input);\n }\n\n /** Delete a message whose processing already succeeded; a broker failure becomes an SqsDeleteError. */\n private async deleteHandled(message: SqsMessage): Promise<void> {\n try {\n await this.delete(message);\n } catch (error) {\n throw new SqsDeleteError(error);\n }\n }\n\n private async delete(message: SqsMessage): Promise<void> {\n if (!message.ReceiptHandle) return;\n await this.client.deleteMessage({\n QueueUrl: this.queueUrl,\n ReceiptHandle: message.ReceiptHandle,\n });\n }\n}\n","/**\n * Pure, dependency-free helpers for the out-of-band transport-header seam (ADR-0028) — the Node\n * adapter mirror of Go's `sanitizeHeaders` and PHP's `Support\\Headers`. They fold an injected\n * {@link HeaderCarrier} (e.g. a W3C `traceparent`) onto a transport's metadata channel **without\n * clobbering** the contract headers it already carries.\n *\n * Headers ride beside the frozen wire envelope (GR-1), never inside it.\n */\n\nimport type { HeaderCarrier } from \"@babelqueue/core\";\n\n/**\n * Copy `headers`, dropping blank keys and blank values. Returns an empty object when nothing\n * survives, so a caller can treat the result as \"no headers\". A nullish input yields an empty\n * object.\n */\nexport function sanitizeHeaders(headers: HeaderCarrier | null | undefined): HeaderCarrier {\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headers[key];\n if (key === \"\" || value == null || value === \"\") continue;\n out[key] = value;\n }\n return out;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA6CA,kBAOO;;;ACpCA,SAAS,gBAAgB,SAA0D;AACxF,QAAM,MAAqB,CAAC;AAC5B,MAAI,CAAC,QAAS,QAAO;AACrB,aAAW,OAAO,OAAO,KAAK,OAAO,GAAG;AACtC,UAAM,QAAQ,QAAQ,GAAG;AACzB,QAAI,QAAQ,MAAM,SAAS,QAAQ,UAAU,GAAI;AACjD,QAAI,GAAG,IAAI;AAAA,EACb;AACA,SAAO;AACT;;;ADiCA,IAAM,iBAAiB;AAGhB,IAAM,yBAAyB;AA2DtC,IAAM,MAAM,CAAC,WAA8C;AAAA,EACzD,UAAU;AAAA,EACV,aAAa,OAAO,KAAK;AAC3B;AACA,IAAM,MAAM,CAAC,WAA8C;AAAA,EACzD,UAAU;AAAA,EACV,aAAa,OAAO,KAAK;AAC3B;AAMO,SAAS,oBAAoB,UAA8D;AAChG,QAAM,QAAkD,CAAC;AACzD,MAAI,SAAS,IAAK,OAAM,QAAQ,IAAI,IAAI,SAAS,GAAG;AACpD,MAAI,SAAS,SAAU,OAAM,aAAa,IAAI,IAAI,SAAS,QAAQ;AACnE,MAAI,SAAS,KAAK,GAAI,OAAM,eAAe,IAAI,IAAI,SAAS,KAAK,EAAE;AACnE,MAAI,SAAS,KAAK,kBAAkB,MAAM;AACxC,UAAM,mBAAmB,IAAI,IAAI,SAAS,KAAK,cAAc;AAAA,EAC/D;AACA,MAAI,SAAS,KAAK,KAAM,OAAM,gBAAgB,IAAI,IAAI,SAAS,KAAK,IAAI;AACxE,MAAI,SAAS,KAAK,cAAc,MAAM;AACpC,UAAM,eAAe,IAAI,IAAI,SAAS,KAAK,UAAU;AAAA,EACvD;AACA,SAAO;AACT;AAUO,SAAS,gBACd,MACA,SAC0C;AAC1C,QAAM,QAAQ,gBAAgB,OAAO;AACrC,aAAW,OAAO,OAAO,KAAK,KAAK,EAAE,KAAK,GAAG;AAC3C,QAAI,OAAO,KAAM;AACjB,QAAI,OAAO,KAAK,IAAI,EAAE,UAAU,eAAgB;AAChD,SAAK,GAAG,IAAI,IAAI,MAAM,GAAG,CAAC;AAAA,EAC5B;AACA,SAAO;AACT;AAQO,SAAS,UAAU,SAAoC;AAC5D,QAAM,QAAQ,QAAQ;AACtB,QAAM,MAAqB,CAAC;AAC5B,MAAI,CAAC,MAAO,QAAO;AACnB,aAAW,OAAO,OAAO,KAAK,KAAK,GAAG;AACpC,UAAM,QAAQ,MAAM,GAAG,GAAG;AAC1B,QAAI,SAAS,QAAQ,UAAU,GAAI,KAAI,GAAG,IAAI;AAAA,EAChD;AACA,SAAO;AACT;AAGA,IAAM,cAAc;AASpB,SAAS,yBAAyB,SAAyC;AACzE,QAAM,MAAe,QAAQ,oBAAoB,mBAAmB,GAAG;AACvE,MAAI,OAAO,QAAQ,YAAY,YAAY,KAAK,GAAG,EAAG,QAAO;AAC7D,SAAO,QAAQ,OAAO,0BAAc,IAAI,SAAY;AACtD;AAEA,SAAS,iBAAiB,UAA0B;AAClD,QAAM,WAAW,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO;AACnD,SAAO,SAAS,SAAS,SAAS,CAAC,KAAK;AAC1C;AA4BO,IAAM,eAAN,MAAmB;AAAA,EACxB,YACmB,QACA,UACA,UAA+B,CAAC,GACjD;AAHiB;AACA;AACA;AAAA,EAChB;AAAA,EAHgB;AAAA,EACA;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOnB,MAAM,QACJ,KACA,MACA,UAA0B,CAAC,GACV;AACjB,UAAM,WAAW,0BAAc,KAAK,KAAK,MAAM;AAAA,MAC7C,OAAO,iBAAiB,KAAK,QAAQ;AAAA,MACrC,SAAS,QAAQ;AAAA,IACnB,CAAC;AACD,UAAM,QAA0B;AAAA,MAC9B,UAAU,KAAK;AAAA,MACf,aAAa,0BAAc,OAAO,QAAQ;AAAA,MAC1C,mBAAmB,gBAAgB,oBAAoB,QAAQ,GAAG,QAAQ,OAAO;AAAA,IACnF;AACA,QAAI,KAAK,QAAQ,MAAM;AACrB,YAAM,iBAAiB,KAAK,QAAQ,kBAAkB,iBAAiB,KAAK,QAAQ;AACpF,UAAI,CAAC,KAAK,QAAQ,cAAc;AAC9B,cAAM,yBAAyB,SAAS,KAAK;AAAA,MAC/C;AAAA,IACF;AACA,UAAM,KAAK,OAAO,YAAY,KAAK;AACnC,WAAO,SAAS,KAAK;AAAA,EACvB;AACF;AAuEO,IAAM,iBAAN,cAA6B,4BAAgB;AAAA,EAClD,YAAY,OAAgB;AAC1B,UAAM,gGAAgG;AACtG,SAAK,OAAO;AACZ,SAAK,QAAQ;AAAA,EACf;AACF;AAUO,IAAM,cAAN,MAAkB;AAAA,EACvB,YACmB,QACA,UACA,UACA,UAA8B,CAAC,GAChD;AAJiB;AACA;AACA;AACA;AAEjB,UAAM,WAAW,QAAQ;AACzB,QAAI,aAAa,UAAa,CAAE,OAAO,OAAO,8BAAkB,EAAe,SAAS,QAAQ,GAAG;AACjG,YAAM,IAAI,4BAAgB,gCAAgC,QAAQ,IAAI;AAAA,IACxE;AACA,UAAM,eAAe,QAAQ,gBAAgB,QAAQ,aAAa,+BAAmB;AACrF,QAAI,gBAAgB,OAAO,OAAO,4BAA4B,YAAY;AACxE,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EAfmB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAenB,MAAM,OAAwB;AAC5B,UAAM,QAA6B;AAAA,MACjC,UAAU,KAAK;AAAA,MACf,qBAAqB,KAAK,QAAQ,eAAe;AAAA,MACjD,iBAAiB,KAAK,QAAQ,mBAAmB;AAAA,MACjD,uBAAuB,CAAC,KAAK;AAAA,MAC7B,gBAAgB,CAAC,yBAAyB;AAAA,IAC5C;AACA,QAAI,KAAK,QAAQ,qBAAqB,MAAM;AAC1C,YAAM,oBAAoB,KAAK,QAAQ;AAAA,IACzC;AACA,UAAM,SAAS,MAAM,KAAK,OAAO,eAAe,KAAK;AACrD,UAAM,WAAW,OAAO,YAAY,CAAC;AACrC,eAAW,WAAW,UAAU;AAC9B,YAAM,KAAK,OAAO,OAAO;AAAA,IAC3B;AACA,WAAO,SAAS;AAAA,EAClB;AAAA;AAAA,EAGA,MAAM,IAAI,QAAqC;AAC7C,WAAO,QAAQ,YAAY,MAAM;AAC/B,YAAM,KAAK,KAAK;AAAA,IAClB;AAAA,EACF;AAAA,EAEA,MAAc,OAAO,SAAoC;AAGvD,UAAM,kBAAkB,yBAAyB,OAAO;AACxD,QAAI,oBAAoB,QAAW;AACjC,WAAK,QAAQ;AAAA,QACX,IAAI;AAAA,UACF,mEAAmE,eAAe,iBAAiB,0BAAc;AAAA,QACnH;AAAA,QACA,CAAC;AAAA,QACD;AAAA,MACF;AACA;AAAA,IACF;AAEA,UAAM,WAAW,0BAAc,OAAO,QAAQ,QAAQ,EAAE;AAExD,UAAM,eAAe,QAAQ,aAAa,yBAAyB;AACnE,QAAI,iBAAiB,QAAW;AAC9B,YAAM,SAAS,OAAO,SAAS,cAAc,EAAE,IAAI;AACnD,YAAM,UAAU,OAAO,SAAS,aAAa,WAAW,SAAS,WAAW;AAC5E,UAAI,OAAO,SAAS,MAAM,KAAK,SAAS,SAAS;AAC/C,iBAAS,WAAW;AAAA,MACtB;AAAA,IACF;AAEA,QAAI,CAAC,0BAAc,QAAQ,QAAQ,GAAG;AACpC,WAAK,QAAQ;AAAA,QACX,IAAI,4BAAgB,yDAAyD;AAAA,QAC7E;AAAA,QACA;AAAA,MACF;AACA;AAAA,IACF;AAEA,UAAM,MAAM,0BAAc,IAAI,QAAQ;AACtC,UAAM,UAAU,KAAK,SAAS,GAAG;AACjC,QAAI,CAAC,SAAS;AACZ,YAAM,KAAK,QAAQ,KAAK,UAAsB,OAAO;AACrD;AAAA,IACF;AAEA,QAAI;AACF,YAAM,QAAQ,UAAU,SAAS,UAAU,OAAO,CAAC;AAAA,IACrD,SAAS,OAAO;AACd,WAAK,QAAQ,UAAU,OAAO,UAAU,OAAO;AAC/C,YAAM,KAAK,MAAM,UAAU,SAAS,YAAY;AAC9C,cAAM,QAAQ,KAAK,QAAQ,gBAAgB;AAC3C,cAAM,WAAW,OAAO,SAAS,aAAa,WAAW,SAAS,WAAW;AAC7E,cAAM,KAAK,QAAQ,SAAS,OAAO,UAAU,aAAa,MAAM,QAAQ,IAAI,KAAK;AAAA,MACnF,CAAC;AACD;AAAA,IACF;AAIA,UAAM,KAAK,MAAM,UAAU,SAAS,MAAM,KAAK,cAAc,OAAO,CAAC;AAAA,EACvE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAc,MAAM,UAA4B,SAAqB,MAA0C;AAC7G,QAAI;AACF,YAAM,KAAK;AAAA,IACb,SAAS,OAAO;AACd,WAAK,QAAQ,UAAU,OAAO,UAAU,OAAO;AAAA,IACjD;AAAA,EACF;AAAA;AAAA,EAGA,MAAc,QAAQ,KAAa,UAAoB,SAAoC;AACzF,UAAM,WAAW,KAAK,QAAQ;AAC9B,QAAI,aAAa,QAAW;AAC1B,UAAI,KAAK,QAAQ,cAAc;AAC7B,cAAM,KAAK,QAAQ,aAAa,UAAU,OAAO;AACjD,cAAM,KAAK,OAAO,OAAO;AAAA,MAC3B,OAAO;AACL,aAAK,QAAQ,UAAU,IAAI,4BAAgB,GAAG,GAAG,UAAU,OAAO;AAAA,MACpE;AACA;AAAA,IACF;AAEA,UAAM,KAAK,MAAM,UAAU,SAAS,YAAY;AAC9C,YAAM,KAAK,QAAQ,eAAe,UAAU,OAAO;AAAA,IACrD,CAAC;AACD,YAAQ,UAAU;AAAA,MAChB,KAAK,+BAAmB;AACtB,cAAM,KAAK,MAAM,UAAU,SAAS,MAAM,KAAK,cAAc,OAAO,CAAC;AACrE;AAAA,MACF,KAAK,+BAAmB;AACtB,cAAM,KAAK,MAAM,UAAU,SAAS,MAAM,KAAK,QAAQ,SAAS,KAAK,QAAQ,0BAA0B,CAAC,CAAC;AACzG;AAAA,MACF,KAAK,+BAAmB;AACtB,cAAM,KAAK,MAAM,UAAU,SAAS,YAAY;AAC9C,gBAAM,KAAK,WAAW,UAAU,aAAa;AAC7C,gBAAM,KAAK,cAAc,OAAO;AAAA,QAClC,CAAC;AACD;AAAA,MACF;AAEE,aAAK,QAAQ,UAAU,IAAI,4BAAgB,GAAG,GAAG,UAAU,OAAO;AAAA,IACtE;AAAA,EACF;AAAA;AAAA,EAGA,MAAc,QAAQ,SAAqB,SAAgC;AACzE,QAAI,CAAC,QAAQ,iBAAiB,CAAC,KAAK,OAAO,wBAAyB;AACpE,UAAM,UAAU,OAAO,SAAS,OAAO,IACnC,KAAK,IAAI,wBAAwB,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,CAAC,CAAC,IACjE;AACJ,UAAM,KAAK,OAAO,wBAAwB;AAAA,MACxC,UAAU,KAAK;AAAA,MACf,eAAe,QAAQ;AAAA,MACvB,mBAAmB;AAAA,IACrB,CAAC;AAAA,EACH;AAAA;AAAA,EAGA,MAAc,WAAW,UAAoB,QAA+B;AAC1E,UAAM,MAAM,KAAK,QAAQ;AACzB,QAAI,CAAC,IAAK;AACV,UAAM,SAAS,SAAS,MAAM,SAAS,iBAAiB,KAAK,QAAQ;AACrE,UAAM,gBAAY,sBAAS,UAAU,QAAQ,QAAQ;AAAA,MACnD,UAAU,SAAS,YAAY;AAAA,IACjC,CAAC;AACD,UAAM,QAA0B;AAAA,MAC9B,UAAU;AAAA,MACV,aAAa,0BAAc,OAAO,SAAS;AAAA,MAC3C,mBAAmB,oBAAoB,SAAS;AAAA,IAClD;AACA,QAAI,IAAI,SAAS,OAAO,GAAG;AAEzB,YAAM,iBAAiB;AACvB,UAAI,UAAU,MAAM,GAAI,OAAM,yBAAyB,UAAU,KAAK;AAAA,IACxE;AACA,UAAM,KAAK,OAAO,YAAY,KAAK;AAAA,EACrC;AAAA;AAAA,EAGA,MAAc,cAAc,SAAoC;AAC9D,QAAI;AACF,YAAM,KAAK,OAAO,OAAO;AAAA,IAC3B,SAAS,OAAO;AACd,YAAM,IAAI,eAAe,KAAK;AAAA,IAChC;AAAA,EACF;AAAA,EAEA,MAAc,OAAO,SAAoC;AACvD,QAAI,CAAC,QAAQ,cAAe;AAC5B,UAAM,KAAK,OAAO,cAAc;AAAA,MAC9B,UAAU,KAAK;AAAA,MACf,eAAe,QAAQ;AAAA,IACzB,CAAC;AAAA,EACH;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/headers.ts"],"sourcesContent":["/**\n * Amazon SQS adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed **consumer** over Amazon SQS,\n * so an SQS-based Node service speaks the same contract (envelope shape, URN\n * identity, trace propagation) as the PHP/Laravel, Python, Go, Java and .NET SDKs.\n *\n * import { SQS } from \"@aws-sdk/client-sqs\";\n * import { SqsPublisher, SqsConsumer } from \"@babelqueue/sqs\";\n *\n * const sqs = new SQS({ region: \"eu-central-1\" });\n * const url = \"https://sqs.eu-central-1.amazonaws.com/123456789012/orders\";\n *\n * await new SqsPublisher(sqs, url).publish(\"urn:babel:orders:created\", { order_id: 1042 });\n *\n * const consumer = new SqsConsumer(sqs, url, {\n * \"urn:babel:orders:created\": async (env) => { ... },\n * });\n * await consumer.run();\n *\n * This implements §3 of the broker-bindings contract: the canonical envelope is the\n * message body, projected onto native SQS `MessageAttributes`. The envelope is\n * unchanged (`schema_version` stays 1); SQS is purely additive. Retry is SQS-native\n * (a failed handler's message is released via `ChangeMessageVisibility` after\n * `releaseDelay`, default 0 s — pair the queue with a `RedrivePolicy`); the\n * authoritative attempt count is `ApproximateReceiveCount`, surfaced to handlers as\n * `attempts = count − 1`.\n *\n * **Version gate (§3.7, ADR-0037 decision 1).** Before the body is decoded the consumer reads the `bq-schema-version`\n * message attribute. A missing or blank value (empty or only ASCII whitespace) changes nothing; any\n * value other than the core's supported `SCHEMA_VERSION` (`\"2\"`, `\"x\"`, `\"01\"`, ...) rejects the\n * message undecoded (`onError` with an error naming the version and an empty envelope) and settles it on its\n * **first delivery**: never decoded, never given to a handler, `onUnknownUrn` or a retry. Under\n * `unknownUrn: \"delete\"` it is deleted; with a `deadLetterQueueUrl` the body, byte for byte and without a\n * `dead_letter` block, is sent to the DLQ and deleted only after the send succeeded (a failed send leaves it\n * undeleted: visibility expiry redelivers it and the write is tried again); without a `deadLetterQueueUrl`\n * it is left to the **native** mechanism — not deleted, so visibility expiry redelivers it and the queue's\n * `RedrivePolicy` moves it to the native DLQ (pair the queue with one). Only the explicitly configured\n * `unknownUrn` strategy is read: an `onUnknownUrn` hook never changes it (a hook-only consumer behaves as `fail`).\n *\n * **Poison (ADR-0037 decision 2, ADR-0035).** A body that does not decode into a valid envelope is never given\n * to a handler and never retried. `unknownUrn: \"delete\"` deletes it; with a `deadLetterQueueUrl` the ADR-0035\n * record (`reason: \"poison\"`, built with the core's `buildPoisonRecord`) is sent to the DLQ and the message\n * deleted only after the send succeeded; without one it is left to the native mechanism, as above.\n *\n * **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop\n * span linkage) rides as additional String `MessageAttributes` **beside** the contract `bq-*`\n * attributes where `bq-trace-id` already lives — the contract attributes win a key collision, and\n * the merged set is bounded by SQS's **10-attribute limit** (contract attributes are seeded first,\n * so a rider only lands while headroom remains). On consume the inbound `MessageAttributes` are\n * surfaced as a `Record<string,string>` so the core's `otel` extract sees the `traceparent` and\n * links the consumer span as a child. A header-less publish is byte-identical. This mirrors the Go\n * `/sqs` and PHP `SqsTransport` wiring. GR-1: the wire envelope body is never touched.\n */\n\nimport { createHash } from \"node:crypto\";\n\nimport {\n BabelQueueError,\n EnvelopeCodec,\n SCHEMA_VERSION,\n UnknownUrnError,\n UnknownUrnStrategy,\n annotate,\n buildPoisonRecord,\n retryBackoffBound,\n retryBackoffDelay,\n} from \"@babelqueue/core\";\nimport type { Envelope, HeaderCarrier, IncomingEnvelope, RetryBackoffOptions } from \"@babelqueue/core\";\n\nimport { sanitizeHeaders } from \"./headers.js\";\n\n/** SQS allows at most 10 user message attributes per message. */\nconst MAX_ATTRIBUTES = 10;\n\n/** The SQS ceiling for a `VisibilityTimeout` (12 hours, in seconds). */\nexport const MAX_VISIBILITY_TIMEOUT = 43_200;\n\n// --- Minimal SQS shapes (a structural subset of @aws-sdk/client-sqs) -----------\n\nexport interface SqsMessageAttributeValue {\n DataType: string;\n StringValue?: string;\n}\n\nexport interface SqsMessage {\n MessageId?: string;\n Body?: string;\n ReceiptHandle?: string;\n MessageAttributes?: Record<string, SqsMessageAttributeValue>;\n Attributes?: Record<string, string>;\n}\n\nexport interface SendMessageInput {\n QueueUrl: string;\n MessageBody: string;\n MessageAttributes?: Record<string, SqsMessageAttributeValue>;\n MessageGroupId?: string;\n MessageDeduplicationId?: string;\n}\n\nexport interface ReceiveMessageInput {\n QueueUrl: string;\n MaxNumberOfMessages?: number;\n WaitTimeSeconds?: number;\n VisibilityTimeout?: number;\n MessageAttributeNames?: string[];\n AttributeNames?: string[];\n}\n\n/**\n * The subset of the AWS SQS client this adapter calls. The aggregated `SQS` class\n * from `@aws-sdk/client-sqs` satisfies it structurally; a fake satisfies it in tests.\n */\nexport interface SqsApi {\n sendMessage(input: SendMessageInput): Promise<{ MessageId?: string } | unknown>;\n receiveMessage(input: ReceiveMessageInput): Promise<{ Messages?: SqsMessage[] }>;\n deleteMessage(input: { QueueUrl: string; ReceiptHandle: string }): Promise<unknown>;\n /**\n * The §3.5 release primitive. Optional so existing structural clients keep compiling; it is\n * required (checked at construction) when a consumer sets `releaseDelay` or the `release`\n * unknown-URN strategy explicitly. Without it the default handler-failure release degrades to\n * leaving the message for visibility-timeout redelivery. The aggregated `SQS` class from\n * `@aws-sdk/client-sqs` provides it.\n */\n changeMessageVisibility?(input: ChangeMessageVisibilityInput): Promise<unknown>;\n}\n\nexport interface ChangeMessageVisibilityInput {\n QueueUrl: string;\n ReceiptHandle: string;\n VisibilityTimeout: number;\n}\n\n// --- Attribute projection (contract §3.2) --------------------------------------\n\nconst str = (value: unknown): SqsMessageAttributeValue => ({\n DataType: \"String\",\n StringValue: String(value),\n});\nconst num = (value: unknown): SqsMessageAttributeValue => ({\n DataType: \"Number\",\n StringValue: String(value),\n});\n\n/**\n * Project the envelope's contract fields onto native SQS `MessageAttributes` — a\n * redundant, routable view of the body (the body stays authoritative).\n */\nexport function toMessageAttributes(envelope: Envelope): Record<string, SqsMessageAttributeValue> {\n const attrs: Record<string, SqsMessageAttributeValue> = {};\n if (envelope.job) attrs[\"bq-job\"] = str(envelope.job);\n if (envelope.trace_id) attrs[\"bq-trace-id\"] = str(envelope.trace_id);\n if (envelope.meta.id) attrs[\"bq-message-id\"] = str(envelope.meta.id);\n if (envelope.meta.schema_version != null) {\n attrs[\"bq-schema-version\"] = num(envelope.meta.schema_version);\n }\n if (envelope.meta.lang) attrs[\"bq-source-lang\"] = str(envelope.meta.lang);\n if (envelope.meta.created_at != null) {\n attrs[\"bq-created-at\"] = num(envelope.meta.created_at);\n }\n return attrs;\n}\n\n/**\n * Overlay the out-of-band `headers` onto the contract attribute projection as String\n * `MessageAttributes`, without overwriting an existing `bq-*` attribute (the contract wins a key\n * collision) and skipping blanks. Keys are merged in sorted order and the merge stops at the\n * 10-attribute SQS ceiling, so unbounded riders can never push the message past the limit (SQS\n * rejects the whole send otherwise) — the contract attributes are always preserved first. Mirrors\n * Go's `mergeAttributes` / PHP's `SqsTransport::attributes`.\n */\nexport function mergeAttributes(\n base: Record<string, SqsMessageAttributeValue>,\n headers: HeaderCarrier | null | undefined,\n): Record<string, SqsMessageAttributeValue> {\n const clean = sanitizeHeaders(headers);\n for (const key of Object.keys(clean).sort()) {\n if (key in base) continue; // never clobber a contract bq-* attribute\n if (Object.keys(base).length >= MAX_ATTRIBUTES) break; // respect the SQS 10-attribute cap\n base[key] = str(clean[key]);\n }\n return base;\n}\n\n/**\n * Map inbound SQS `MessageAttributes` onto a flat {@link HeaderCarrier} (the consume-side\n * counterpart of {@link mergeAttributes}), reading each attribute's `StringValue`. Returns an empty\n * object when there are none. Both the contract `bq-*` attributes and any out-of-band rider (e.g.\n * `traceparent`) surface — the core's `otel` extract reads only the keys it knows.\n */\nexport function headersOf(message: SqsMessage): HeaderCarrier {\n const attrs = message.MessageAttributes;\n const out: HeaderCarrier = {};\n if (!attrs) return out;\n for (const key of Object.keys(attrs)) {\n const value = attrs[key]?.StringValue;\n if (value != null && value !== \"\") out[key] = value;\n }\n return out;\n}\n\n/** Blank = empty or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) — not `String.prototype.trim()`'s wider set. */\nconst BLANK_ASCII = /^[ \\t\\n\\v\\f\\r]*$/;\n\n/**\n * The `bq-schema-version` message attribute when it is present, non-blank and not the supported\n * version (compared against the core's `SCHEMA_VERSION`, exact string match: `\"01\"`, `\" 1\"`, `\"1.0\"`\n * are rejected). Blank means the empty string or only ASCII whitespace (the same set in every SDK,\n * GR-5); NBSP, U+0085, U+FEFF, U+3000 and the like are not blank. `undefined` when the message may\n * be decoded.\n */\nfunction unsupportedSchemaVersion(message: SqsMessage): string | undefined {\n const raw: unknown = message.MessageAttributes?.[\"bq-schema-version\"]?.StringValue;\n if (typeof raw !== \"string\" || BLANK_ASCII.test(raw)) return undefined;\n return raw === String(SCHEMA_VERSION) ? undefined : raw;\n}\n\nfunction queueNameFromUrl(queueUrl: string): string {\n const segments = queueUrl.split(\"/\").filter(Boolean);\n return segments[segments.length - 1] ?? \"default\";\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link SqsPublisher}. */\nexport interface SqsPublisherOptions {\n /** Treat the queue as FIFO: set `MessageGroupId` and (unless content dedup) `MessageDeduplicationId`. */\n fifo?: boolean;\n /** FIFO ordering group (default: the queue name from the URL). */\n messageGroupId?: string;\n /** Use the queue's content-based dedup instead of `meta.id` as the dedup id. */\n contentDedup?: boolean;\n}\n\n/** Options for {@link SqsPublisher.publish}. */\nexport interface PublishOptions {\n /** Reuse an existing trace id (trace continuation). */\n traceId?: string;\n /**\n * Out-of-band transport headers carried as String `MessageAttributes` beside the contract `bq-*`\n * attributes (ADR-0028) — e.g. a W3C `traceparent` written by `@babelqueue/core/otel`'s `publish`.\n * The contract attributes win a key collision and the merged set is capped at SQS's 10-attribute\n * limit; an empty/omitted carrier leaves the publish unchanged.\n */\n headers?: HeaderCarrier;\n}\n\n/** Sends canonical-envelope messages to one SQS queue with the §3 attribute projection. */\nexport class SqsPublisher {\n constructor(\n private readonly client: SqsApi,\n private readonly queueUrl: string,\n private readonly options: SqsPublisherOptions = {},\n ) {}\n\n /**\n * Build the canonical envelope for `(urn, data)`, send it as the message body with\n * the projected `MessageAttributes`, and return the message id (`meta.id`).\n */\n async publish(\n urn: string,\n data: Record<string, unknown>,\n options: PublishOptions = {},\n ): Promise<string> {\n const envelope = EnvelopeCodec.make(urn, data, {\n queue: queueNameFromUrl(this.queueUrl),\n traceId: options.traceId,\n });\n const input: SendMessageInput = {\n QueueUrl: this.queueUrl,\n MessageBody: EnvelopeCodec.encode(envelope),\n MessageAttributes: mergeAttributes(toMessageAttributes(envelope), options.headers),\n };\n if (this.options.fifo) {\n input.MessageGroupId = this.options.messageGroupId ?? queueNameFromUrl(this.queueUrl);\n if (!this.options.contentDedup) {\n input.MessageDeduplicationId = envelope.meta.id;\n }\n }\n await this.client.sendMessage(input);\n return envelope.meta.id;\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/**\n * A URN handler. Receives the validated envelope, the raw SQS message, and the out-of-band\n * {@link HeaderCarrier} read from the message's `MessageAttributes` (empty when there are none).\n * Pass `headers` to `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of\n * the producer span (ADR-0028).\n */\nexport type BabelHandler = (envelope: Envelope, message: SqsMessage, headers: HeaderCarrier) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link SqsConsumer}. */\nexport interface SqsConsumerOptions extends RetryBackoffOptions {\n /**\n * Called when a message's URN has no handler. Without `unknownUrn` the message is then deleted\n * (the original behaviour); with `unknownUrn` set it is a notification hook and the strategy\n * decides the message's fate.\n */\n onUnknownUrn?: (envelope: IncomingEnvelope, message: SqsMessage) => unknown | Promise<unknown>;\n /**\n * Unknown-URN strategy per contract §3.5 — `fail` | `delete` | `release` | `dead_letter`\n * ({@link UnknownUrnStrategy}). Omitted keeps the original behaviour (`onUnknownUrn` → delete,\n * otherwise report and leave for visibility-timeout redelivery, i.e. `fail`). `release` uses\n * `ChangeMessageVisibility` with {@link unknownUrnReleaseDelay}; `dead_letter` sends the annotated\n * envelope to {@link deadLetterQueueUrl} then deletes, degrading to `delete` when no DLQ is set.\n *\n * The version gate and the poison route read **only this explicitly configured strategy** (ADR-0037): an\n * `onUnknownUrn` hook, and the `delete` it implies when no strategy is set, never apply to them.\n */\n unknownUrn?: string;\n /** Backoff (seconds) for the `release` unknown-URN strategy (default 0 = redeliver now). */\n unknownUrnReleaseDelay?: number;\n /**\n * Backoff before a failed message is redelivered. A failed handler always releases the message\n * via `ChangeMessageVisibility(ReceiptHandle, VisibilityTimeout)` (contract §3.5); this sets the\n * `VisibilityTimeout` — a number of seconds, or a function of the (reconciled) `attempts`\n * returning seconds (e.g. an exponential backoff). Clamped to `0…43200`. Default `0`: the message\n * is visible again immediately. A permanently failing handler therefore retries without pause\n * until the queue's `RedrivePolicy` (`maxReceiveCount`) moves it to the DLQ — configure one.\n *\n * This is the SQS spelling of the cross-SDK A9 backoff and stays supported as an alias: when it is\n * set it wins over `retryBackoff` / `retryBackoffMultiplier` / `retryBackoffMax` / `retryJitter`\n * (inherited from {@link RetryBackoffOptions}), which compute `d(n) = min(max, base * multiplier^(n-1))`\n * seconds for the 1-based attempt `n = attempts + 1` (the SQS 12 h ceiling still applies on top).\n */\n releaseDelay?: number | ((attempts: number) => number);\n /**\n * The cross-language `<queue>-dlq` URL (opt-in; default none; SQS names cannot contain a dot): the `dead_letter` strategy, poison bodies\n * (ADR-0035 record) and version-gated deliveries (the raw body) are written here. Without it a poison or\n * version-gated delivery is left to the queue's native `RedrivePolicy` (ADR-0037). A\n * `.fifo` DLQ is sent with `MessageGroupId` (the source queue name) and `MessageDeduplicationId`\n * (`meta.id`).\n */\n deadLetterQueueUrl?: string | null;\n /**\n * Called for a non-conformant envelope, an unmapped URN (no `onUnknownUrn`), a throwing handler,\n * a throwing `onUnknownUrn` under an `unknownUrn` strategy, a failed release / dead-letter send, or\n * a failed delete after successful processing (an {@link SqsDeleteError} wrapping the broker\n * error; never released). The message is then left for visibility-timeout redelivery. The loop\n * never stops on these.\n */\n onError?: (error: unknown, envelope: IncomingEnvelope, message: SqsMessage) => void;\n /** Long-poll wait seconds (default 20). */\n waitTimeSeconds?: number;\n /** Reservation window applied on receive (seconds). */\n visibilityTimeout?: number;\n /** Max messages per receive (default 10). */\n maxMessages?: number;\n}\n\n/**\n * Reported via `onError` when `DeleteMessage` fails for a message whose processing already succeeded\n * (a handler returned normally, or the unknown-URN strategy chose `delete` / `dead_letter`). The\n * broker error is `cause`. Distinct from a handler failure: the message is NOT released, so it is\n * redelivered only after its visibility timeout expires (at-least-once; dedupe on `meta.id` with the\n * idempotency helper if the side effect must not repeat).\n */\nexport class SqsDeleteError extends BabelQueueError {\n constructor(cause: unknown) {\n super(\"Failed to delete a processed SQS message; it will be redelivered after its visibility timeout.\");\n this.name = \"SqsDeleteError\";\n this.cause = cause;\n }\n}\n\nfunction hasRetryBackoff(options: RetryBackoffOptions): boolean {\n return (\n options.retryBackoff !== undefined ||\n options.retryBackoffMultiplier !== undefined ||\n options.retryBackoffMax !== undefined ||\n options.retryJitter !== undefined\n );\n}\n\n/**\n * Polls an SQS queue, decodes + validates each message, routes it to the handler\n * registered for its URN, and deletes it on success. A throwing handler's message is released via\n * `ChangeMessageVisibility` after `releaseDelay` (default 0 s, contract §3.5); if the release itself\n * fails the message is left for visibility-timeout redelivery (at-least-once);\n * `attempts` is reconciled to `ApproximateReceiveCount − 1` for the handler. An unmapped URN\n * follows the `unknownUrn` strategy (`fail` | `delete` | `release` | `dead_letter`).\n */\nexport class SqsConsumer {\n constructor(\n private readonly client: SqsApi,\n private readonly queueUrl: string,\n private readonly handlers: BabelHandlers,\n private readonly options: SqsConsumerOptions = {},\n ) {\n const strategy = options.unknownUrn;\n if (strategy !== undefined && !(Object.values(UnknownUrnStrategy) as string[]).includes(strategy)) {\n throw new BabelQueueError(`Unknown unknownUrn strategy \"${strategy}\".`);\n }\n // Fail fast on an invalid A9 parameter instead of at the first failed message.\n retryBackoffBound(options, 1);\n if (options.retryJitter !== undefined && options.retryJitter !== \"none\" && options.retryJitter !== \"full\") {\n throw new BabelQueueError(`retryJitter must be \"none\" or \"full\", got ${String(options.retryJitter)}.`);\n }\n const needsRelease =\n options.releaseDelay != null || hasRetryBackoff(options) || strategy === UnknownUrnStrategy.RELEASE;\n if (needsRelease && typeof client.changeMessageVisibility !== \"function\") {\n throw new BabelQueueError(\n \"SqsConsumer release requires a client with changeMessageVisibility (contract §3.5).\",\n );\n }\n }\n\n /** Receive one batch, route each message, delete the ones handled. Returns the batch size. */\n async poll(): Promise<number> {\n const input: ReceiveMessageInput = {\n QueueUrl: this.queueUrl,\n MaxNumberOfMessages: this.options.maxMessages ?? 10,\n WaitTimeSeconds: this.options.waitTimeSeconds ?? 20,\n MessageAttributeNames: [\"All\"],\n AttributeNames: [\"ApproximateReceiveCount\"],\n };\n if (this.options.visibilityTimeout != null) {\n input.VisibilityTimeout = this.options.visibilityTimeout;\n }\n const result = await this.client.receiveMessage(input);\n const messages = result.Messages ?? [];\n for (const message of messages) {\n await this.handle(message);\n }\n return messages.length;\n }\n\n /** Poll until `signal` aborts (each poll long-polls, so this does not busy-loop). */\n async run(signal?: AbortSignal): Promise<void> {\n while (signal?.aborted !== true) {\n await this.poll();\n }\n }\n\n private async handle(message: SqsMessage): Promise<void> {\n // §3.7: gate on the bq-schema-version attribute before the body is decoded (ADR-0037 decision 1).\n const rejectedVersion = unsupportedSchemaVersion(message);\n if (rejectedVersion !== undefined) {\n await this.settleVersionGated(\n message,\n new BabelQueueError(\n `Rejected a message from SQS with unsupported bq-schema-version \"${rejectedVersion}\" (supported: ${SCHEMA_VERSION}).`,\n ),\n );\n return;\n }\n\n const envelope = EnvelopeCodec.decode(message.Body ?? \"\");\n\n const receiveCount = message.Attributes?.[\"ApproximateReceiveCount\"];\n if (receiveCount !== undefined) {\n const native = Number.parseInt(receiveCount, 10) - 1;\n const current = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n if (Number.isFinite(native) && native > current) {\n envelope.attempts = native;\n }\n }\n\n if (!EnvelopeCodec.accepts(envelope)) {\n await this.settlePoison(message, envelope, new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from SQS.\"));\n return;\n }\n\n const urn = EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n await this.unknown(urn, envelope as Envelope, message);\n return;\n }\n\n try {\n await handler(envelope, message, headersOf(message));\n } catch (error) {\n this.options.onError?.(error, envelope, message);\n await this.guard(envelope, message, async () => {\n const attempts = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n await this.release(message, this.failureDelay(attempts));\n });\n return;\n }\n // Outside the handler's try: a failed delete is not a handler failure. It is reported as an\n // SqsDeleteError and the message is NOT released — releasing at the (default 0 s) backoff would\n // redeliver an already-processed message immediately; it returns on visibility expiry instead.\n await this.guard(envelope, message, () => this.deleteHandled(message));\n }\n\n /**\n * Version-gated delivery (ADR-0037 decision 1). `unknownUrn: \"delete\"` deletes it; with a DLQ the raw body goes\n * to `<queue>-dlq` and the message is deleted only after the send; otherwise it is reported and left for the\n * queue's native mechanism (never deleted, never released by the SDK). Only the configured strategy is read.\n */\n private async settleVersionGated(message: SqsMessage, gateError: BabelQueueError): Promise<void> {\n this.options.onError?.(gateError, {}, message);\n const dlq = this.options.deadLetterQueueUrl;\n if (this.options.unknownUrn === UnknownUrnStrategy.DELETE) {\n await this.guard({}, message, () => this.deleteHandled(message));\n return;\n }\n if (!dlq) return; // native: SQS redelivers on visibility expiry, RedrivePolicy moves it after maxReceiveCount\n await this.guard({}, message, async () => {\n await this.sendRaw(dlq, message, message.Body ?? \"\");\n await this.deleteHandled(message);\n });\n }\n\n /**\n * Poison body (ADR-0037 decision 2). `unknownUrn: \"delete\"` deletes it; with a DLQ the ADR-0035 record is sent\n * and the message deleted only after the send; otherwise it is reported and left for the native mechanism.\n */\n private async settlePoison(message: SqsMessage, envelope: IncomingEnvelope, error: BabelQueueError): Promise<void> {\n this.options.onError?.(error, envelope, message);\n const dlq = this.options.deadLetterQueueUrl;\n if (this.options.unknownUrn === UnknownUrnStrategy.DELETE) {\n await this.guard(envelope, message, () => this.deleteHandled(message));\n return;\n }\n if (!dlq) return; // native, as above\n await this.guard(envelope, message, async () => {\n const source = queueNameFromUrl(this.queueUrl);\n const count = Number.parseInt(message.Attributes?.[\"ApproximateReceiveCount\"] ?? \"\", 10);\n const delivered = Number.isFinite(count) && count > 1 ? count - 1 : 0; // informational: deliveries before this one\n const deadLetter = annotate({ attempts: delivered } as unknown as Envelope, \"poison\", source, {\n attempts: delivered,\n error: error.message,\n exception: error.name,\n }).dead_letter;\n const record = buildPoisonRecord(Buffer.from(message.Body ?? \"\", \"utf8\"), JSON.stringify(deadLetter));\n await this.sendRaw(dlq, message, record);\n await this.deleteHandled(message);\n });\n }\n\n /** Send `body` to the DLQ as is (a `.fifo` DLQ gets the source queue as group and a stable dedup id). */\n private async sendRaw(dlq: string, message: SqsMessage, body: string): Promise<void> {\n const input: SendMessageInput = { QueueUrl: dlq, MessageBody: body };\n if (dlq.endsWith(\".fifo\")) {\n input.MessageGroupId = queueNameFromUrl(this.queueUrl);\n input.MessageDeduplicationId = message.MessageId ?? createHash(\"sha256\").update(message.Body ?? \"\").digest(\"hex\");\n }\n await this.client.sendMessage(input);\n }\n\n /** The release delay (seconds) for a failed handler: `releaseDelay` (alias) wins, else the A9 backoff, else 0. */\n private failureDelay(attempts: number): number {\n const delay = this.options.releaseDelay;\n if (delay != null) return typeof delay === \"function\" ? delay(attempts) : delay;\n if (!hasRetryBackoff(this.options)) return 0;\n return retryBackoffDelay(this.options, Math.max(0, Math.floor(attempts)) + 1);\n }\n\n /**\n * Run a release / dead-letter step; a failure (e.g. `MessageNotInflight`, a missing IAM grant, a\n * throwing `releaseDelay`) is reported to `onError` and the message is left undeleted, so\n * visibility expiry still redelivers it. Never lets the error stop the consume loop.\n */\n private async guard(envelope: IncomingEnvelope, message: SqsMessage, step: () => Promise<void>): Promise<void> {\n try {\n await step();\n } catch (error) {\n this.options.onError?.(error, envelope, message);\n }\n }\n\n /** Apply the unknown-URN strategy (contract §3.5). */\n private async unknown(urn: string, envelope: Envelope, message: SqsMessage): Promise<void> {\n const strategy = this.options.unknownUrn;\n if (strategy === undefined) {\n if (this.options.onUnknownUrn) {\n await this.options.onUnknownUrn(envelope, message);\n await this.delete(message);\n } else {\n this.options.onError?.(new UnknownUrnError(urn), envelope, message);\n }\n return;\n }\n\n await this.guard(envelope, message, async () => {\n await this.options.onUnknownUrn?.(envelope, message);\n });\n switch (strategy) {\n case UnknownUrnStrategy.DELETE:\n await this.guard(envelope, message, () => this.deleteHandled(message));\n return;\n case UnknownUrnStrategy.RELEASE:\n await this.guard(envelope, message, () => this.release(message, this.options.unknownUrnReleaseDelay ?? 0));\n return;\n case UnknownUrnStrategy.DEAD_LETTER:\n await this.guard(envelope, message, async () => {\n await this.deadLetter(envelope, \"unknown_urn\");\n await this.deleteHandled(message);\n });\n return;\n default:\n // FAIL: surface and do NOT delete — visibility expiry redelivers, native redrive quarantines.\n this.options.onError?.(new UnknownUrnError(urn), envelope, message);\n }\n }\n\n /** Make the message visible again after `seconds` via `ChangeMessageVisibility`; never deletes. */\n private async release(message: SqsMessage, seconds: number): Promise<void> {\n if (!message.ReceiptHandle || !this.client.changeMessageVisibility) return;\n const timeout = Number.isFinite(seconds)\n ? Math.min(MAX_VISIBILITY_TIMEOUT, Math.max(0, Math.floor(seconds)))\n : 0;\n await this.client.changeMessageVisibility({\n QueueUrl: this.queueUrl,\n ReceiptHandle: message.ReceiptHandle,\n VisibilityTimeout: timeout,\n });\n }\n\n /** Send the annotated envelope to the opt-in DLQ; a no-op (→ plain delete) when none is set. */\n private async deadLetter(envelope: Envelope, reason: string): Promise<void> {\n const dlq = this.options.deadLetterQueueUrl;\n if (!dlq) return;\n const source = envelope.meta?.queue ?? queueNameFromUrl(this.queueUrl);\n const annotated = annotate(envelope, reason, source, {\n attempts: envelope.attempts ?? 0,\n });\n const input: SendMessageInput = {\n QueueUrl: dlq,\n MessageBody: EnvelopeCodec.encode(annotated),\n MessageAttributes: toMessageAttributes(annotated),\n };\n if (dlq.endsWith(\".fifo\")) {\n // A FIFO DLQ (`<queue>-dlq.fifo`) rejects a send without a group; dedup on meta.id (§3.2).\n input.MessageGroupId = source;\n if (annotated.meta?.id) input.MessageDeduplicationId = annotated.meta.id;\n }\n await this.client.sendMessage(input);\n }\n\n /** Delete a message whose processing already succeeded; a broker failure becomes an SqsDeleteError. */\n private async deleteHandled(message: SqsMessage): Promise<void> {\n try {\n await this.delete(message);\n } catch (error) {\n throw new SqsDeleteError(error);\n }\n }\n\n private async delete(message: SqsMessage): Promise<void> {\n if (!message.ReceiptHandle) return;\n await this.client.deleteMessage({\n QueueUrl: this.queueUrl,\n ReceiptHandle: message.ReceiptHandle,\n });\n }\n}\n","/**\n * Pure, dependency-free helpers for the out-of-band transport-header seam (ADR-0028) — the Node\n * adapter mirror of Go's `sanitizeHeaders` and PHP's `Support\\Headers`. They fold an injected\n * {@link HeaderCarrier} (e.g. a W3C `traceparent`) onto a transport's metadata channel **without\n * clobbering** the contract headers it already carries.\n *\n * Headers ride beside the frozen wire envelope (GR-1), never inside it.\n */\n\nimport type { HeaderCarrier } from \"@babelqueue/core\";\n\n/**\n * Copy `headers`, dropping blank keys and blank values. Returns an empty object when nothing\n * survives, so a caller can treat the result as \"no headers\". A nullish input yields an empty\n * object.\n */\nexport function sanitizeHeaders(headers: HeaderCarrier | null | undefined): HeaderCarrier {\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headers[key];\n if (key === \"\" || value == null || value === \"\") continue;\n out[key] = value;\n }\n return out;\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { Envelope, HeaderCarrier, IncomingEnvelope, BabelQueueError } from '@babelqueue/core';
1
+ import { Envelope, HeaderCarrier, RetryBackoffOptions, IncomingEnvelope, BabelQueueError } from '@babelqueue/core';
2
2
 
3
3
  /**
4
4
  * Amazon SQS adapter for BabelQueue.
@@ -28,12 +28,22 @@ import { Envelope, HeaderCarrier, IncomingEnvelope, BabelQueueError } from '@bab
28
28
  * authoritative attempt count is `ApproximateReceiveCount`, surfaced to handlers as
29
29
  * `attempts = count − 1`.
30
30
  *
31
- * **Version gate (§3.7).** Before the body is decoded the consumer reads the `bq-schema-version`
31
+ * **Version gate (§3.7, ADR-0037 decision 1).** Before the body is decoded the consumer reads the `bq-schema-version`
32
32
  * message attribute. A missing or blank value (empty or only ASCII whitespace) changes nothing; any
33
33
  * value other than the core's supported `SCHEMA_VERSION` (`"2"`, `"x"`, `"01"`, ...) rejects the
34
- * message undecoded through the same path as a non-conformant envelope — `onError` (with an error
35
- * naming the version, and an empty envelope since nothing was decoded) and the message is left
36
- * undeleted, so it is redelivered and the queue's `RedrivePolicy` moves it to the DLQ.
34
+ * message undecoded (`onError` with an error naming the version and an empty envelope) and settles it on its
35
+ * **first delivery**: never decoded, never given to a handler, `onUnknownUrn` or a retry. Under
36
+ * `unknownUrn: "delete"` it is deleted; with a `deadLetterQueueUrl` the body, byte for byte and without a
37
+ * `dead_letter` block, is sent to the DLQ and deleted only after the send succeeded (a failed send leaves it
38
+ * undeleted: visibility expiry redelivers it and the write is tried again); without a `deadLetterQueueUrl`
39
+ * it is left to the **native** mechanism — not deleted, so visibility expiry redelivers it and the queue's
40
+ * `RedrivePolicy` moves it to the native DLQ (pair the queue with one). Only the explicitly configured
41
+ * `unknownUrn` strategy is read: an `onUnknownUrn` hook never changes it (a hook-only consumer behaves as `fail`).
42
+ *
43
+ * **Poison (ADR-0037 decision 2, ADR-0035).** A body that does not decode into a valid envelope is never given
44
+ * to a handler and never retried. `unknownUrn: "delete"` deletes it; with a `deadLetterQueueUrl` the ADR-0035
45
+ * record (`reason: "poison"`, built with the core's `buildPoisonRecord`) is sent to the DLQ and the message
46
+ * deleted only after the send succeeded; without one it is left to the native mechanism, as above.
37
47
  *
38
48
  * **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop
39
49
  * span linkage) rides as additional String `MessageAttributes` **beside** the contract `bq-*`
@@ -52,6 +62,7 @@ interface SqsMessageAttributeValue {
52
62
  StringValue?: string;
53
63
  }
54
64
  interface SqsMessage {
65
+ MessageId?: string;
55
66
  Body?: string;
56
67
  ReceiptHandle?: string;
57
68
  MessageAttributes?: Record<string, SqsMessageAttributeValue>;
@@ -165,7 +176,7 @@ type BabelHandler = (envelope: Envelope, message: SqsMessage, headers: HeaderCar
165
176
  /** A map of URN → handler. */
166
177
  type BabelHandlers = Record<string, BabelHandler>;
167
178
  /** Options for {@link SqsConsumer}. */
168
- interface SqsConsumerOptions {
179
+ interface SqsConsumerOptions extends RetryBackoffOptions {
169
180
  /**
170
181
  * Called when a message's URN has no handler. Without `unknownUrn` the message is then deleted
171
182
  * (the original behaviour); with `unknownUrn` set it is a notification hook and the strategy
@@ -178,6 +189,9 @@ interface SqsConsumerOptions {
178
189
  * otherwise report and leave for visibility-timeout redelivery, i.e. `fail`). `release` uses
179
190
  * `ChangeMessageVisibility` with {@link unknownUrnReleaseDelay}; `dead_letter` sends the annotated
180
191
  * envelope to {@link deadLetterQueueUrl} then deletes, degrading to `delete` when no DLQ is set.
192
+ *
193
+ * The version gate and the poison route read **only this explicitly configured strategy** (ADR-0037): an
194
+ * `onUnknownUrn` hook, and the `delete` it implies when no strategy is set, never apply to them.
181
195
  */
182
196
  unknownUrn?: string;
183
197
  /** Backoff (seconds) for the `release` unknown-URN strategy (default 0 = redeliver now). */
@@ -189,10 +203,17 @@ interface SqsConsumerOptions {
189
203
  * returning seconds (e.g. an exponential backoff). Clamped to `0…43200`. Default `0`: the message
190
204
  * is visible again immediately. A permanently failing handler therefore retries without pause
191
205
  * until the queue's `RedrivePolicy` (`maxReceiveCount`) moves it to the DLQ — configure one.
206
+ *
207
+ * This is the SQS spelling of the cross-SDK A9 backoff and stays supported as an alias: when it is
208
+ * set it wins over `retryBackoff` / `retryBackoffMultiplier` / `retryBackoffMax` / `retryJitter`
209
+ * (inherited from {@link RetryBackoffOptions}), which compute `d(n) = min(max, base * multiplier^(n-1))`
210
+ * seconds for the 1-based attempt `n = attempts + 1` (the SQS 12 h ceiling still applies on top).
192
211
  */
193
212
  releaseDelay?: number | ((attempts: number) => number);
194
213
  /**
195
- * The cross-language `<queue>.dlq` URL for the `dead_letter` strategy (opt-in; default none). A
214
+ * The cross-language `<queue>-dlq` URL (opt-in; default none; SQS names cannot contain a dot): the `dead_letter` strategy, poison bodies
215
+ * (ADR-0035 record) and version-gated deliveries (the raw body) are written here. Without it a poison or
216
+ * version-gated delivery is left to the queue's native `RedrivePolicy` (ADR-0037). A
196
217
  * `.fifo` DLQ is sent with `MessageGroupId` (the source queue name) and `MessageDeduplicationId`
197
218
  * (`meta.id`).
198
219
  */
@@ -241,6 +262,21 @@ declare class SqsConsumer {
241
262
  /** Poll until `signal` aborts (each poll long-polls, so this does not busy-loop). */
242
263
  run(signal?: AbortSignal): Promise<void>;
243
264
  private handle;
265
+ /**
266
+ * Version-gated delivery (ADR-0037 decision 1). `unknownUrn: "delete"` deletes it; with a DLQ the raw body goes
267
+ * to `<queue>-dlq` and the message is deleted only after the send; otherwise it is reported and left for the
268
+ * queue's native mechanism (never deleted, never released by the SDK). Only the configured strategy is read.
269
+ */
270
+ private settleVersionGated;
271
+ /**
272
+ * Poison body (ADR-0037 decision 2). `unknownUrn: "delete"` deletes it; with a DLQ the ADR-0035 record is sent
273
+ * and the message deleted only after the send; otherwise it is reported and left for the native mechanism.
274
+ */
275
+ private settlePoison;
276
+ /** Send `body` to the DLQ as is (a `.fifo` DLQ gets the source queue as group and a stable dedup id). */
277
+ private sendRaw;
278
+ /** The release delay (seconds) for a failed handler: `releaseDelay` (alias) wins, else the A9 backoff, else 0. */
279
+ private failureDelay;
244
280
  /**
245
281
  * Run a release / dead-letter step; a failure (e.g. `MessageNotInflight`, a missing IAM grant, a
246
282
  * throwing `releaseDelay`) is reported to `onError` and the message is left undeleted, so
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Envelope, HeaderCarrier, IncomingEnvelope, BabelQueueError } from '@babelqueue/core';
1
+ import { Envelope, HeaderCarrier, RetryBackoffOptions, IncomingEnvelope, BabelQueueError } from '@babelqueue/core';
2
2
 
3
3
  /**
4
4
  * Amazon SQS adapter for BabelQueue.
@@ -28,12 +28,22 @@ import { Envelope, HeaderCarrier, IncomingEnvelope, BabelQueueError } from '@bab
28
28
  * authoritative attempt count is `ApproximateReceiveCount`, surfaced to handlers as
29
29
  * `attempts = count − 1`.
30
30
  *
31
- * **Version gate (§3.7).** Before the body is decoded the consumer reads the `bq-schema-version`
31
+ * **Version gate (§3.7, ADR-0037 decision 1).** Before the body is decoded the consumer reads the `bq-schema-version`
32
32
  * message attribute. A missing or blank value (empty or only ASCII whitespace) changes nothing; any
33
33
  * value other than the core's supported `SCHEMA_VERSION` (`"2"`, `"x"`, `"01"`, ...) rejects the
34
- * message undecoded through the same path as a non-conformant envelope — `onError` (with an error
35
- * naming the version, and an empty envelope since nothing was decoded) and the message is left
36
- * undeleted, so it is redelivered and the queue's `RedrivePolicy` moves it to the DLQ.
34
+ * message undecoded (`onError` with an error naming the version and an empty envelope) and settles it on its
35
+ * **first delivery**: never decoded, never given to a handler, `onUnknownUrn` or a retry. Under
36
+ * `unknownUrn: "delete"` it is deleted; with a `deadLetterQueueUrl` the body, byte for byte and without a
37
+ * `dead_letter` block, is sent to the DLQ and deleted only after the send succeeded (a failed send leaves it
38
+ * undeleted: visibility expiry redelivers it and the write is tried again); without a `deadLetterQueueUrl`
39
+ * it is left to the **native** mechanism — not deleted, so visibility expiry redelivers it and the queue's
40
+ * `RedrivePolicy` moves it to the native DLQ (pair the queue with one). Only the explicitly configured
41
+ * `unknownUrn` strategy is read: an `onUnknownUrn` hook never changes it (a hook-only consumer behaves as `fail`).
42
+ *
43
+ * **Poison (ADR-0037 decision 2, ADR-0035).** A body that does not decode into a valid envelope is never given
44
+ * to a handler and never retried. `unknownUrn: "delete"` deletes it; with a `deadLetterQueueUrl` the ADR-0035
45
+ * record (`reason: "poison"`, built with the core's `buildPoisonRecord`) is sent to the DLQ and the message
46
+ * deleted only after the send succeeded; without one it is left to the native mechanism, as above.
37
47
  *
38
48
  * **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop
39
49
  * span linkage) rides as additional String `MessageAttributes` **beside** the contract `bq-*`
@@ -52,6 +62,7 @@ interface SqsMessageAttributeValue {
52
62
  StringValue?: string;
53
63
  }
54
64
  interface SqsMessage {
65
+ MessageId?: string;
55
66
  Body?: string;
56
67
  ReceiptHandle?: string;
57
68
  MessageAttributes?: Record<string, SqsMessageAttributeValue>;
@@ -165,7 +176,7 @@ type BabelHandler = (envelope: Envelope, message: SqsMessage, headers: HeaderCar
165
176
  /** A map of URN → handler. */
166
177
  type BabelHandlers = Record<string, BabelHandler>;
167
178
  /** Options for {@link SqsConsumer}. */
168
- interface SqsConsumerOptions {
179
+ interface SqsConsumerOptions extends RetryBackoffOptions {
169
180
  /**
170
181
  * Called when a message's URN has no handler. Without `unknownUrn` the message is then deleted
171
182
  * (the original behaviour); with `unknownUrn` set it is a notification hook and the strategy
@@ -178,6 +189,9 @@ interface SqsConsumerOptions {
178
189
  * otherwise report and leave for visibility-timeout redelivery, i.e. `fail`). `release` uses
179
190
  * `ChangeMessageVisibility` with {@link unknownUrnReleaseDelay}; `dead_letter` sends the annotated
180
191
  * envelope to {@link deadLetterQueueUrl} then deletes, degrading to `delete` when no DLQ is set.
192
+ *
193
+ * The version gate and the poison route read **only this explicitly configured strategy** (ADR-0037): an
194
+ * `onUnknownUrn` hook, and the `delete` it implies when no strategy is set, never apply to them.
181
195
  */
182
196
  unknownUrn?: string;
183
197
  /** Backoff (seconds) for the `release` unknown-URN strategy (default 0 = redeliver now). */
@@ -189,10 +203,17 @@ interface SqsConsumerOptions {
189
203
  * returning seconds (e.g. an exponential backoff). Clamped to `0…43200`. Default `0`: the message
190
204
  * is visible again immediately. A permanently failing handler therefore retries without pause
191
205
  * until the queue's `RedrivePolicy` (`maxReceiveCount`) moves it to the DLQ — configure one.
206
+ *
207
+ * This is the SQS spelling of the cross-SDK A9 backoff and stays supported as an alias: when it is
208
+ * set it wins over `retryBackoff` / `retryBackoffMultiplier` / `retryBackoffMax` / `retryJitter`
209
+ * (inherited from {@link RetryBackoffOptions}), which compute `d(n) = min(max, base * multiplier^(n-1))`
210
+ * seconds for the 1-based attempt `n = attempts + 1` (the SQS 12 h ceiling still applies on top).
192
211
  */
193
212
  releaseDelay?: number | ((attempts: number) => number);
194
213
  /**
195
- * The cross-language `<queue>.dlq` URL for the `dead_letter` strategy (opt-in; default none). A
214
+ * The cross-language `<queue>-dlq` URL (opt-in; default none; SQS names cannot contain a dot): the `dead_letter` strategy, poison bodies
215
+ * (ADR-0035 record) and version-gated deliveries (the raw body) are written here. Without it a poison or
216
+ * version-gated delivery is left to the queue's native `RedrivePolicy` (ADR-0037). A
196
217
  * `.fifo` DLQ is sent with `MessageGroupId` (the source queue name) and `MessageDeduplicationId`
197
218
  * (`meta.id`).
198
219
  */
@@ -241,6 +262,21 @@ declare class SqsConsumer {
241
262
  /** Poll until `signal` aborts (each poll long-polls, so this does not busy-loop). */
242
263
  run(signal?: AbortSignal): Promise<void>;
243
264
  private handle;
265
+ /**
266
+ * Version-gated delivery (ADR-0037 decision 1). `unknownUrn: "delete"` deletes it; with a DLQ the raw body goes
267
+ * to `<queue>-dlq` and the message is deleted only after the send; otherwise it is reported and left for the
268
+ * queue's native mechanism (never deleted, never released by the SDK). Only the configured strategy is read.
269
+ */
270
+ private settleVersionGated;
271
+ /**
272
+ * Poison body (ADR-0037 decision 2). `unknownUrn: "delete"` deletes it; with a DLQ the ADR-0035 record is sent
273
+ * and the message deleted only after the send; otherwise it is reported and left for the native mechanism.
274
+ */
275
+ private settlePoison;
276
+ /** Send `body` to the DLQ as is (a `.fifo` DLQ gets the source queue as group and a stable dedup id). */
277
+ private sendRaw;
278
+ /** The release delay (seconds) for a failed handler: `releaseDelay` (alias) wins, else the A9 backoff, else 0. */
279
+ private failureDelay;
244
280
  /**
245
281
  * Run a release / dead-letter step; a failure (e.g. `MessageNotInflight`, a missing IAM grant, a
246
282
  * throwing `releaseDelay`) is reported to `onError` and the message is left undeleted, so
package/dist/index.js CHANGED
@@ -1,11 +1,15 @@
1
1
  // src/index.ts
2
+ import { createHash } from "crypto";
2
3
  import {
3
4
  BabelQueueError,
4
5
  EnvelopeCodec,
5
6
  SCHEMA_VERSION,
6
7
  UnknownUrnError,
7
8
  UnknownUrnStrategy,
8
- annotate
9
+ annotate,
10
+ buildPoisonRecord,
11
+ retryBackoffBound,
12
+ retryBackoffDelay
9
13
  } from "@babelqueue/core";
10
14
 
11
15
  // src/headers.ts
@@ -114,6 +118,9 @@ var SqsDeleteError = class extends BabelQueueError {
114
118
  this.cause = cause;
115
119
  }
116
120
  };
121
+ function hasRetryBackoff(options) {
122
+ return options.retryBackoff !== void 0 || options.retryBackoffMultiplier !== void 0 || options.retryBackoffMax !== void 0 || options.retryJitter !== void 0;
123
+ }
117
124
  var SqsConsumer = class {
118
125
  constructor(client, queueUrl, handlers, options = {}) {
119
126
  this.client = client;
@@ -124,7 +131,11 @@ var SqsConsumer = class {
124
131
  if (strategy !== void 0 && !Object.values(UnknownUrnStrategy).includes(strategy)) {
125
132
  throw new BabelQueueError(`Unknown unknownUrn strategy "${strategy}".`);
126
133
  }
127
- const needsRelease = options.releaseDelay != null || strategy === UnknownUrnStrategy.RELEASE;
134
+ retryBackoffBound(options, 1);
135
+ if (options.retryJitter !== void 0 && options.retryJitter !== "none" && options.retryJitter !== "full") {
136
+ throw new BabelQueueError(`retryJitter must be "none" or "full", got ${String(options.retryJitter)}.`);
137
+ }
138
+ const needsRelease = options.releaseDelay != null || hasRetryBackoff(options) || strategy === UnknownUrnStrategy.RELEASE;
128
139
  if (needsRelease && typeof client.changeMessageVisibility !== "function") {
129
140
  throw new BabelQueueError(
130
141
  "SqsConsumer release requires a client with changeMessageVisibility (contract \xA73.5)."
@@ -163,12 +174,11 @@ var SqsConsumer = class {
163
174
  async handle(message) {
164
175
  const rejectedVersion = unsupportedSchemaVersion(message);
165
176
  if (rejectedVersion !== void 0) {
166
- this.options.onError?.(
177
+ await this.settleVersionGated(
178
+ message,
167
179
  new BabelQueueError(
168
180
  `Rejected a message from SQS with unsupported bq-schema-version "${rejectedVersion}" (supported: ${SCHEMA_VERSION}).`
169
- ),
170
- {},
171
- message
181
+ )
172
182
  );
173
183
  return;
174
184
  }
@@ -182,11 +192,7 @@ var SqsConsumer = class {
182
192
  }
183
193
  }
184
194
  if (!EnvelopeCodec.accepts(envelope)) {
185
- this.options.onError?.(
186
- new BabelQueueError("Rejected a non-conformant BabelQueue envelope from SQS."),
187
- envelope,
188
- message
189
- );
195
+ await this.settlePoison(message, envelope, new BabelQueueError("Rejected a non-conformant BabelQueue envelope from SQS."));
190
196
  return;
191
197
  }
192
198
  const urn = EnvelopeCodec.urn(envelope);
@@ -200,14 +206,73 @@ var SqsConsumer = class {
200
206
  } catch (error) {
201
207
  this.options.onError?.(error, envelope, message);
202
208
  await this.guard(envelope, message, async () => {
203
- const delay = this.options.releaseDelay ?? 0;
204
209
  const attempts = typeof envelope.attempts === "number" ? envelope.attempts : 0;
205
- await this.release(message, typeof delay === "function" ? delay(attempts) : delay);
210
+ await this.release(message, this.failureDelay(attempts));
206
211
  });
207
212
  return;
208
213
  }
209
214
  await this.guard(envelope, message, () => this.deleteHandled(message));
210
215
  }
216
+ /**
217
+ * Version-gated delivery (ADR-0037 decision 1). `unknownUrn: "delete"` deletes it; with a DLQ the raw body goes
218
+ * to `<queue>-dlq` and the message is deleted only after the send; otherwise it is reported and left for the
219
+ * queue's native mechanism (never deleted, never released by the SDK). Only the configured strategy is read.
220
+ */
221
+ async settleVersionGated(message, gateError) {
222
+ this.options.onError?.(gateError, {}, message);
223
+ const dlq = this.options.deadLetterQueueUrl;
224
+ if (this.options.unknownUrn === UnknownUrnStrategy.DELETE) {
225
+ await this.guard({}, message, () => this.deleteHandled(message));
226
+ return;
227
+ }
228
+ if (!dlq) return;
229
+ await this.guard({}, message, async () => {
230
+ await this.sendRaw(dlq, message, message.Body ?? "");
231
+ await this.deleteHandled(message);
232
+ });
233
+ }
234
+ /**
235
+ * Poison body (ADR-0037 decision 2). `unknownUrn: "delete"` deletes it; with a DLQ the ADR-0035 record is sent
236
+ * and the message deleted only after the send; otherwise it is reported and left for the native mechanism.
237
+ */
238
+ async settlePoison(message, envelope, error) {
239
+ this.options.onError?.(error, envelope, message);
240
+ const dlq = this.options.deadLetterQueueUrl;
241
+ if (this.options.unknownUrn === UnknownUrnStrategy.DELETE) {
242
+ await this.guard(envelope, message, () => this.deleteHandled(message));
243
+ return;
244
+ }
245
+ if (!dlq) return;
246
+ await this.guard(envelope, message, async () => {
247
+ const source = queueNameFromUrl(this.queueUrl);
248
+ const count = Number.parseInt(message.Attributes?.["ApproximateReceiveCount"] ?? "", 10);
249
+ const delivered = Number.isFinite(count) && count > 1 ? count - 1 : 0;
250
+ const deadLetter = annotate({ attempts: delivered }, "poison", source, {
251
+ attempts: delivered,
252
+ error: error.message,
253
+ exception: error.name
254
+ }).dead_letter;
255
+ const record = buildPoisonRecord(Buffer.from(message.Body ?? "", "utf8"), JSON.stringify(deadLetter));
256
+ await this.sendRaw(dlq, message, record);
257
+ await this.deleteHandled(message);
258
+ });
259
+ }
260
+ /** Send `body` to the DLQ as is (a `.fifo` DLQ gets the source queue as group and a stable dedup id). */
261
+ async sendRaw(dlq, message, body) {
262
+ const input = { QueueUrl: dlq, MessageBody: body };
263
+ if (dlq.endsWith(".fifo")) {
264
+ input.MessageGroupId = queueNameFromUrl(this.queueUrl);
265
+ input.MessageDeduplicationId = message.MessageId ?? createHash("sha256").update(message.Body ?? "").digest("hex");
266
+ }
267
+ await this.client.sendMessage(input);
268
+ }
269
+ /** The release delay (seconds) for a failed handler: `releaseDelay` (alias) wins, else the A9 backoff, else 0. */
270
+ failureDelay(attempts) {
271
+ const delay = this.options.releaseDelay;
272
+ if (delay != null) return typeof delay === "function" ? delay(attempts) : delay;
273
+ if (!hasRetryBackoff(this.options)) return 0;
274
+ return retryBackoffDelay(this.options, Math.max(0, Math.floor(attempts)) + 1);
275
+ }
211
276
  /**
212
277
  * Run a release / dead-letter step; a failure (e.g. `MessageNotInflight`, a missing IAM grant, a
213
278
  * throwing `releaseDelay`) is reported to `onError` and the message is left undeleted, so
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/headers.ts"],"sourcesContent":["/**\n * Amazon SQS adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed **consumer** over Amazon SQS,\n * so an SQS-based Node service speaks the same contract (envelope shape, URN\n * identity, trace propagation) as the PHP/Laravel, Python, Go, Java and .NET SDKs.\n *\n * import { SQS } from \"@aws-sdk/client-sqs\";\n * import { SqsPublisher, SqsConsumer } from \"@babelqueue/sqs\";\n *\n * const sqs = new SQS({ region: \"eu-central-1\" });\n * const url = \"https://sqs.eu-central-1.amazonaws.com/123456789012/orders\";\n *\n * await new SqsPublisher(sqs, url).publish(\"urn:babel:orders:created\", { order_id: 1042 });\n *\n * const consumer = new SqsConsumer(sqs, url, {\n * \"urn:babel:orders:created\": async (env) => { ... },\n * });\n * await consumer.run();\n *\n * This implements §3 of the broker-bindings contract: the canonical envelope is the\n * message body, projected onto native SQS `MessageAttributes`. The envelope is\n * unchanged (`schema_version` stays 1); SQS is purely additive. Retry is SQS-native\n * (a failed handler's message is released via `ChangeMessageVisibility` after\n * `releaseDelay`, default 0 s — pair the queue with a `RedrivePolicy`); the\n * authoritative attempt count is `ApproximateReceiveCount`, surfaced to handlers as\n * `attempts = count − 1`.\n *\n * **Version gate (§3.7).** Before the body is decoded the consumer reads the `bq-schema-version`\n * message attribute. A missing or blank value (empty or only ASCII whitespace) changes nothing; any\n * value other than the core's supported `SCHEMA_VERSION` (`\"2\"`, `\"x\"`, `\"01\"`, ...) rejects the\n * message undecoded through the same path as a non-conformant envelope — `onError` (with an error\n * naming the version, and an empty envelope since nothing was decoded) and the message is left\n * undeleted, so it is redelivered and the queue's `RedrivePolicy` moves it to the DLQ.\n *\n * **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop\n * span linkage) rides as additional String `MessageAttributes` **beside** the contract `bq-*`\n * attributes where `bq-trace-id` already lives — the contract attributes win a key collision, and\n * the merged set is bounded by SQS's **10-attribute limit** (contract attributes are seeded first,\n * so a rider only lands while headroom remains). On consume the inbound `MessageAttributes` are\n * surfaced as a `Record<string,string>` so the core's `otel` extract sees the `traceparent` and\n * links the consumer span as a child. A header-less publish is byte-identical. This mirrors the Go\n * `/sqs` and PHP `SqsTransport` wiring. GR-1: the wire envelope body is never touched.\n */\n\nimport {\n BabelQueueError,\n EnvelopeCodec,\n SCHEMA_VERSION,\n UnknownUrnError,\n UnknownUrnStrategy,\n annotate,\n} from \"@babelqueue/core\";\nimport type { Envelope, HeaderCarrier, IncomingEnvelope } from \"@babelqueue/core\";\n\nimport { sanitizeHeaders } from \"./headers.js\";\n\n/** SQS allows at most 10 user message attributes per message. */\nconst MAX_ATTRIBUTES = 10;\n\n/** The SQS ceiling for a `VisibilityTimeout` (12 hours, in seconds). */\nexport const MAX_VISIBILITY_TIMEOUT = 43_200;\n\n// --- Minimal SQS shapes (a structural subset of @aws-sdk/client-sqs) -----------\n\nexport interface SqsMessageAttributeValue {\n DataType: string;\n StringValue?: string;\n}\n\nexport interface SqsMessage {\n Body?: string;\n ReceiptHandle?: string;\n MessageAttributes?: Record<string, SqsMessageAttributeValue>;\n Attributes?: Record<string, string>;\n}\n\nexport interface SendMessageInput {\n QueueUrl: string;\n MessageBody: string;\n MessageAttributes?: Record<string, SqsMessageAttributeValue>;\n MessageGroupId?: string;\n MessageDeduplicationId?: string;\n}\n\nexport interface ReceiveMessageInput {\n QueueUrl: string;\n MaxNumberOfMessages?: number;\n WaitTimeSeconds?: number;\n VisibilityTimeout?: number;\n MessageAttributeNames?: string[];\n AttributeNames?: string[];\n}\n\n/**\n * The subset of the AWS SQS client this adapter calls. The aggregated `SQS` class\n * from `@aws-sdk/client-sqs` satisfies it structurally; a fake satisfies it in tests.\n */\nexport interface SqsApi {\n sendMessage(input: SendMessageInput): Promise<{ MessageId?: string } | unknown>;\n receiveMessage(input: ReceiveMessageInput): Promise<{ Messages?: SqsMessage[] }>;\n deleteMessage(input: { QueueUrl: string; ReceiptHandle: string }): Promise<unknown>;\n /**\n * The §3.5 release primitive. Optional so existing structural clients keep compiling; it is\n * required (checked at construction) when a consumer sets `releaseDelay` or the `release`\n * unknown-URN strategy explicitly. Without it the default handler-failure release degrades to\n * leaving the message for visibility-timeout redelivery. The aggregated `SQS` class from\n * `@aws-sdk/client-sqs` provides it.\n */\n changeMessageVisibility?(input: ChangeMessageVisibilityInput): Promise<unknown>;\n}\n\nexport interface ChangeMessageVisibilityInput {\n QueueUrl: string;\n ReceiptHandle: string;\n VisibilityTimeout: number;\n}\n\n// --- Attribute projection (contract §3.2) --------------------------------------\n\nconst str = (value: unknown): SqsMessageAttributeValue => ({\n DataType: \"String\",\n StringValue: String(value),\n});\nconst num = (value: unknown): SqsMessageAttributeValue => ({\n DataType: \"Number\",\n StringValue: String(value),\n});\n\n/**\n * Project the envelope's contract fields onto native SQS `MessageAttributes` — a\n * redundant, routable view of the body (the body stays authoritative).\n */\nexport function toMessageAttributes(envelope: Envelope): Record<string, SqsMessageAttributeValue> {\n const attrs: Record<string, SqsMessageAttributeValue> = {};\n if (envelope.job) attrs[\"bq-job\"] = str(envelope.job);\n if (envelope.trace_id) attrs[\"bq-trace-id\"] = str(envelope.trace_id);\n if (envelope.meta.id) attrs[\"bq-message-id\"] = str(envelope.meta.id);\n if (envelope.meta.schema_version != null) {\n attrs[\"bq-schema-version\"] = num(envelope.meta.schema_version);\n }\n if (envelope.meta.lang) attrs[\"bq-source-lang\"] = str(envelope.meta.lang);\n if (envelope.meta.created_at != null) {\n attrs[\"bq-created-at\"] = num(envelope.meta.created_at);\n }\n return attrs;\n}\n\n/**\n * Overlay the out-of-band `headers` onto the contract attribute projection as String\n * `MessageAttributes`, without overwriting an existing `bq-*` attribute (the contract wins a key\n * collision) and skipping blanks. Keys are merged in sorted order and the merge stops at the\n * 10-attribute SQS ceiling, so unbounded riders can never push the message past the limit (SQS\n * rejects the whole send otherwise) — the contract attributes are always preserved first. Mirrors\n * Go's `mergeAttributes` / PHP's `SqsTransport::attributes`.\n */\nexport function mergeAttributes(\n base: Record<string, SqsMessageAttributeValue>,\n headers: HeaderCarrier | null | undefined,\n): Record<string, SqsMessageAttributeValue> {\n const clean = sanitizeHeaders(headers);\n for (const key of Object.keys(clean).sort()) {\n if (key in base) continue; // never clobber a contract bq-* attribute\n if (Object.keys(base).length >= MAX_ATTRIBUTES) break; // respect the SQS 10-attribute cap\n base[key] = str(clean[key]);\n }\n return base;\n}\n\n/**\n * Map inbound SQS `MessageAttributes` onto a flat {@link HeaderCarrier} (the consume-side\n * counterpart of {@link mergeAttributes}), reading each attribute's `StringValue`. Returns an empty\n * object when there are none. Both the contract `bq-*` attributes and any out-of-band rider (e.g.\n * `traceparent`) surface — the core's `otel` extract reads only the keys it knows.\n */\nexport function headersOf(message: SqsMessage): HeaderCarrier {\n const attrs = message.MessageAttributes;\n const out: HeaderCarrier = {};\n if (!attrs) return out;\n for (const key of Object.keys(attrs)) {\n const value = attrs[key]?.StringValue;\n if (value != null && value !== \"\") out[key] = value;\n }\n return out;\n}\n\n/** Blank = empty or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) — not `String.prototype.trim()`'s wider set. */\nconst BLANK_ASCII = /^[ \\t\\n\\v\\f\\r]*$/;\n\n/**\n * The `bq-schema-version` message attribute when it is present, non-blank and not the supported\n * version (compared against the core's `SCHEMA_VERSION`, exact string match: `\"01\"`, `\" 1\"`, `\"1.0\"`\n * are rejected). Blank means the empty string or only ASCII whitespace (the same set in every SDK,\n * GR-5); NBSP, U+0085, U+FEFF, U+3000 and the like are not blank. `undefined` when the message may\n * be decoded.\n */\nfunction unsupportedSchemaVersion(message: SqsMessage): string | undefined {\n const raw: unknown = message.MessageAttributes?.[\"bq-schema-version\"]?.StringValue;\n if (typeof raw !== \"string\" || BLANK_ASCII.test(raw)) return undefined;\n return raw === String(SCHEMA_VERSION) ? undefined : raw;\n}\n\nfunction queueNameFromUrl(queueUrl: string): string {\n const segments = queueUrl.split(\"/\").filter(Boolean);\n return segments[segments.length - 1] ?? \"default\";\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link SqsPublisher}. */\nexport interface SqsPublisherOptions {\n /** Treat the queue as FIFO: set `MessageGroupId` and (unless content dedup) `MessageDeduplicationId`. */\n fifo?: boolean;\n /** FIFO ordering group (default: the queue name from the URL). */\n messageGroupId?: string;\n /** Use the queue's content-based dedup instead of `meta.id` as the dedup id. */\n contentDedup?: boolean;\n}\n\n/** Options for {@link SqsPublisher.publish}. */\nexport interface PublishOptions {\n /** Reuse an existing trace id (trace continuation). */\n traceId?: string;\n /**\n * Out-of-band transport headers carried as String `MessageAttributes` beside the contract `bq-*`\n * attributes (ADR-0028) — e.g. a W3C `traceparent` written by `@babelqueue/core/otel`'s `publish`.\n * The contract attributes win a key collision and the merged set is capped at SQS's 10-attribute\n * limit; an empty/omitted carrier leaves the publish unchanged.\n */\n headers?: HeaderCarrier;\n}\n\n/** Sends canonical-envelope messages to one SQS queue with the §3 attribute projection. */\nexport class SqsPublisher {\n constructor(\n private readonly client: SqsApi,\n private readonly queueUrl: string,\n private readonly options: SqsPublisherOptions = {},\n ) {}\n\n /**\n * Build the canonical envelope for `(urn, data)`, send it as the message body with\n * the projected `MessageAttributes`, and return the message id (`meta.id`).\n */\n async publish(\n urn: string,\n data: Record<string, unknown>,\n options: PublishOptions = {},\n ): Promise<string> {\n const envelope = EnvelopeCodec.make(urn, data, {\n queue: queueNameFromUrl(this.queueUrl),\n traceId: options.traceId,\n });\n const input: SendMessageInput = {\n QueueUrl: this.queueUrl,\n MessageBody: EnvelopeCodec.encode(envelope),\n MessageAttributes: mergeAttributes(toMessageAttributes(envelope), options.headers),\n };\n if (this.options.fifo) {\n input.MessageGroupId = this.options.messageGroupId ?? queueNameFromUrl(this.queueUrl);\n if (!this.options.contentDedup) {\n input.MessageDeduplicationId = envelope.meta.id;\n }\n }\n await this.client.sendMessage(input);\n return envelope.meta.id;\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/**\n * A URN handler. Receives the validated envelope, the raw SQS message, and the out-of-band\n * {@link HeaderCarrier} read from the message's `MessageAttributes` (empty when there are none).\n * Pass `headers` to `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of\n * the producer span (ADR-0028).\n */\nexport type BabelHandler = (envelope: Envelope, message: SqsMessage, headers: HeaderCarrier) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link SqsConsumer}. */\nexport interface SqsConsumerOptions {\n /**\n * Called when a message's URN has no handler. Without `unknownUrn` the message is then deleted\n * (the original behaviour); with `unknownUrn` set it is a notification hook and the strategy\n * decides the message's fate.\n */\n onUnknownUrn?: (envelope: IncomingEnvelope, message: SqsMessage) => unknown | Promise<unknown>;\n /**\n * Unknown-URN strategy per contract §3.5 — `fail` | `delete` | `release` | `dead_letter`\n * ({@link UnknownUrnStrategy}). Omitted keeps the original behaviour (`onUnknownUrn` → delete,\n * otherwise report and leave for visibility-timeout redelivery, i.e. `fail`). `release` uses\n * `ChangeMessageVisibility` with {@link unknownUrnReleaseDelay}; `dead_letter` sends the annotated\n * envelope to {@link deadLetterQueueUrl} then deletes, degrading to `delete` when no DLQ is set.\n */\n unknownUrn?: string;\n /** Backoff (seconds) for the `release` unknown-URN strategy (default 0 = redeliver now). */\n unknownUrnReleaseDelay?: number;\n /**\n * Backoff before a failed message is redelivered. A failed handler always releases the message\n * via `ChangeMessageVisibility(ReceiptHandle, VisibilityTimeout)` (contract §3.5); this sets the\n * `VisibilityTimeout` — a number of seconds, or a function of the (reconciled) `attempts`\n * returning seconds (e.g. an exponential backoff). Clamped to `0…43200`. Default `0`: the message\n * is visible again immediately. A permanently failing handler therefore retries without pause\n * until the queue's `RedrivePolicy` (`maxReceiveCount`) moves it to the DLQ — configure one.\n */\n releaseDelay?: number | ((attempts: number) => number);\n /**\n * The cross-language `<queue>.dlq` URL for the `dead_letter` strategy (opt-in; default none). A\n * `.fifo` DLQ is sent with `MessageGroupId` (the source queue name) and `MessageDeduplicationId`\n * (`meta.id`).\n */\n deadLetterQueueUrl?: string | null;\n /**\n * Called for a non-conformant envelope, an unmapped URN (no `onUnknownUrn`), a throwing handler,\n * a throwing `onUnknownUrn` under an `unknownUrn` strategy, a failed release / dead-letter send, or\n * a failed delete after successful processing (an {@link SqsDeleteError} wrapping the broker\n * error; never released). The message is then left for visibility-timeout redelivery. The loop\n * never stops on these.\n */\n onError?: (error: unknown, envelope: IncomingEnvelope, message: SqsMessage) => void;\n /** Long-poll wait seconds (default 20). */\n waitTimeSeconds?: number;\n /** Reservation window applied on receive (seconds). */\n visibilityTimeout?: number;\n /** Max messages per receive (default 10). */\n maxMessages?: number;\n}\n\n/**\n * Reported via `onError` when `DeleteMessage` fails for a message whose processing already succeeded\n * (a handler returned normally, or the unknown-URN strategy chose `delete` / `dead_letter`). The\n * broker error is `cause`. Distinct from a handler failure: the message is NOT released, so it is\n * redelivered only after its visibility timeout expires (at-least-once; dedupe on `meta.id` with the\n * idempotency helper if the side effect must not repeat).\n */\nexport class SqsDeleteError extends BabelQueueError {\n constructor(cause: unknown) {\n super(\"Failed to delete a processed SQS message; it will be redelivered after its visibility timeout.\");\n this.name = \"SqsDeleteError\";\n this.cause = cause;\n }\n}\n\n/**\n * Polls an SQS queue, decodes + validates each message, routes it to the handler\n * registered for its URN, and deletes it on success. A throwing handler's message is released via\n * `ChangeMessageVisibility` after `releaseDelay` (default 0 s, contract §3.5); if the release itself\n * fails the message is left for visibility-timeout redelivery (at-least-once);\n * `attempts` is reconciled to `ApproximateReceiveCount − 1` for the handler. An unmapped URN\n * follows the `unknownUrn` strategy (`fail` | `delete` | `release` | `dead_letter`).\n */\nexport class SqsConsumer {\n constructor(\n private readonly client: SqsApi,\n private readonly queueUrl: string,\n private readonly handlers: BabelHandlers,\n private readonly options: SqsConsumerOptions = {},\n ) {\n const strategy = options.unknownUrn;\n if (strategy !== undefined && !(Object.values(UnknownUrnStrategy) as string[]).includes(strategy)) {\n throw new BabelQueueError(`Unknown unknownUrn strategy \"${strategy}\".`);\n }\n const needsRelease = options.releaseDelay != null || strategy === UnknownUrnStrategy.RELEASE;\n if (needsRelease && typeof client.changeMessageVisibility !== \"function\") {\n throw new BabelQueueError(\n \"SqsConsumer release requires a client with changeMessageVisibility (contract §3.5).\",\n );\n }\n }\n\n /** Receive one batch, route each message, delete the ones handled. Returns the batch size. */\n async poll(): Promise<number> {\n const input: ReceiveMessageInput = {\n QueueUrl: this.queueUrl,\n MaxNumberOfMessages: this.options.maxMessages ?? 10,\n WaitTimeSeconds: this.options.waitTimeSeconds ?? 20,\n MessageAttributeNames: [\"All\"],\n AttributeNames: [\"ApproximateReceiveCount\"],\n };\n if (this.options.visibilityTimeout != null) {\n input.VisibilityTimeout = this.options.visibilityTimeout;\n }\n const result = await this.client.receiveMessage(input);\n const messages = result.Messages ?? [];\n for (const message of messages) {\n await this.handle(message);\n }\n return messages.length;\n }\n\n /** Poll until `signal` aborts (each poll long-polls, so this does not busy-loop). */\n async run(signal?: AbortSignal): Promise<void> {\n while (signal?.aborted !== true) {\n await this.poll();\n }\n }\n\n private async handle(message: SqsMessage): Promise<void> {\n // §3.7: gate on the bq-schema-version attribute before the body is decoded. The message is\n // not deleted, so SQS redelivers it and the queue's RedrivePolicy moves it to the DLQ.\n const rejectedVersion = unsupportedSchemaVersion(message);\n if (rejectedVersion !== undefined) {\n this.options.onError?.(\n new BabelQueueError(\n `Rejected a message from SQS with unsupported bq-schema-version \"${rejectedVersion}\" (supported: ${SCHEMA_VERSION}).`,\n ),\n {},\n message,\n );\n return;\n }\n\n const envelope = EnvelopeCodec.decode(message.Body ?? \"\");\n\n const receiveCount = message.Attributes?.[\"ApproximateReceiveCount\"];\n if (receiveCount !== undefined) {\n const native = Number.parseInt(receiveCount, 10) - 1;\n const current = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n if (Number.isFinite(native) && native > current) {\n envelope.attempts = native;\n }\n }\n\n if (!EnvelopeCodec.accepts(envelope)) {\n this.options.onError?.(\n new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from SQS.\"),\n envelope,\n message,\n );\n return;\n }\n\n const urn = EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n await this.unknown(urn, envelope as Envelope, message);\n return;\n }\n\n try {\n await handler(envelope, message, headersOf(message));\n } catch (error) {\n this.options.onError?.(error, envelope, message);\n await this.guard(envelope, message, async () => {\n const delay = this.options.releaseDelay ?? 0;\n const attempts = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n await this.release(message, typeof delay === \"function\" ? delay(attempts) : delay);\n });\n return;\n }\n // Outside the handler's try: a failed delete is not a handler failure. It is reported as an\n // SqsDeleteError and the message is NOT released — releasing at the (default 0 s) backoff would\n // redeliver an already-processed message immediately; it returns on visibility expiry instead.\n await this.guard(envelope, message, () => this.deleteHandled(message));\n }\n\n /**\n * Run a release / dead-letter step; a failure (e.g. `MessageNotInflight`, a missing IAM grant, a\n * throwing `releaseDelay`) is reported to `onError` and the message is left undeleted, so\n * visibility expiry still redelivers it. Never lets the error stop the consume loop.\n */\n private async guard(envelope: IncomingEnvelope, message: SqsMessage, step: () => Promise<void>): Promise<void> {\n try {\n await step();\n } catch (error) {\n this.options.onError?.(error, envelope, message);\n }\n }\n\n /** Apply the unknown-URN strategy (contract §3.5). */\n private async unknown(urn: string, envelope: Envelope, message: SqsMessage): Promise<void> {\n const strategy = this.options.unknownUrn;\n if (strategy === undefined) {\n if (this.options.onUnknownUrn) {\n await this.options.onUnknownUrn(envelope, message);\n await this.delete(message);\n } else {\n this.options.onError?.(new UnknownUrnError(urn), envelope, message);\n }\n return;\n }\n\n await this.guard(envelope, message, async () => {\n await this.options.onUnknownUrn?.(envelope, message);\n });\n switch (strategy) {\n case UnknownUrnStrategy.DELETE:\n await this.guard(envelope, message, () => this.deleteHandled(message));\n return;\n case UnknownUrnStrategy.RELEASE:\n await this.guard(envelope, message, () => this.release(message, this.options.unknownUrnReleaseDelay ?? 0));\n return;\n case UnknownUrnStrategy.DEAD_LETTER:\n await this.guard(envelope, message, async () => {\n await this.deadLetter(envelope, \"unknown_urn\");\n await this.deleteHandled(message);\n });\n return;\n default:\n // FAIL: surface and do NOT delete — visibility expiry redelivers, native redrive quarantines.\n this.options.onError?.(new UnknownUrnError(urn), envelope, message);\n }\n }\n\n /** Make the message visible again after `seconds` via `ChangeMessageVisibility`; never deletes. */\n private async release(message: SqsMessage, seconds: number): Promise<void> {\n if (!message.ReceiptHandle || !this.client.changeMessageVisibility) return;\n const timeout = Number.isFinite(seconds)\n ? Math.min(MAX_VISIBILITY_TIMEOUT, Math.max(0, Math.floor(seconds)))\n : 0;\n await this.client.changeMessageVisibility({\n QueueUrl: this.queueUrl,\n ReceiptHandle: message.ReceiptHandle,\n VisibilityTimeout: timeout,\n });\n }\n\n /** Send the annotated envelope to the opt-in DLQ; a no-op (→ plain delete) when none is set. */\n private async deadLetter(envelope: Envelope, reason: string): Promise<void> {\n const dlq = this.options.deadLetterQueueUrl;\n if (!dlq) return;\n const source = envelope.meta?.queue ?? queueNameFromUrl(this.queueUrl);\n const annotated = annotate(envelope, reason, source, {\n attempts: envelope.attempts ?? 0,\n });\n const input: SendMessageInput = {\n QueueUrl: dlq,\n MessageBody: EnvelopeCodec.encode(annotated),\n MessageAttributes: toMessageAttributes(annotated),\n };\n if (dlq.endsWith(\".fifo\")) {\n // A FIFO DLQ (`<queue>.dlq.fifo`) rejects a send without a group; dedup on meta.id (§3.2).\n input.MessageGroupId = source;\n if (annotated.meta?.id) input.MessageDeduplicationId = annotated.meta.id;\n }\n await this.client.sendMessage(input);\n }\n\n /** Delete a message whose processing already succeeded; a broker failure becomes an SqsDeleteError. */\n private async deleteHandled(message: SqsMessage): Promise<void> {\n try {\n await this.delete(message);\n } catch (error) {\n throw new SqsDeleteError(error);\n }\n }\n\n private async delete(message: SqsMessage): Promise<void> {\n if (!message.ReceiptHandle) return;\n await this.client.deleteMessage({\n QueueUrl: this.queueUrl,\n ReceiptHandle: message.ReceiptHandle,\n });\n }\n}\n","/**\n * Pure, dependency-free helpers for the out-of-band transport-header seam (ADR-0028) — the Node\n * adapter mirror of Go's `sanitizeHeaders` and PHP's `Support\\Headers`. They fold an injected\n * {@link HeaderCarrier} (e.g. a W3C `traceparent`) onto a transport's metadata channel **without\n * clobbering** the contract headers it already carries.\n *\n * Headers ride beside the frozen wire envelope (GR-1), never inside it.\n */\n\nimport type { HeaderCarrier } from \"@babelqueue/core\";\n\n/**\n * Copy `headers`, dropping blank keys and blank values. Returns an empty object when nothing\n * survives, so a caller can treat the result as \"no headers\". A nullish input yields an empty\n * object.\n */\nexport function sanitizeHeaders(headers: HeaderCarrier | null | undefined): HeaderCarrier {\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headers[key];\n if (key === \"\" || value == null || value === \"\") continue;\n out[key] = value;\n }\n return out;\n}\n"],"mappings":";AA6CA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;;;ACpCA,SAAS,gBAAgB,SAA0D;AACxF,QAAM,MAAqB,CAAC;AAC5B,MAAI,CAAC,QAAS,QAAO;AACrB,aAAW,OAAO,OAAO,KAAK,OAAO,GAAG;AACtC,UAAM,QAAQ,QAAQ,GAAG;AACzB,QAAI,QAAQ,MAAM,SAAS,QAAQ,UAAU,GAAI;AACjD,QAAI,GAAG,IAAI;AAAA,EACb;AACA,SAAO;AACT;;;ADiCA,IAAM,iBAAiB;AAGhB,IAAM,yBAAyB;AA2DtC,IAAM,MAAM,CAAC,WAA8C;AAAA,EACzD,UAAU;AAAA,EACV,aAAa,OAAO,KAAK;AAC3B;AACA,IAAM,MAAM,CAAC,WAA8C;AAAA,EACzD,UAAU;AAAA,EACV,aAAa,OAAO,KAAK;AAC3B;AAMO,SAAS,oBAAoB,UAA8D;AAChG,QAAM,QAAkD,CAAC;AACzD,MAAI,SAAS,IAAK,OAAM,QAAQ,IAAI,IAAI,SAAS,GAAG;AACpD,MAAI,SAAS,SAAU,OAAM,aAAa,IAAI,IAAI,SAAS,QAAQ;AACnE,MAAI,SAAS,KAAK,GAAI,OAAM,eAAe,IAAI,IAAI,SAAS,KAAK,EAAE;AACnE,MAAI,SAAS,KAAK,kBAAkB,MAAM;AACxC,UAAM,mBAAmB,IAAI,IAAI,SAAS,KAAK,cAAc;AAAA,EAC/D;AACA,MAAI,SAAS,KAAK,KAAM,OAAM,gBAAgB,IAAI,IAAI,SAAS,KAAK,IAAI;AACxE,MAAI,SAAS,KAAK,cAAc,MAAM;AACpC,UAAM,eAAe,IAAI,IAAI,SAAS,KAAK,UAAU;AAAA,EACvD;AACA,SAAO;AACT;AAUO,SAAS,gBACd,MACA,SAC0C;AAC1C,QAAM,QAAQ,gBAAgB,OAAO;AACrC,aAAW,OAAO,OAAO,KAAK,KAAK,EAAE,KAAK,GAAG;AAC3C,QAAI,OAAO,KAAM;AACjB,QAAI,OAAO,KAAK,IAAI,EAAE,UAAU,eAAgB;AAChD,SAAK,GAAG,IAAI,IAAI,MAAM,GAAG,CAAC;AAAA,EAC5B;AACA,SAAO;AACT;AAQO,SAAS,UAAU,SAAoC;AAC5D,QAAM,QAAQ,QAAQ;AACtB,QAAM,MAAqB,CAAC;AAC5B,MAAI,CAAC,MAAO,QAAO;AACnB,aAAW,OAAO,OAAO,KAAK,KAAK,GAAG;AACpC,UAAM,QAAQ,MAAM,GAAG,GAAG;AAC1B,QAAI,SAAS,QAAQ,UAAU,GAAI,KAAI,GAAG,IAAI;AAAA,EAChD;AACA,SAAO;AACT;AAGA,IAAM,cAAc;AASpB,SAAS,yBAAyB,SAAyC;AACzE,QAAM,MAAe,QAAQ,oBAAoB,mBAAmB,GAAG;AACvE,MAAI,OAAO,QAAQ,YAAY,YAAY,KAAK,GAAG,EAAG,QAAO;AAC7D,SAAO,QAAQ,OAAO,cAAc,IAAI,SAAY;AACtD;AAEA,SAAS,iBAAiB,UAA0B;AAClD,QAAM,WAAW,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO;AACnD,SAAO,SAAS,SAAS,SAAS,CAAC,KAAK;AAC1C;AA4BO,IAAM,eAAN,MAAmB;AAAA,EACxB,YACmB,QACA,UACA,UAA+B,CAAC,GACjD;AAHiB;AACA;AACA;AAAA,EAChB;AAAA,EAHgB;AAAA,EACA;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOnB,MAAM,QACJ,KACA,MACA,UAA0B,CAAC,GACV;AACjB,UAAM,WAAW,cAAc,KAAK,KAAK,MAAM;AAAA,MAC7C,OAAO,iBAAiB,KAAK,QAAQ;AAAA,MACrC,SAAS,QAAQ;AAAA,IACnB,CAAC;AACD,UAAM,QAA0B;AAAA,MAC9B,UAAU,KAAK;AAAA,MACf,aAAa,cAAc,OAAO,QAAQ;AAAA,MAC1C,mBAAmB,gBAAgB,oBAAoB,QAAQ,GAAG,QAAQ,OAAO;AAAA,IACnF;AACA,QAAI,KAAK,QAAQ,MAAM;AACrB,YAAM,iBAAiB,KAAK,QAAQ,kBAAkB,iBAAiB,KAAK,QAAQ;AACpF,UAAI,CAAC,KAAK,QAAQ,cAAc;AAC9B,cAAM,yBAAyB,SAAS,KAAK;AAAA,MAC/C;AAAA,IACF;AACA,UAAM,KAAK,OAAO,YAAY,KAAK;AACnC,WAAO,SAAS,KAAK;AAAA,EACvB;AACF;AAuEO,IAAM,iBAAN,cAA6B,gBAAgB;AAAA,EAClD,YAAY,OAAgB;AAC1B,UAAM,gGAAgG;AACtG,SAAK,OAAO;AACZ,SAAK,QAAQ;AAAA,EACf;AACF;AAUO,IAAM,cAAN,MAAkB;AAAA,EACvB,YACmB,QACA,UACA,UACA,UAA8B,CAAC,GAChD;AAJiB;AACA;AACA;AACA;AAEjB,UAAM,WAAW,QAAQ;AACzB,QAAI,aAAa,UAAa,CAAE,OAAO,OAAO,kBAAkB,EAAe,SAAS,QAAQ,GAAG;AACjG,YAAM,IAAI,gBAAgB,gCAAgC,QAAQ,IAAI;AAAA,IACxE;AACA,UAAM,eAAe,QAAQ,gBAAgB,QAAQ,aAAa,mBAAmB;AACrF,QAAI,gBAAgB,OAAO,OAAO,4BAA4B,YAAY;AACxE,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EAfmB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAenB,MAAM,OAAwB;AAC5B,UAAM,QAA6B;AAAA,MACjC,UAAU,KAAK;AAAA,MACf,qBAAqB,KAAK,QAAQ,eAAe;AAAA,MACjD,iBAAiB,KAAK,QAAQ,mBAAmB;AAAA,MACjD,uBAAuB,CAAC,KAAK;AAAA,MAC7B,gBAAgB,CAAC,yBAAyB;AAAA,IAC5C;AACA,QAAI,KAAK,QAAQ,qBAAqB,MAAM;AAC1C,YAAM,oBAAoB,KAAK,QAAQ;AAAA,IACzC;AACA,UAAM,SAAS,MAAM,KAAK,OAAO,eAAe,KAAK;AACrD,UAAM,WAAW,OAAO,YAAY,CAAC;AACrC,eAAW,WAAW,UAAU;AAC9B,YAAM,KAAK,OAAO,OAAO;AAAA,IAC3B;AACA,WAAO,SAAS;AAAA,EAClB;AAAA;AAAA,EAGA,MAAM,IAAI,QAAqC;AAC7C,WAAO,QAAQ,YAAY,MAAM;AAC/B,YAAM,KAAK,KAAK;AAAA,IAClB;AAAA,EACF;AAAA,EAEA,MAAc,OAAO,SAAoC;AAGvD,UAAM,kBAAkB,yBAAyB,OAAO;AACxD,QAAI,oBAAoB,QAAW;AACjC,WAAK,QAAQ;AAAA,QACX,IAAI;AAAA,UACF,mEAAmE,eAAe,iBAAiB,cAAc;AAAA,QACnH;AAAA,QACA,CAAC;AAAA,QACD;AAAA,MACF;AACA;AAAA,IACF;AAEA,UAAM,WAAW,cAAc,OAAO,QAAQ,QAAQ,EAAE;AAExD,UAAM,eAAe,QAAQ,aAAa,yBAAyB;AACnE,QAAI,iBAAiB,QAAW;AAC9B,YAAM,SAAS,OAAO,SAAS,cAAc,EAAE,IAAI;AACnD,YAAM,UAAU,OAAO,SAAS,aAAa,WAAW,SAAS,WAAW;AAC5E,UAAI,OAAO,SAAS,MAAM,KAAK,SAAS,SAAS;AAC/C,iBAAS,WAAW;AAAA,MACtB;AAAA,IACF;AAEA,QAAI,CAAC,cAAc,QAAQ,QAAQ,GAAG;AACpC,WAAK,QAAQ;AAAA,QACX,IAAI,gBAAgB,yDAAyD;AAAA,QAC7E;AAAA,QACA;AAAA,MACF;AACA;AAAA,IACF;AAEA,UAAM,MAAM,cAAc,IAAI,QAAQ;AACtC,UAAM,UAAU,KAAK,SAAS,GAAG;AACjC,QAAI,CAAC,SAAS;AACZ,YAAM,KAAK,QAAQ,KAAK,UAAsB,OAAO;AACrD;AAAA,IACF;AAEA,QAAI;AACF,YAAM,QAAQ,UAAU,SAAS,UAAU,OAAO,CAAC;AAAA,IACrD,SAAS,OAAO;AACd,WAAK,QAAQ,UAAU,OAAO,UAAU,OAAO;AAC/C,YAAM,KAAK,MAAM,UAAU,SAAS,YAAY;AAC9C,cAAM,QAAQ,KAAK,QAAQ,gBAAgB;AAC3C,cAAM,WAAW,OAAO,SAAS,aAAa,WAAW,SAAS,WAAW;AAC7E,cAAM,KAAK,QAAQ,SAAS,OAAO,UAAU,aAAa,MAAM,QAAQ,IAAI,KAAK;AAAA,MACnF,CAAC;AACD;AAAA,IACF;AAIA,UAAM,KAAK,MAAM,UAAU,SAAS,MAAM,KAAK,cAAc,OAAO,CAAC;AAAA,EACvE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAc,MAAM,UAA4B,SAAqB,MAA0C;AAC7G,QAAI;AACF,YAAM,KAAK;AAAA,IACb,SAAS,OAAO;AACd,WAAK,QAAQ,UAAU,OAAO,UAAU,OAAO;AAAA,IACjD;AAAA,EACF;AAAA;AAAA,EAGA,MAAc,QAAQ,KAAa,UAAoB,SAAoC;AACzF,UAAM,WAAW,KAAK,QAAQ;AAC9B,QAAI,aAAa,QAAW;AAC1B,UAAI,KAAK,QAAQ,cAAc;AAC7B,cAAM,KAAK,QAAQ,aAAa,UAAU,OAAO;AACjD,cAAM,KAAK,OAAO,OAAO;AAAA,MAC3B,OAAO;AACL,aAAK,QAAQ,UAAU,IAAI,gBAAgB,GAAG,GAAG,UAAU,OAAO;AAAA,MACpE;AACA;AAAA,IACF;AAEA,UAAM,KAAK,MAAM,UAAU,SAAS,YAAY;AAC9C,YAAM,KAAK,QAAQ,eAAe,UAAU,OAAO;AAAA,IACrD,CAAC;AACD,YAAQ,UAAU;AAAA,MAChB,KAAK,mBAAmB;AACtB,cAAM,KAAK,MAAM,UAAU,SAAS,MAAM,KAAK,cAAc,OAAO,CAAC;AACrE;AAAA,MACF,KAAK,mBAAmB;AACtB,cAAM,KAAK,MAAM,UAAU,SAAS,MAAM,KAAK,QAAQ,SAAS,KAAK,QAAQ,0BAA0B,CAAC,CAAC;AACzG;AAAA,MACF,KAAK,mBAAmB;AACtB,cAAM,KAAK,MAAM,UAAU,SAAS,YAAY;AAC9C,gBAAM,KAAK,WAAW,UAAU,aAAa;AAC7C,gBAAM,KAAK,cAAc,OAAO;AAAA,QAClC,CAAC;AACD;AAAA,MACF;AAEE,aAAK,QAAQ,UAAU,IAAI,gBAAgB,GAAG,GAAG,UAAU,OAAO;AAAA,IACtE;AAAA,EACF;AAAA;AAAA,EAGA,MAAc,QAAQ,SAAqB,SAAgC;AACzE,QAAI,CAAC,QAAQ,iBAAiB,CAAC,KAAK,OAAO,wBAAyB;AACpE,UAAM,UAAU,OAAO,SAAS,OAAO,IACnC,KAAK,IAAI,wBAAwB,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,CAAC,CAAC,IACjE;AACJ,UAAM,KAAK,OAAO,wBAAwB;AAAA,MACxC,UAAU,KAAK;AAAA,MACf,eAAe,QAAQ;AAAA,MACvB,mBAAmB;AAAA,IACrB,CAAC;AAAA,EACH;AAAA;AAAA,EAGA,MAAc,WAAW,UAAoB,QAA+B;AAC1E,UAAM,MAAM,KAAK,QAAQ;AACzB,QAAI,CAAC,IAAK;AACV,UAAM,SAAS,SAAS,MAAM,SAAS,iBAAiB,KAAK,QAAQ;AACrE,UAAM,YAAY,SAAS,UAAU,QAAQ,QAAQ;AAAA,MACnD,UAAU,SAAS,YAAY;AAAA,IACjC,CAAC;AACD,UAAM,QAA0B;AAAA,MAC9B,UAAU;AAAA,MACV,aAAa,cAAc,OAAO,SAAS;AAAA,MAC3C,mBAAmB,oBAAoB,SAAS;AAAA,IAClD;AACA,QAAI,IAAI,SAAS,OAAO,GAAG;AAEzB,YAAM,iBAAiB;AACvB,UAAI,UAAU,MAAM,GAAI,OAAM,yBAAyB,UAAU,KAAK;AAAA,IACxE;AACA,UAAM,KAAK,OAAO,YAAY,KAAK;AAAA,EACrC;AAAA;AAAA,EAGA,MAAc,cAAc,SAAoC;AAC9D,QAAI;AACF,YAAM,KAAK,OAAO,OAAO;AAAA,IAC3B,SAAS,OAAO;AACd,YAAM,IAAI,eAAe,KAAK;AAAA,IAChC;AAAA,EACF;AAAA,EAEA,MAAc,OAAO,SAAoC;AACvD,QAAI,CAAC,QAAQ,cAAe;AAC5B,UAAM,KAAK,OAAO,cAAc;AAAA,MAC9B,UAAU,KAAK;AAAA,MACf,eAAe,QAAQ;AAAA,IACzB,CAAC;AAAA,EACH;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/headers.ts"],"sourcesContent":["/**\n * Amazon SQS adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed **consumer** over Amazon SQS,\n * so an SQS-based Node service speaks the same contract (envelope shape, URN\n * identity, trace propagation) as the PHP/Laravel, Python, Go, Java and .NET SDKs.\n *\n * import { SQS } from \"@aws-sdk/client-sqs\";\n * import { SqsPublisher, SqsConsumer } from \"@babelqueue/sqs\";\n *\n * const sqs = new SQS({ region: \"eu-central-1\" });\n * const url = \"https://sqs.eu-central-1.amazonaws.com/123456789012/orders\";\n *\n * await new SqsPublisher(sqs, url).publish(\"urn:babel:orders:created\", { order_id: 1042 });\n *\n * const consumer = new SqsConsumer(sqs, url, {\n * \"urn:babel:orders:created\": async (env) => { ... },\n * });\n * await consumer.run();\n *\n * This implements §3 of the broker-bindings contract: the canonical envelope is the\n * message body, projected onto native SQS `MessageAttributes`. The envelope is\n * unchanged (`schema_version` stays 1); SQS is purely additive. Retry is SQS-native\n * (a failed handler's message is released via `ChangeMessageVisibility` after\n * `releaseDelay`, default 0 s — pair the queue with a `RedrivePolicy`); the\n * authoritative attempt count is `ApproximateReceiveCount`, surfaced to handlers as\n * `attempts = count − 1`.\n *\n * **Version gate (§3.7, ADR-0037 decision 1).** Before the body is decoded the consumer reads the `bq-schema-version`\n * message attribute. A missing or blank value (empty or only ASCII whitespace) changes nothing; any\n * value other than the core's supported `SCHEMA_VERSION` (`\"2\"`, `\"x\"`, `\"01\"`, ...) rejects the\n * message undecoded (`onError` with an error naming the version and an empty envelope) and settles it on its\n * **first delivery**: never decoded, never given to a handler, `onUnknownUrn` or a retry. Under\n * `unknownUrn: \"delete\"` it is deleted; with a `deadLetterQueueUrl` the body, byte for byte and without a\n * `dead_letter` block, is sent to the DLQ and deleted only after the send succeeded (a failed send leaves it\n * undeleted: visibility expiry redelivers it and the write is tried again); without a `deadLetterQueueUrl`\n * it is left to the **native** mechanism — not deleted, so visibility expiry redelivers it and the queue's\n * `RedrivePolicy` moves it to the native DLQ (pair the queue with one). Only the explicitly configured\n * `unknownUrn` strategy is read: an `onUnknownUrn` hook never changes it (a hook-only consumer behaves as `fail`).\n *\n * **Poison (ADR-0037 decision 2, ADR-0035).** A body that does not decode into a valid envelope is never given\n * to a handler and never retried. `unknownUrn: \"delete\"` deletes it; with a `deadLetterQueueUrl` the ADR-0035\n * record (`reason: \"poison\"`, built with the core's `buildPoisonRecord`) is sent to the DLQ and the message\n * deleted only after the send succeeded; without one it is left to the native mechanism, as above.\n *\n * **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop\n * span linkage) rides as additional String `MessageAttributes` **beside** the contract `bq-*`\n * attributes where `bq-trace-id` already lives — the contract attributes win a key collision, and\n * the merged set is bounded by SQS's **10-attribute limit** (contract attributes are seeded first,\n * so a rider only lands while headroom remains). On consume the inbound `MessageAttributes` are\n * surfaced as a `Record<string,string>` so the core's `otel` extract sees the `traceparent` and\n * links the consumer span as a child. A header-less publish is byte-identical. This mirrors the Go\n * `/sqs` and PHP `SqsTransport` wiring. GR-1: the wire envelope body is never touched.\n */\n\nimport { createHash } from \"node:crypto\";\n\nimport {\n BabelQueueError,\n EnvelopeCodec,\n SCHEMA_VERSION,\n UnknownUrnError,\n UnknownUrnStrategy,\n annotate,\n buildPoisonRecord,\n retryBackoffBound,\n retryBackoffDelay,\n} from \"@babelqueue/core\";\nimport type { Envelope, HeaderCarrier, IncomingEnvelope, RetryBackoffOptions } from \"@babelqueue/core\";\n\nimport { sanitizeHeaders } from \"./headers.js\";\n\n/** SQS allows at most 10 user message attributes per message. */\nconst MAX_ATTRIBUTES = 10;\n\n/** The SQS ceiling for a `VisibilityTimeout` (12 hours, in seconds). */\nexport const MAX_VISIBILITY_TIMEOUT = 43_200;\n\n// --- Minimal SQS shapes (a structural subset of @aws-sdk/client-sqs) -----------\n\nexport interface SqsMessageAttributeValue {\n DataType: string;\n StringValue?: string;\n}\n\nexport interface SqsMessage {\n MessageId?: string;\n Body?: string;\n ReceiptHandle?: string;\n MessageAttributes?: Record<string, SqsMessageAttributeValue>;\n Attributes?: Record<string, string>;\n}\n\nexport interface SendMessageInput {\n QueueUrl: string;\n MessageBody: string;\n MessageAttributes?: Record<string, SqsMessageAttributeValue>;\n MessageGroupId?: string;\n MessageDeduplicationId?: string;\n}\n\nexport interface ReceiveMessageInput {\n QueueUrl: string;\n MaxNumberOfMessages?: number;\n WaitTimeSeconds?: number;\n VisibilityTimeout?: number;\n MessageAttributeNames?: string[];\n AttributeNames?: string[];\n}\n\n/**\n * The subset of the AWS SQS client this adapter calls. The aggregated `SQS` class\n * from `@aws-sdk/client-sqs` satisfies it structurally; a fake satisfies it in tests.\n */\nexport interface SqsApi {\n sendMessage(input: SendMessageInput): Promise<{ MessageId?: string } | unknown>;\n receiveMessage(input: ReceiveMessageInput): Promise<{ Messages?: SqsMessage[] }>;\n deleteMessage(input: { QueueUrl: string; ReceiptHandle: string }): Promise<unknown>;\n /**\n * The §3.5 release primitive. Optional so existing structural clients keep compiling; it is\n * required (checked at construction) when a consumer sets `releaseDelay` or the `release`\n * unknown-URN strategy explicitly. Without it the default handler-failure release degrades to\n * leaving the message for visibility-timeout redelivery. The aggregated `SQS` class from\n * `@aws-sdk/client-sqs` provides it.\n */\n changeMessageVisibility?(input: ChangeMessageVisibilityInput): Promise<unknown>;\n}\n\nexport interface ChangeMessageVisibilityInput {\n QueueUrl: string;\n ReceiptHandle: string;\n VisibilityTimeout: number;\n}\n\n// --- Attribute projection (contract §3.2) --------------------------------------\n\nconst str = (value: unknown): SqsMessageAttributeValue => ({\n DataType: \"String\",\n StringValue: String(value),\n});\nconst num = (value: unknown): SqsMessageAttributeValue => ({\n DataType: \"Number\",\n StringValue: String(value),\n});\n\n/**\n * Project the envelope's contract fields onto native SQS `MessageAttributes` — a\n * redundant, routable view of the body (the body stays authoritative).\n */\nexport function toMessageAttributes(envelope: Envelope): Record<string, SqsMessageAttributeValue> {\n const attrs: Record<string, SqsMessageAttributeValue> = {};\n if (envelope.job) attrs[\"bq-job\"] = str(envelope.job);\n if (envelope.trace_id) attrs[\"bq-trace-id\"] = str(envelope.trace_id);\n if (envelope.meta.id) attrs[\"bq-message-id\"] = str(envelope.meta.id);\n if (envelope.meta.schema_version != null) {\n attrs[\"bq-schema-version\"] = num(envelope.meta.schema_version);\n }\n if (envelope.meta.lang) attrs[\"bq-source-lang\"] = str(envelope.meta.lang);\n if (envelope.meta.created_at != null) {\n attrs[\"bq-created-at\"] = num(envelope.meta.created_at);\n }\n return attrs;\n}\n\n/**\n * Overlay the out-of-band `headers` onto the contract attribute projection as String\n * `MessageAttributes`, without overwriting an existing `bq-*` attribute (the contract wins a key\n * collision) and skipping blanks. Keys are merged in sorted order and the merge stops at the\n * 10-attribute SQS ceiling, so unbounded riders can never push the message past the limit (SQS\n * rejects the whole send otherwise) — the contract attributes are always preserved first. Mirrors\n * Go's `mergeAttributes` / PHP's `SqsTransport::attributes`.\n */\nexport function mergeAttributes(\n base: Record<string, SqsMessageAttributeValue>,\n headers: HeaderCarrier | null | undefined,\n): Record<string, SqsMessageAttributeValue> {\n const clean = sanitizeHeaders(headers);\n for (const key of Object.keys(clean).sort()) {\n if (key in base) continue; // never clobber a contract bq-* attribute\n if (Object.keys(base).length >= MAX_ATTRIBUTES) break; // respect the SQS 10-attribute cap\n base[key] = str(clean[key]);\n }\n return base;\n}\n\n/**\n * Map inbound SQS `MessageAttributes` onto a flat {@link HeaderCarrier} (the consume-side\n * counterpart of {@link mergeAttributes}), reading each attribute's `StringValue`. Returns an empty\n * object when there are none. Both the contract `bq-*` attributes and any out-of-band rider (e.g.\n * `traceparent`) surface — the core's `otel` extract reads only the keys it knows.\n */\nexport function headersOf(message: SqsMessage): HeaderCarrier {\n const attrs = message.MessageAttributes;\n const out: HeaderCarrier = {};\n if (!attrs) return out;\n for (const key of Object.keys(attrs)) {\n const value = attrs[key]?.StringValue;\n if (value != null && value !== \"\") out[key] = value;\n }\n return out;\n}\n\n/** Blank = empty or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) — not `String.prototype.trim()`'s wider set. */\nconst BLANK_ASCII = /^[ \\t\\n\\v\\f\\r]*$/;\n\n/**\n * The `bq-schema-version` message attribute when it is present, non-blank and not the supported\n * version (compared against the core's `SCHEMA_VERSION`, exact string match: `\"01\"`, `\" 1\"`, `\"1.0\"`\n * are rejected). Blank means the empty string or only ASCII whitespace (the same set in every SDK,\n * GR-5); NBSP, U+0085, U+FEFF, U+3000 and the like are not blank. `undefined` when the message may\n * be decoded.\n */\nfunction unsupportedSchemaVersion(message: SqsMessage): string | undefined {\n const raw: unknown = message.MessageAttributes?.[\"bq-schema-version\"]?.StringValue;\n if (typeof raw !== \"string\" || BLANK_ASCII.test(raw)) return undefined;\n return raw === String(SCHEMA_VERSION) ? undefined : raw;\n}\n\nfunction queueNameFromUrl(queueUrl: string): string {\n const segments = queueUrl.split(\"/\").filter(Boolean);\n return segments[segments.length - 1] ?? \"default\";\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link SqsPublisher}. */\nexport interface SqsPublisherOptions {\n /** Treat the queue as FIFO: set `MessageGroupId` and (unless content dedup) `MessageDeduplicationId`. */\n fifo?: boolean;\n /** FIFO ordering group (default: the queue name from the URL). */\n messageGroupId?: string;\n /** Use the queue's content-based dedup instead of `meta.id` as the dedup id. */\n contentDedup?: boolean;\n}\n\n/** Options for {@link SqsPublisher.publish}. */\nexport interface PublishOptions {\n /** Reuse an existing trace id (trace continuation). */\n traceId?: string;\n /**\n * Out-of-band transport headers carried as String `MessageAttributes` beside the contract `bq-*`\n * attributes (ADR-0028) — e.g. a W3C `traceparent` written by `@babelqueue/core/otel`'s `publish`.\n * The contract attributes win a key collision and the merged set is capped at SQS's 10-attribute\n * limit; an empty/omitted carrier leaves the publish unchanged.\n */\n headers?: HeaderCarrier;\n}\n\n/** Sends canonical-envelope messages to one SQS queue with the §3 attribute projection. */\nexport class SqsPublisher {\n constructor(\n private readonly client: SqsApi,\n private readonly queueUrl: string,\n private readonly options: SqsPublisherOptions = {},\n ) {}\n\n /**\n * Build the canonical envelope for `(urn, data)`, send it as the message body with\n * the projected `MessageAttributes`, and return the message id (`meta.id`).\n */\n async publish(\n urn: string,\n data: Record<string, unknown>,\n options: PublishOptions = {},\n ): Promise<string> {\n const envelope = EnvelopeCodec.make(urn, data, {\n queue: queueNameFromUrl(this.queueUrl),\n traceId: options.traceId,\n });\n const input: SendMessageInput = {\n QueueUrl: this.queueUrl,\n MessageBody: EnvelopeCodec.encode(envelope),\n MessageAttributes: mergeAttributes(toMessageAttributes(envelope), options.headers),\n };\n if (this.options.fifo) {\n input.MessageGroupId = this.options.messageGroupId ?? queueNameFromUrl(this.queueUrl);\n if (!this.options.contentDedup) {\n input.MessageDeduplicationId = envelope.meta.id;\n }\n }\n await this.client.sendMessage(input);\n return envelope.meta.id;\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/**\n * A URN handler. Receives the validated envelope, the raw SQS message, and the out-of-band\n * {@link HeaderCarrier} read from the message's `MessageAttributes` (empty when there are none).\n * Pass `headers` to `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of\n * the producer span (ADR-0028).\n */\nexport type BabelHandler = (envelope: Envelope, message: SqsMessage, headers: HeaderCarrier) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link SqsConsumer}. */\nexport interface SqsConsumerOptions extends RetryBackoffOptions {\n /**\n * Called when a message's URN has no handler. Without `unknownUrn` the message is then deleted\n * (the original behaviour); with `unknownUrn` set it is a notification hook and the strategy\n * decides the message's fate.\n */\n onUnknownUrn?: (envelope: IncomingEnvelope, message: SqsMessage) => unknown | Promise<unknown>;\n /**\n * Unknown-URN strategy per contract §3.5 — `fail` | `delete` | `release` | `dead_letter`\n * ({@link UnknownUrnStrategy}). Omitted keeps the original behaviour (`onUnknownUrn` → delete,\n * otherwise report and leave for visibility-timeout redelivery, i.e. `fail`). `release` uses\n * `ChangeMessageVisibility` with {@link unknownUrnReleaseDelay}; `dead_letter` sends the annotated\n * envelope to {@link deadLetterQueueUrl} then deletes, degrading to `delete` when no DLQ is set.\n *\n * The version gate and the poison route read **only this explicitly configured strategy** (ADR-0037): an\n * `onUnknownUrn` hook, and the `delete` it implies when no strategy is set, never apply to them.\n */\n unknownUrn?: string;\n /** Backoff (seconds) for the `release` unknown-URN strategy (default 0 = redeliver now). */\n unknownUrnReleaseDelay?: number;\n /**\n * Backoff before a failed message is redelivered. A failed handler always releases the message\n * via `ChangeMessageVisibility(ReceiptHandle, VisibilityTimeout)` (contract §3.5); this sets the\n * `VisibilityTimeout` — a number of seconds, or a function of the (reconciled) `attempts`\n * returning seconds (e.g. an exponential backoff). Clamped to `0…43200`. Default `0`: the message\n * is visible again immediately. A permanently failing handler therefore retries without pause\n * until the queue's `RedrivePolicy` (`maxReceiveCount`) moves it to the DLQ — configure one.\n *\n * This is the SQS spelling of the cross-SDK A9 backoff and stays supported as an alias: when it is\n * set it wins over `retryBackoff` / `retryBackoffMultiplier` / `retryBackoffMax` / `retryJitter`\n * (inherited from {@link RetryBackoffOptions}), which compute `d(n) = min(max, base * multiplier^(n-1))`\n * seconds for the 1-based attempt `n = attempts + 1` (the SQS 12 h ceiling still applies on top).\n */\n releaseDelay?: number | ((attempts: number) => number);\n /**\n * The cross-language `<queue>-dlq` URL (opt-in; default none; SQS names cannot contain a dot): the `dead_letter` strategy, poison bodies\n * (ADR-0035 record) and version-gated deliveries (the raw body) are written here. Without it a poison or\n * version-gated delivery is left to the queue's native `RedrivePolicy` (ADR-0037). A\n * `.fifo` DLQ is sent with `MessageGroupId` (the source queue name) and `MessageDeduplicationId`\n * (`meta.id`).\n */\n deadLetterQueueUrl?: string | null;\n /**\n * Called for a non-conformant envelope, an unmapped URN (no `onUnknownUrn`), a throwing handler,\n * a throwing `onUnknownUrn` under an `unknownUrn` strategy, a failed release / dead-letter send, or\n * a failed delete after successful processing (an {@link SqsDeleteError} wrapping the broker\n * error; never released). The message is then left for visibility-timeout redelivery. The loop\n * never stops on these.\n */\n onError?: (error: unknown, envelope: IncomingEnvelope, message: SqsMessage) => void;\n /** Long-poll wait seconds (default 20). */\n waitTimeSeconds?: number;\n /** Reservation window applied on receive (seconds). */\n visibilityTimeout?: number;\n /** Max messages per receive (default 10). */\n maxMessages?: number;\n}\n\n/**\n * Reported via `onError` when `DeleteMessage` fails for a message whose processing already succeeded\n * (a handler returned normally, or the unknown-URN strategy chose `delete` / `dead_letter`). The\n * broker error is `cause`. Distinct from a handler failure: the message is NOT released, so it is\n * redelivered only after its visibility timeout expires (at-least-once; dedupe on `meta.id` with the\n * idempotency helper if the side effect must not repeat).\n */\nexport class SqsDeleteError extends BabelQueueError {\n constructor(cause: unknown) {\n super(\"Failed to delete a processed SQS message; it will be redelivered after its visibility timeout.\");\n this.name = \"SqsDeleteError\";\n this.cause = cause;\n }\n}\n\nfunction hasRetryBackoff(options: RetryBackoffOptions): boolean {\n return (\n options.retryBackoff !== undefined ||\n options.retryBackoffMultiplier !== undefined ||\n options.retryBackoffMax !== undefined ||\n options.retryJitter !== undefined\n );\n}\n\n/**\n * Polls an SQS queue, decodes + validates each message, routes it to the handler\n * registered for its URN, and deletes it on success. A throwing handler's message is released via\n * `ChangeMessageVisibility` after `releaseDelay` (default 0 s, contract §3.5); if the release itself\n * fails the message is left for visibility-timeout redelivery (at-least-once);\n * `attempts` is reconciled to `ApproximateReceiveCount − 1` for the handler. An unmapped URN\n * follows the `unknownUrn` strategy (`fail` | `delete` | `release` | `dead_letter`).\n */\nexport class SqsConsumer {\n constructor(\n private readonly client: SqsApi,\n private readonly queueUrl: string,\n private readonly handlers: BabelHandlers,\n private readonly options: SqsConsumerOptions = {},\n ) {\n const strategy = options.unknownUrn;\n if (strategy !== undefined && !(Object.values(UnknownUrnStrategy) as string[]).includes(strategy)) {\n throw new BabelQueueError(`Unknown unknownUrn strategy \"${strategy}\".`);\n }\n // Fail fast on an invalid A9 parameter instead of at the first failed message.\n retryBackoffBound(options, 1);\n if (options.retryJitter !== undefined && options.retryJitter !== \"none\" && options.retryJitter !== \"full\") {\n throw new BabelQueueError(`retryJitter must be \"none\" or \"full\", got ${String(options.retryJitter)}.`);\n }\n const needsRelease =\n options.releaseDelay != null || hasRetryBackoff(options) || strategy === UnknownUrnStrategy.RELEASE;\n if (needsRelease && typeof client.changeMessageVisibility !== \"function\") {\n throw new BabelQueueError(\n \"SqsConsumer release requires a client with changeMessageVisibility (contract §3.5).\",\n );\n }\n }\n\n /** Receive one batch, route each message, delete the ones handled. Returns the batch size. */\n async poll(): Promise<number> {\n const input: ReceiveMessageInput = {\n QueueUrl: this.queueUrl,\n MaxNumberOfMessages: this.options.maxMessages ?? 10,\n WaitTimeSeconds: this.options.waitTimeSeconds ?? 20,\n MessageAttributeNames: [\"All\"],\n AttributeNames: [\"ApproximateReceiveCount\"],\n };\n if (this.options.visibilityTimeout != null) {\n input.VisibilityTimeout = this.options.visibilityTimeout;\n }\n const result = await this.client.receiveMessage(input);\n const messages = result.Messages ?? [];\n for (const message of messages) {\n await this.handle(message);\n }\n return messages.length;\n }\n\n /** Poll until `signal` aborts (each poll long-polls, so this does not busy-loop). */\n async run(signal?: AbortSignal): Promise<void> {\n while (signal?.aborted !== true) {\n await this.poll();\n }\n }\n\n private async handle(message: SqsMessage): Promise<void> {\n // §3.7: gate on the bq-schema-version attribute before the body is decoded (ADR-0037 decision 1).\n const rejectedVersion = unsupportedSchemaVersion(message);\n if (rejectedVersion !== undefined) {\n await this.settleVersionGated(\n message,\n new BabelQueueError(\n `Rejected a message from SQS with unsupported bq-schema-version \"${rejectedVersion}\" (supported: ${SCHEMA_VERSION}).`,\n ),\n );\n return;\n }\n\n const envelope = EnvelopeCodec.decode(message.Body ?? \"\");\n\n const receiveCount = message.Attributes?.[\"ApproximateReceiveCount\"];\n if (receiveCount !== undefined) {\n const native = Number.parseInt(receiveCount, 10) - 1;\n const current = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n if (Number.isFinite(native) && native > current) {\n envelope.attempts = native;\n }\n }\n\n if (!EnvelopeCodec.accepts(envelope)) {\n await this.settlePoison(message, envelope, new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from SQS.\"));\n return;\n }\n\n const urn = EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n await this.unknown(urn, envelope as Envelope, message);\n return;\n }\n\n try {\n await handler(envelope, message, headersOf(message));\n } catch (error) {\n this.options.onError?.(error, envelope, message);\n await this.guard(envelope, message, async () => {\n const attempts = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n await this.release(message, this.failureDelay(attempts));\n });\n return;\n }\n // Outside the handler's try: a failed delete is not a handler failure. It is reported as an\n // SqsDeleteError and the message is NOT released — releasing at the (default 0 s) backoff would\n // redeliver an already-processed message immediately; it returns on visibility expiry instead.\n await this.guard(envelope, message, () => this.deleteHandled(message));\n }\n\n /**\n * Version-gated delivery (ADR-0037 decision 1). `unknownUrn: \"delete\"` deletes it; with a DLQ the raw body goes\n * to `<queue>-dlq` and the message is deleted only after the send; otherwise it is reported and left for the\n * queue's native mechanism (never deleted, never released by the SDK). Only the configured strategy is read.\n */\n private async settleVersionGated(message: SqsMessage, gateError: BabelQueueError): Promise<void> {\n this.options.onError?.(gateError, {}, message);\n const dlq = this.options.deadLetterQueueUrl;\n if (this.options.unknownUrn === UnknownUrnStrategy.DELETE) {\n await this.guard({}, message, () => this.deleteHandled(message));\n return;\n }\n if (!dlq) return; // native: SQS redelivers on visibility expiry, RedrivePolicy moves it after maxReceiveCount\n await this.guard({}, message, async () => {\n await this.sendRaw(dlq, message, message.Body ?? \"\");\n await this.deleteHandled(message);\n });\n }\n\n /**\n * Poison body (ADR-0037 decision 2). `unknownUrn: \"delete\"` deletes it; with a DLQ the ADR-0035 record is sent\n * and the message deleted only after the send; otherwise it is reported and left for the native mechanism.\n */\n private async settlePoison(message: SqsMessage, envelope: IncomingEnvelope, error: BabelQueueError): Promise<void> {\n this.options.onError?.(error, envelope, message);\n const dlq = this.options.deadLetterQueueUrl;\n if (this.options.unknownUrn === UnknownUrnStrategy.DELETE) {\n await this.guard(envelope, message, () => this.deleteHandled(message));\n return;\n }\n if (!dlq) return; // native, as above\n await this.guard(envelope, message, async () => {\n const source = queueNameFromUrl(this.queueUrl);\n const count = Number.parseInt(message.Attributes?.[\"ApproximateReceiveCount\"] ?? \"\", 10);\n const delivered = Number.isFinite(count) && count > 1 ? count - 1 : 0; // informational: deliveries before this one\n const deadLetter = annotate({ attempts: delivered } as unknown as Envelope, \"poison\", source, {\n attempts: delivered,\n error: error.message,\n exception: error.name,\n }).dead_letter;\n const record = buildPoisonRecord(Buffer.from(message.Body ?? \"\", \"utf8\"), JSON.stringify(deadLetter));\n await this.sendRaw(dlq, message, record);\n await this.deleteHandled(message);\n });\n }\n\n /** Send `body` to the DLQ as is (a `.fifo` DLQ gets the source queue as group and a stable dedup id). */\n private async sendRaw(dlq: string, message: SqsMessage, body: string): Promise<void> {\n const input: SendMessageInput = { QueueUrl: dlq, MessageBody: body };\n if (dlq.endsWith(\".fifo\")) {\n input.MessageGroupId = queueNameFromUrl(this.queueUrl);\n input.MessageDeduplicationId = message.MessageId ?? createHash(\"sha256\").update(message.Body ?? \"\").digest(\"hex\");\n }\n await this.client.sendMessage(input);\n }\n\n /** The release delay (seconds) for a failed handler: `releaseDelay` (alias) wins, else the A9 backoff, else 0. */\n private failureDelay(attempts: number): number {\n const delay = this.options.releaseDelay;\n if (delay != null) return typeof delay === \"function\" ? delay(attempts) : delay;\n if (!hasRetryBackoff(this.options)) return 0;\n return retryBackoffDelay(this.options, Math.max(0, Math.floor(attempts)) + 1);\n }\n\n /**\n * Run a release / dead-letter step; a failure (e.g. `MessageNotInflight`, a missing IAM grant, a\n * throwing `releaseDelay`) is reported to `onError` and the message is left undeleted, so\n * visibility expiry still redelivers it. Never lets the error stop the consume loop.\n */\n private async guard(envelope: IncomingEnvelope, message: SqsMessage, step: () => Promise<void>): Promise<void> {\n try {\n await step();\n } catch (error) {\n this.options.onError?.(error, envelope, message);\n }\n }\n\n /** Apply the unknown-URN strategy (contract §3.5). */\n private async unknown(urn: string, envelope: Envelope, message: SqsMessage): Promise<void> {\n const strategy = this.options.unknownUrn;\n if (strategy === undefined) {\n if (this.options.onUnknownUrn) {\n await this.options.onUnknownUrn(envelope, message);\n await this.delete(message);\n } else {\n this.options.onError?.(new UnknownUrnError(urn), envelope, message);\n }\n return;\n }\n\n await this.guard(envelope, message, async () => {\n await this.options.onUnknownUrn?.(envelope, message);\n });\n switch (strategy) {\n case UnknownUrnStrategy.DELETE:\n await this.guard(envelope, message, () => this.deleteHandled(message));\n return;\n case UnknownUrnStrategy.RELEASE:\n await this.guard(envelope, message, () => this.release(message, this.options.unknownUrnReleaseDelay ?? 0));\n return;\n case UnknownUrnStrategy.DEAD_LETTER:\n await this.guard(envelope, message, async () => {\n await this.deadLetter(envelope, \"unknown_urn\");\n await this.deleteHandled(message);\n });\n return;\n default:\n // FAIL: surface and do NOT delete — visibility expiry redelivers, native redrive quarantines.\n this.options.onError?.(new UnknownUrnError(urn), envelope, message);\n }\n }\n\n /** Make the message visible again after `seconds` via `ChangeMessageVisibility`; never deletes. */\n private async release(message: SqsMessage, seconds: number): Promise<void> {\n if (!message.ReceiptHandle || !this.client.changeMessageVisibility) return;\n const timeout = Number.isFinite(seconds)\n ? Math.min(MAX_VISIBILITY_TIMEOUT, Math.max(0, Math.floor(seconds)))\n : 0;\n await this.client.changeMessageVisibility({\n QueueUrl: this.queueUrl,\n ReceiptHandle: message.ReceiptHandle,\n VisibilityTimeout: timeout,\n });\n }\n\n /** Send the annotated envelope to the opt-in DLQ; a no-op (→ plain delete) when none is set. */\n private async deadLetter(envelope: Envelope, reason: string): Promise<void> {\n const dlq = this.options.deadLetterQueueUrl;\n if (!dlq) return;\n const source = envelope.meta?.queue ?? queueNameFromUrl(this.queueUrl);\n const annotated = annotate(envelope, reason, source, {\n attempts: envelope.attempts ?? 0,\n });\n const input: SendMessageInput = {\n QueueUrl: dlq,\n MessageBody: EnvelopeCodec.encode(annotated),\n MessageAttributes: toMessageAttributes(annotated),\n };\n if (dlq.endsWith(\".fifo\")) {\n // A FIFO DLQ (`<queue>-dlq.fifo`) rejects a send without a group; dedup on meta.id (§3.2).\n input.MessageGroupId = source;\n if (annotated.meta?.id) input.MessageDeduplicationId = annotated.meta.id;\n }\n await this.client.sendMessage(input);\n }\n\n /** Delete a message whose processing already succeeded; a broker failure becomes an SqsDeleteError. */\n private async deleteHandled(message: SqsMessage): Promise<void> {\n try {\n await this.delete(message);\n } catch (error) {\n throw new SqsDeleteError(error);\n }\n }\n\n private async delete(message: SqsMessage): Promise<void> {\n if (!message.ReceiptHandle) return;\n await this.client.deleteMessage({\n QueueUrl: this.queueUrl,\n ReceiptHandle: message.ReceiptHandle,\n });\n }\n}\n","/**\n * Pure, dependency-free helpers for the out-of-band transport-header seam (ADR-0028) — the Node\n * adapter mirror of Go's `sanitizeHeaders` and PHP's `Support\\Headers`. They fold an injected\n * {@link HeaderCarrier} (e.g. a W3C `traceparent`) onto a transport's metadata channel **without\n * clobbering** the contract headers it already carries.\n *\n * Headers ride beside the frozen wire envelope (GR-1), never inside it.\n */\n\nimport type { HeaderCarrier } from \"@babelqueue/core\";\n\n/**\n * Copy `headers`, dropping blank keys and blank values. Returns an empty object when nothing\n * survives, so a caller can treat the result as \"no headers\". A nullish input yields an empty\n * object.\n */\nexport function sanitizeHeaders(headers: HeaderCarrier | null | undefined): HeaderCarrier {\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headers[key];\n if (key === \"\" || value == null || value === \"\") continue;\n out[key] = value;\n }\n return out;\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@babelqueue/sqs",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "Amazon SQS adapter for BabelQueue — canonical-envelope publisher and a URN-routed consumer.",
5
5
  "keywords": [
6
6
  "babelqueue",
@@ -50,7 +50,7 @@
50
50
  "test": "node --import tsx --test test/*.test.ts"
51
51
  },
52
52
  "dependencies": {
53
- "@babelqueue/core": "^1.8.0"
53
+ "@babelqueue/core": "^1.13.0"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "@aws-sdk/client-sqs": "^3.0.0"