All notable changes to @babelqueue/core are documented here.
The format is based on Keep a Changelog,
and this project adheres to Semantic Versioning.
The envelope wire format is versioned separately by meta.schema_version
(currently 1) — see the contract at babelqueue.com.
- Decode: a non-string
job(null, number, boolean, object, array) is now ignored with a warning and a valid stringurntakes over; with neither, the message is not accepted (ADR-0032 §6). The default-channel warning starts withBabelQueue:and carries its own code,NON_STRING_JOB_WARNING_CODE("BABELQUEUE_NON_STRING_JOB"), now exported. - Trim and blank checks in
make,acceptsandurnuse the closed ASCII set (space,\t,\n,\v,\f,\r) in linear time; Unicode whitespace is no longer trimmed (ADR-0032).
- Conformance runner covers the v1.5.0 sections (
trim,job_type,traceparent) and writes aresults.json(ADR-0032 §2), validated in CI and before publish.
Implements ADR-0031 (producer meta extension keys); conformance suite v1.4.0. The envelope stays at schema_version: 1.
MakeOptions.meta— producer extension keys (flat keys or ameta.ext.<vendor>block), written intometaand preserved across hops. A collision with a coremetakey (id,queue,lang,schema_version,created_at) or a forbidden key throws.InvalidEnvelopeKeyError(extendsBabelQueueError) with the offending RFC 6901pointer.Metahas an index signature ([extension: string]: unknown), so extension keys type-check; existing code keeps compiling.- Conformance runner:
extension_write,encode_reject(the Go-onlyreject-repeated-keycase is skipped),expect_preserved,expect_absent_after_reencode,apply_dead_letter(throughannotate) andexpect_dead_letter, with unit tests that prove each field is evaluated.
encodethrowsInvalidEnvelopeKeyErroron a non-canonical key (top-leveltimestamp/urn,meta.max_retries/attempts/source/ts) instead of warning and dropping it.decodestill warns and drops.process.emitWarning/onWarningis now used for decode only. The error message isBabelQueue: cannot encode envelope key <pointer>: <reason>.(for example... /meta/ts: non-canonical envelope key (message-envelope.md section 10).);error.pointerholds the RFC 6901 pointer.decodefalls back tournwhenjobis missing, empty or only ASCII whitespace ([ \t\n\v\f\r], never Unicode spaces); theurnkey is not kept. Whenjobandurnare both present,jobwins andurnis dropped with a warning naming/urn. Aurnthat is not a string and has no usablejobto stand in for (number,null, object ...) is dropped with the same warning.EnvelopeCodec.urn()/accepts()use the same closed ASCII whitespace set instead oftrim().
EncodeOptions/encode(envelope, { onWarning })— the option is accepted but never called, sinceencodeno longer warns.
RedriveIO.rewind?(from)— optional capability (the Node counterpart of Go'sRewinder). Whatever stops aredrive()run (the restore write back to the DLQ failed, the redrive write failed, theselectpredicate threw, the ack failed) the IO is asked to hand back every reserved, unacknowledged message (an offset-committing broker such as Kafka seeks the consumer back, so a later cumulative commit cannot skip any of them; the whole batch is reserved first). A message whose restore failed is never acked.frommay already be acknowledged (restored after a failed redrive write) and must then not be redelivered. When several steps failed (publish, restore, rewind) all errors are thrown together as one flatAggregateError; a lone error is thrown as is. ARedriveIOwithoutrewindis unchanged, including the error thrown when a redrive write and its restore both fail (the restore error, as before). Additive, no wire change.
- Tests only: the vendored conformance suite is now 1.3.0 (exact-case envelope keys, last-wins duplicates: new
invalid-wrong-case-job,wrong-case-keys-roundtripandduplicate-keys-last-winsfixtures). No production change; the Node core already behaved this way.
- DLQ redrive carries the Kafka record key.
RedriveIOgains an optionalpublishFrom(queue, body, from)capability (the Node counterpart of Go'sKeyedRepublisher);redriveuses it for the redrive write and the restore write back to the DLQ, so a keyed transport can re-publish the consumed record's key byte-for-byte (null stays null, empty stays empty) per contract §6.5 "Record key on republish". ARedriveIOwithout it behaves exactly as before. No wire change (schema_versionstays 1).
minLengthnow counts Unicode code points, not UTF-16 code units. An astral character (e.g. an emoji) counts once, so"😀"no longer satisfiesminLength: 2. Verdicts now match the other SDK cores (conformancepayload_schema_unicode).- Non-canonical envelope keys are dropped on decode, with a warning (message-envelope.md
§10, policy K-15):
timestamp,meta.max_retries,meta.attempts,meta.sourceandmeta.ts. Decode still succeeds so older producers keep working; the keys are no longer carried through and re-emitted on retry/relay.encodenever emits them either: a key the caller set on the envelope object is left out of the output (the object itself is not modified) and a warning names it. All other unknown keys are still preserved. K-15's reject on encode is planned for R1-E0, whenencodewill throw instead of dropping, so treat these warnings as errors-to-be. - The default warning channel is rate-limited:
process.emitWarningfires once per key (and direction) per process, so a high-volume legacy producer cannot flood stderr. An explicitonWarningcallback still receives every occurrence.
EnvelopeCodec.decode(raw, { onWarning })— an optionalDecodeOptions.onWarningcallback that receives one message per dropped key, naming it as a JSON pointer (e.g./meta/max_retries). Without it the warning goes toprocess.emitWarningwith the codeFORBIDDEN_KEY_WARNING_CODE("BABELQUEUE_FORBIDDEN_KEY").EnvelopeCodec.encode(envelope, { onWarning })— the same optional callback (EncodeOptions) for keys left out on encode.- Conformance runners for the new shared sections
roundtrip,data_shape,forbidden_keysandpayload_schema_unicode.
- Runtime GDPR field encryption — the SDK-enforcement half of
x-gdpr-sensitive(ADR-0030). The registry only declares and audits whichdatafields are personal/sensitive; this core now enforces it on the wire. A producer encrypts each marked leaf before publish, a consumer decrypts it after decode — PII rides the wire as ciphertext while the envelope stays frozen. The Node mirror of the Go reference; purely additive, opt-in and validation-neutral.- New
gdpr.protect(data, schema, cipher)/gdpr.unprotect(...)standalone helpers — they rewrite eachx-gdpr-sensitiveleaf in place:protectcanonically JSON-encodes the value then replaces it with the cipher's ciphertext string;unprotectis the byte-for-byte inverse (numbers restore to numbers, objects to objects). They walk nested objects (profile.full_name) and array items (addresses[].line); an absent marked field is skipped (not an error); a non-sensitive field is never touched. A wrong-key / tampered / non-ciphertext value throws the typedDecryptErrorso the consumer fails the message (retry / dead-letter). - New
Cipherinterface —encrypt(bytes): string/decrypt(string): bytes, both synchronous — the caller-provided seam onto a KMS / Vault / HSM / tokenisation service, so the core pulls no crypto dependency (GR-7). - New
AesGcmCipherreference cipher built only onnode:crypto— AES-GCM with a random 12-byte IV prepended and base64 output (base64(iv || ciphertext || tag)); 16/24/32-byte keys select AES-128/192/256-GCM. The caller owns the key (no key management). GCM authenticates, so a wrong key or tampered input throws rather than returning corrupt plaintext. New typed errorsDecryptError,InvalidKeySizeError,MalformedCiphertextError. - New
schema.sensitivePaths(schema)+SensitivePathtype — parses thex-gdpr-sensitivekeyword (booleantrueor non-empty string category) and walks nested objects, array items and the root mark, in sorted path order. Parsing is validation-neutral — the keyword never makes a value valid or invalid, so annotating a schema is non-breaking. - The envelope stays frozen (GR-1): only
datavalues change, a ciphertext is a JSON string sodatastays pure JSON (GR-3),meta.schema_versionstays 1 andtrace_idis preserved (GR-4). An SDK without the key still carries the envelope. Validate cleartext — beforeprotect, afterunprotect. Entirely opt-in and backward compatible.
- New
- Transactional outbox — an optional producer-side helper that removes the dual
write (ADR-0029). A plain producer must commit its business row and publish to
the broker — two systems that disagree on a crash. The outbox persists the encoded
envelope into the same DB transaction as the business write, and a separate relay
publishes the durable rows afterwards: no distributed transaction, exactly-once
handoff, then at-least-once on the wire as always.
- New
Outboxwriter —write(envelope)encodes via the frozenEnvelopeCodecand delegates to the store. The caller owns the transaction boundary; the helper never begins/commits anything (GR-7). - New
OutboxStoreinterface (save,fetchUnpublishedoldest-first,markPublished,markFailed) — the persistence seam the caller binds to their own DB; the core ships no DB driver. NewOutboxTransportinterface (publish(body, queue)) — the publish-only seam the relay forwards through, bound to the caller's broker. - New
OutboxRelay—flush()publishes one batch (marking each row published only after the transport resolves; a rejecting publish →markFailed+ bounded linear backoff, the row stays pending, the batch continues) anddrain(maxPasses?)loops until no progress, with a safety ceiling. The sleeper is injectable so tests are instant. - New
InMemoryOutboxStorereference store (tests / single-process demos; no real transaction). New typesOutboxRecord,OutboxRelayResult,OutboxRelayOptions,Sleeper. - The relay publishes the stored bytes verbatim — never decoding, rebuilding or
re-encoding the envelope — so
trace_idis preserved end-to-end (GR-4) and the body is byte-identical before store and after relay (GR-1/GR-5).schema_versionstays 1: the outbox's bookkeeping lives around the envelope, never on the wire. Entirely opt-in and backward compatible.
- New
- Replay-bypass — an out-of-band side-effect guard for DLQ replay (ADR-0027). A
deliberate
redrivere-runs the handler and re-fires its external side-effects (a second charge, a duplicate email); idempotency stops an accidental duplicate, not the intended reprocess. This closes that gap.- New
redrive(io, dlq, { bypass: true })option stamps abq-replay-bypasstransport header on each replayed message; the per-item result gains abypassedflag. It takes effect only when theRedriveIOimplements the new optionalpublishWithHeaders(queue, body, headers)capability — otherwisebypassis a no-op and the message is still redriven (bypassed: false), exactly like the Go reference. - New
isReplay(headers)+bypassExternalEffects(headers, effect)consume-side guard (plus theHEADER_REPLAY_BYPASSconstant): a handler wraps its external, non-idempotent side so a replay skips it while the idempotent core still runs. - The marker rides beside the frozen envelope on the
HeaderCarrierseam, never inside it (schema_versionstays 1, GR-1;trace_idpreserved, GR-4) — the same out-of-band channel as thetraceparentheader. Fully opt-in and backward compatible: a header-less message behaves exactly as before. Per-adapter transport wiring (thebabelqueue-node-adaptersrepo) is the documented follow-up, like ADR-0028's.
- New
- OpenTelemetry v0.2 — W3C
traceparentcross-hop span linkage (ADR-0028). The@babelqueue/core/otelmodule now propagates the active span as a W3Ctraceparent(andtracestate) transport header so a consumer span is a true child of the producer span — not just a sharedtrace_idtrace.- New
HeaderCarriertype (aRecord<string, string>out-of-band header map) — the seam the core and thebabelqueue-node-adapterstransports agree on. It rides beside the frozen envelope, never inside it (schema_versionstays 1, GR-1). publish(...)takes an optionaloptions.headerscarrier and writes the producer span'straceparentinto it;wrapHandler(tracer, handler, headers?)reads a delivered message's headers (a carrier or a sync/async getter) and starts the consumer span as a remote-parent child. No header ⇒ v0.1trace_idfallback (no regression); fully opt-in and backward compatible.- New low-level exports
injectTraceparent,remoteParentFromHeaders,HEADER_TRACEPARENT,HEADER_TRACESTATE. The W3C parse/format is implemented against the frozen Trace Context format, so the optional dependency stays at@opentelemetry/apionly — the core itself remains zero-runtime-dependency (GR-7). - Per-adapter transport wiring (bullmq/redis/rabbitmq/sqs in the separate
babelqueue-node-adaptersrepo) is a documented follow-up; until wired, propagation degrades to v0.1trace_idcorrelation with no error.
- New
1.0.0 - 2026-06-07
1.0.0 — the public API is now SemVer-stable: breaking changes require a MAJOR,
following the deprecation policy. The wire envelope is unchanged
(schema_version: 1). Full reference at babelqueue.com.
- CI adds ESLint (
@eslint/js+typescript-eslint) and a c8 coverage gate (npm run coverage, ≥90% lines/functions, ≥85% branches) as separate jobs;tsc --stricttypecheck already ran.npm run lintfor local use. - GR-8 latency benchmark (
test/overhead.test.ts) — asserts the envelope encode/decode path adds ≤2% over plain-JSON serialization vs a conservative 750µs broker round-trip.
0.1.0 - 2026-06-06
EnvelopeCodec— builds (make,fromMessage), encodes and decodes the canonical{job, trace_id, data, meta, attempts}envelope (schema_version1). The single Node/TypeScript implementation of the wire format.EnvelopeCodec.encodeemits compact UTF-8 JSON (slashes/unicode unescaped) — the canonical wire form shared by every SDK.EnvelopeCodec.urn()— resolve the URN (job, acceptingurnas an alias).EnvelopeCodec.accepts()— consumer-side validation (rejects empty URN, unsupportedmeta.schema_version, non-objectdata, non-integerattempts, blanktrace_id); acts as a TypeScript type guard narrowing toEnvelope.makeoptionsqueueandtraceId(trace continuation).annotate/deadLetter.annotate— additivedead_letterblock builder.- Contracts
PolyglotMessage/HasTraceId. UnknownUrnStrategy(FAIL/DELETE/RELEASE/DEAD_LETTER);BabelQueueError/UnknownUrnError.- Shipped as a dual ESM + CommonJS package with bundled type declarations.
- Shared cross-SDK conformance suite under
test/conformance/(vendored from the canonicalconformance/set) plus a runner.
- Pre-1.0: the public API may change before the
1.0.0tag. - Zero runtime dependencies; Node
>=18.