@prosody-events/prosody 0.3.0 → 0.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/index.js CHANGED
@@ -28,6 +28,8 @@
28
28
  * @typedef {import('./bindings').Mode} Mode
29
29
  */
30
30
 
31
+ const { types } = require("node:util");
32
+
31
33
  const {
32
34
  context: otelContext,
33
35
  propagation,
@@ -60,11 +62,10 @@ function getSentry() {
60
62
  return Sentry;
61
63
  } catch (err) {
62
64
  const isMissing =
63
- err?.code === "MODULE_NOT_FOUND" &&
64
- err.message?.includes("@sentry/node");
65
+ err?.code === "MODULE_NOT_FOUND" && err.message?.includes("@sentry/node");
65
66
  if (isMissing) {
66
67
  getCurrentLogger()?.error(
67
- "SENTRY_DSN is set but @sentry/node is not installed. Run: npm install @sentry/node"
68
+ "SENTRY_DSN is set but @sentry/node is not installed. Run: npm install @sentry/node",
68
69
  );
69
70
  } else {
70
71
  getCurrentLogger()?.warn("Unexpected error loading @sentry/node", err);
@@ -84,11 +85,26 @@ function captureException(error, eventType, context) {
84
85
  }
85
86
 
86
87
  const defaultLogger = {
87
- error: (message, metadata) => metadata !== undefined ? console.error(message, metadata) : console.error(message),
88
- warn: (message, metadata) => metadata !== undefined ? console.warn(message, metadata) : console.warn(message),
89
- info: (message, metadata) => metadata !== undefined ? console.info(message, metadata) : console.info(message),
90
- debug: (message, metadata) => metadata !== undefined ? console.debug(message, metadata) : console.debug(message),
91
- trace: (message, metadata) => metadata !== undefined ? console.debug(message, metadata) : console.debug(message),
88
+ error: (message, metadata) =>
89
+ metadata !== undefined
90
+ ? console.error(message, metadata)
91
+ : console.error(message),
92
+ warn: (message, metadata) =>
93
+ metadata !== undefined
94
+ ? console.warn(message, metadata)
95
+ : console.warn(message),
96
+ info: (message, metadata) =>
97
+ metadata !== undefined
98
+ ? console.info(message, metadata)
99
+ : console.info(message),
100
+ debug: (message, metadata) =>
101
+ metadata !== undefined
102
+ ? console.debug(message, metadata)
103
+ : console.debug(message),
104
+ trace: (message, metadata) =>
105
+ metadata !== undefined
106
+ ? console.debug(message, metadata)
107
+ : console.debug(message),
92
108
  };
93
109
 
94
110
  // Keep reference to current logger for use in handlers
@@ -295,10 +311,16 @@ class ProsodyClient {
295
311
  const context = new Context(nativeContext);
296
312
  await onMessage(context, message, controller.signal);
297
313
  } catch (error) {
298
- getCurrentLogger()?.error("Message handler error", error.cause ?? error);
314
+ getCurrentLogger()?.error(
315
+ "Message handler error",
316
+ error.cause ?? error,
317
+ );
299
318
  const cause = error.cause ?? error;
300
319
  span.recordException(cause);
301
- span.setStatus({ code: SpanStatusCode.ERROR, message: cause.message });
320
+ span.setStatus({
321
+ code: SpanStatusCode.ERROR,
322
+ message: cause.message,
323
+ });
302
324
  captureException(error, "message", {
303
325
  topic: message.topic,
304
326
  partition: message.partition,
@@ -335,10 +357,16 @@ class ProsodyClient {
335
357
  const context = new Context(nativeContext);
336
358
  await onTimer(context, timer, controller.signal);
337
359
  } catch (error) {
338
- getCurrentLogger()?.error("Timer handler error", error.cause ?? error);
360
+ getCurrentLogger()?.error(
361
+ "Timer handler error",
362
+ error.cause ?? error,
363
+ );
339
364
  const cause = error.cause ?? error;
340
365
  span.recordException(cause);
341
- span.setStatus({ code: SpanStatusCode.ERROR, message: cause.message });
366
+ span.setStatus({
367
+ code: SpanStatusCode.ERROR,
368
+ message: cause.message,
369
+ });
342
370
  captureException(error, "timer", {
343
371
  key: timer.key,
344
372
  time: timer.time,
@@ -439,6 +467,57 @@ class PermanentError extends EventHandlerError {
439
467
  }
440
468
  }
441
469
 
470
+ /**
471
+ * Represents a transient keyed-state failure that may succeed on a later
472
+ * attempt — a store read/write timeout, AND every caller mistake (a
473
+ * null/unrepresentable write, an item-shape mismatch, an out-of-range index, an
474
+ * invalid scan direction). Caller mistakes are transient on purpose: retrying
475
+ * keeps the failure visible and never discards the message (see the
476
+ * error-classification rule in CLAUDE.md). Thrown by state handles and scan
477
+ * iterators. Because it subclasses {@link TransientError}, rethrowing it from a
478
+ * handler classifies the event transient through the existing error bridge with
479
+ * no bridge change.
480
+ * @extends TransientError
481
+ */
482
+ class TransientStateError extends TransientError {}
483
+
484
+ /**
485
+ * Represents a permanent keyed-state failure — one a retry cannot resolve in
486
+ * the running process: an unregistered collection name, a registered-identity
487
+ * mismatch, or a duplicate registration. Caller mistakes (a null/unrepresentable
488
+ * write, an item-shape mismatch, a bad index, an invalid direction) are NOT
489
+ * permanent — they are {@link TransientStateError} so they retry and stay
490
+ * visible rather than discarding the message (see the error-classification rule
491
+ * in CLAUDE.md). A handler may also throw this to declare its own failure
492
+ * permanent. Because it subclasses {@link PermanentError}, rethrowing it from a
493
+ * handler classifies the event permanent through the existing error bridge with
494
+ * no bridge change.
495
+ * @extends PermanentError
496
+ */
497
+ class PermanentStateError extends PermanentError {}
498
+
499
+ const STATE_ERROR_NAMES = new Set([
500
+ "PermanentStateError",
501
+ "TransientStateError",
502
+ ]);
503
+
504
+ /**
505
+ * Checks whether a value is a keyed-state error of either category.
506
+ *
507
+ * Name-branded: it matches on the error's `name`, so it recognizes state errors
508
+ * across both category classes (and across realms/duplicate module copies)
509
+ * without caring which category the error carries. It uses a realm-neutral
510
+ * native-error check so an error minted in another realm (a Node vm context,
511
+ * worker thread, or duplicate module copy) is still recognized — a bare
512
+ * `instanceof Error` would reject those.
513
+ *
514
+ * @param {unknown} error - The value to test.
515
+ * @returns {boolean} True when the value is a keyed-state error.
516
+ */
517
+ function isStateError(error) {
518
+ return types.isNativeError(error) && STATE_ERROR_NAMES.has(error.name);
519
+ }
520
+
442
521
  /**
443
522
  * Helper function to create error decorators.
444
523
  * @param {Function} ErrorClass - The error class to wrap exceptions with.
@@ -500,6 +579,722 @@ const transient = createErrorDecorator(TransientError);
500
579
  */
501
580
  const permanent = createErrorDecorator(PermanentError);
502
581
 
582
+ /**
583
+ * Injects the active OpenTelemetry context into a fresh carrier for one native
584
+ * operation. Native glue activates the carrier without recording a binding
585
+ * span, so the core semantic operation span joins the JavaScript trace directly.
586
+ * @returns {Record<string, string>} The populated carrier.
587
+ * @private
588
+ */
589
+ function injectedCarrier() {
590
+ const carrier = {};
591
+ propagation.inject(otelContext.active(), carrier);
592
+ return carrier;
593
+ }
594
+
595
+ /**
596
+ * Converts a category-tagged native state error into the matching typed state
597
+ * error. The category crosses the FFI boundary structurally: the native layer
598
+ * sets the error's `cause` to an error whose message is exactly `"permanent"`
599
+ * or `"transient"` — a data channel distinct from the human-readable message,
600
+ * which is never parsed. Untagged errors (e.g. an argument-conversion
601
+ * TypeError) pass through unchanged.
602
+ *
603
+ * The native-error checks are realm-neutral (`util.types.isNativeError`) rather
604
+ * than `instanceof Error`: a napi error is minted in the addon's realm, so under
605
+ * a Node vm context, worker thread, or duplicate module copy a plain
606
+ * `instanceof Error` would be false and the category tag would be missed. The
607
+ * category is still matched exactly against the closed `"permanent"`/
608
+ * `"transient"` token set on the cause.
609
+ * @param {unknown} error - The error thrown by the native layer.
610
+ * @returns {unknown} The typed state error, or the original error if untagged.
611
+ * @private
612
+ */
613
+ function toStateError(error) {
614
+ if (!types.isNativeError(error)) return error;
615
+ const cause = error.cause;
616
+ const category = types.isNativeError(cause) ? cause.message : undefined;
617
+ if (category !== "permanent" && category !== "transient") return error;
618
+ const wrapped =
619
+ category === "permanent"
620
+ ? new PermanentStateError(error.message)
621
+ : new TransientStateError(error.message);
622
+ wrapped.cause = error;
623
+ return wrapped;
624
+ }
625
+
626
+ /**
627
+ * Runs one async native state operation with a fresh carrier, translating a
628
+ * category-tagged failure into the matching typed state error.
629
+ * @param {(carrier: Record<string, string>) => Promise<*>} operation - The native call.
630
+ * @returns {Promise<*>} The operation's resolved value.
631
+ * @private
632
+ */
633
+ /**
634
+ * Renders a value for a diagnostic message without ever throwing. `JSON.stringify`
635
+ * throws on a BigInt or a cyclic object and a template literal throws on a
636
+ * Symbol, so interpolating a hostile argument directly would raise a raw
637
+ * `TypeError` — bypassing the promise/state-error contract. Falls back to the
638
+ * value's `typeof` when it cannot be serialized.
639
+ * @param {*} value - The value to describe.
640
+ * @returns {string} A safe, human-readable rendering.
641
+ * @private
642
+ */
643
+ function describeValue(value) {
644
+ try {
645
+ const rendered = JSON.stringify(value);
646
+ return rendered === undefined ? typeof value : rendered;
647
+ } catch {
648
+ return typeof value;
649
+ }
650
+ }
651
+
652
+ async function stateOp(operation) {
653
+ try {
654
+ return await operation(injectedCarrier());
655
+ } catch (error) {
656
+ throw toStateError(error);
657
+ }
658
+ }
659
+
660
+ /**
661
+ * Runs one synchronous native call (a handle vend or a scan open), translating
662
+ * a category-tagged failure into the matching typed state error.
663
+ * @param {() => *} operation - The native call.
664
+ * @returns {*} The operation's return value.
665
+ * @private
666
+ */
667
+ function stateSync(operation) {
668
+ try {
669
+ return operation();
670
+ } catch (error) {
671
+ throw toStateError(error);
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Builds a frozen state-collection definition. The definition is the single
677
+ * source of typing: the same frozen object is placed in
678
+ * `Configuration.stateCollections` (so the collection is registered once) and
679
+ * passed to `Context.state()` to vend a typed handle. Validation (name/ttl/
680
+ * keyset rules, duplicate names) is core-owned and happens at client
681
+ * construction and registration — this layer only shapes and freezes.
682
+ * @param {string} name - The collection name.
683
+ * @param {string} kind - `"value"`, `"map"`, or `"deque"`.
684
+ * @param {string} payload - `"json"` or `"message"`.
685
+ * @param {object} [options] - Optional ttlSeconds / readUncommitted, map-only
686
+ * keysetLimit, and deque-only capacity.
687
+ * @returns {Readonly<object>} The frozen definition.
688
+ * @private
689
+ */
690
+ function stateDefinition(name, kind, payload, options = {}) {
691
+ const definition = { name, kind, payload };
692
+ if (options.ttlSeconds !== undefined)
693
+ definition.ttlSeconds = options.ttlSeconds;
694
+ if (options.readUncommitted !== undefined)
695
+ definition.readUncommitted = options.readUncommitted;
696
+ if (options.keysetLimit !== undefined)
697
+ definition.keysetLimit = options.keysetLimit;
698
+ if (options.capacity !== undefined) definition.capacity = options.capacity;
699
+ return Object.freeze(definition);
700
+ }
701
+
702
+ /**
703
+ * Declares a single-value JSON collection. The type parameter annotates the
704
+ * stored value; it is compile-time only — payloads cross as plain JSON with no
705
+ * runtime validation.
706
+ * @param {string} name - The collection name (unique per client).
707
+ * @param {object} [options] - `ttlSeconds` (whole seconds) and `readUncommitted`.
708
+ * @returns {Readonly<object>} A frozen definition for `stateCollections` and `state()`.
709
+ */
710
+ function value(name, options) {
711
+ return stateDefinition(name, "value", "json", options);
712
+ }
713
+
714
+ /**
715
+ * Declares an ordered-map JSON collection. Map keys are always strings; the
716
+ * type parameter annotates the stored value (compile-time only).
717
+ * @param {string} name - The collection name (unique per client).
718
+ * @param {object} [options] - `ttlSeconds`, `readUncommitted`, and map-only `keysetLimit`.
719
+ * @returns {Readonly<object>} A frozen definition for `stateCollections` and `state()`.
720
+ */
721
+ function map(name, options) {
722
+ return stateDefinition(name, "map", "json", options);
723
+ }
724
+
725
+ /**
726
+ * Declares a double-ended-queue JSON collection. The type parameter annotates
727
+ * the stored element (compile-time only).
728
+ * @param {string} name - The collection name (unique per client).
729
+ * @param {object} [options] - `ttlSeconds` (whole seconds), `readUncommitted`,
730
+ * and deque-only `capacity` (bounded backlog; enforced lazily on push).
731
+ * @returns {Readonly<object>} A frozen definition for `stateCollections` and `state()`.
732
+ */
733
+ function deque(name, options) {
734
+ return stateDefinition(name, "deque", "json", options);
735
+ }
736
+
737
+ /**
738
+ * Declares a single-value message collection: each stored item is the full
739
+ * Kafka {@link Message} the handler received. The type parameter annotates the
740
+ * message payload (compile-time only).
741
+ * @param {string} name - The collection name (unique per client).
742
+ * @param {object} [options] - `ttlSeconds` (whole seconds) and `readUncommitted`.
743
+ * @returns {Readonly<object>} A frozen definition for `stateCollections` and `state()`.
744
+ */
745
+ function messageValue(name, options) {
746
+ return stateDefinition(name, "value", "message", options);
747
+ }
748
+
749
+ /**
750
+ * Declares an ordered-map message collection. Map keys are always strings; each
751
+ * stored value is the full Kafka {@link Message}. The type parameter annotates
752
+ * the message payload (compile-time only).
753
+ * @param {string} name - The collection name (unique per client).
754
+ * @param {object} [options] - `ttlSeconds`, `readUncommitted`, and map-only `keysetLimit`.
755
+ * @returns {Readonly<object>} A frozen definition for `stateCollections` and `state()`.
756
+ */
757
+ function messageMap(name, options) {
758
+ return stateDefinition(name, "map", "message", options);
759
+ }
760
+
761
+ /**
762
+ * Declares a double-ended-queue message collection: each stored element is the
763
+ * full Kafka {@link Message}. The type parameter annotates the message payload
764
+ * (compile-time only).
765
+ * @param {string} name - The collection name (unique per client).
766
+ * @param {object} [options] - `ttlSeconds` (whole seconds), `readUncommitted`,
767
+ * and deque-only `capacity` (bounded backlog; enforced lazily on push).
768
+ * @returns {Readonly<object>} A frozen definition for `stateCollections` and `state()`.
769
+ */
770
+ function messageDeque(name, options) {
771
+ return stateDefinition(name, "deque", "message", options);
772
+ }
773
+
774
+ /**
775
+ * Adapts a chunked native scan cursor to the item-oriented JS async-iterator
776
+ * protocol. A fresh carrier is propagated per native chunk without recording
777
+ * a span, while individual `next()` calls drain the retained chunk without
778
+ * crossing N-API.
779
+ * Native `null` (exhausted) maps to `{ done: true }`. Early exit from a `for await` loop
780
+ * (`break`/`return`/`throw`) invokes `return()`, which awaits the native
781
+ * `close()`; exhaustion and a pull error also close the cursor. Once finished,
782
+ * the cursor is never touched again. A pull error is never masked by the
783
+ * cleanup close (that close is best-effort). A close failure on the exhaustion
784
+ * or early-exit path is normalized through `toStateError`, so every keyed-state
785
+ * failure a caller can observe is a `PermanentStateError`/`TransientStateError`.
786
+ *
787
+ * The iterator is valid only within the handler invocation (attempt) that
788
+ * opened it; a cursor leaked past the attempt errors on its next pull.
789
+ * @param {object} cursor - The native scan cursor.
790
+ * @param {(item: *) => *} transform - Maps each raw item to the yielded value.
791
+ * @returns {AsyncIterableIterator<*>} The async iterator.
792
+ * @private
793
+ */
794
+ function stateIterator(cursor, transform) {
795
+ let finished = false;
796
+ let chunk = [];
797
+ let offset = 0;
798
+ let queue = Promise.resolve();
799
+ // Serialize the complete iterator protocol, not just native pulls. Without
800
+ // this queue, concurrent next() continuations can both reset `offset` after
801
+ // awaiting the same chunk and yield the same first item. return() shares the
802
+ // queue so it cannot close the cursor underneath an active next().
803
+ const enqueue = (operation) => {
804
+ const result = queue.then(operation, operation);
805
+ // A rejected operation must not poison later cleanup or done checks.
806
+ queue = result.then(
807
+ () => undefined,
808
+ () => undefined,
809
+ );
810
+ return result;
811
+ };
812
+ // Best-effort close used after a pull or transform failure: it must never
813
+ // mask the primary error. `try/catch` (not `.catch()`) so a synchronous throw
814
+ // from `close()` is swallowed too.
815
+ const closeQuietly = async () => {
816
+ try {
817
+ await cursor.close();
818
+ } catch {
819
+ /* the primary error is already propagating */
820
+ }
821
+ };
822
+ // Close on clean exhaustion / early exit, where there is no primary error to
823
+ // mask: a close failure surfaces through the state-error model.
824
+ const closeOrThrow = async () => {
825
+ try {
826
+ await cursor.close();
827
+ } catch (error) {
828
+ throw toStateError(error);
829
+ }
830
+ };
831
+ return {
832
+ next() {
833
+ return enqueue(async () => {
834
+ if (finished) return { value: undefined, done: true };
835
+ while (offset >= chunk.length) {
836
+ chunk = [];
837
+ offset = 0;
838
+ try {
839
+ chunk = await cursor.nextChunk(injectedCarrier());
840
+ } catch (error) {
841
+ finished = true;
842
+ await closeQuietly();
843
+ throw toStateError(error);
844
+ }
845
+ if (chunk === null) {
846
+ chunk = [];
847
+ finished = true;
848
+ await closeOrThrow();
849
+ return { value: undefined, done: true };
850
+ }
851
+ }
852
+ const item = chunk[offset];
853
+ // Release consumed values even while the rest of a large chunk remains.
854
+ chunk[offset] = undefined;
855
+ offset += 1;
856
+ try {
857
+ return { value: transform(item), done: false };
858
+ } catch (error) {
859
+ // A transform failure is a binding defect, not a store error; close the
860
+ // cursor and mark the iterator done rather than leaving it live.
861
+ finished = true;
862
+ await closeQuietly();
863
+ throw error;
864
+ }
865
+ });
866
+ },
867
+ return(value) {
868
+ return enqueue(async () => {
869
+ if (!finished) {
870
+ finished = true;
871
+ chunk = [];
872
+ offset = 0;
873
+ await closeOrThrow();
874
+ }
875
+ return { value, done: true };
876
+ });
877
+ },
878
+ [Symbol.asyncIterator]() {
879
+ return this;
880
+ },
881
+ };
882
+ }
883
+
884
+ /**
885
+ * Typed handle over a single-value keyed-state collection, vended by
886
+ * {@link Context#state}. Core records one semantic span per operation; this
887
+ * binding propagates context without adding an N-API span. Handles are valid
888
+ * only within the handler invocation (attempt) that vended them.
889
+ */
890
+ class ValueState {
891
+ /**
892
+ * @param {import('./bindings').NativeValueState} native - The vended native handle.
893
+ */
894
+ constructor(native) {
895
+ this.native = native;
896
+ }
897
+
898
+ /**
899
+ * Reads the current value.
900
+ * @returns {Promise<*|null>} The stored value, or null when absent/cleared.
901
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
902
+ */
903
+ get() {
904
+ return stateOp((carrier) => this.native.get(carrier));
905
+ }
906
+
907
+ /**
908
+ * Buffers a write of the value. Writing JSON `null` (or an unrepresentable
909
+ * value) is a caller mistake, rejected with a {@link TransientStateError}
910
+ * naming `clear()` — use {@link ValueState#clear} to delete instead. The
911
+ * error is transient so it retries and stays visible rather than discarding
912
+ * the message and losing data.
913
+ * @param {*} value - The value to store.
914
+ * @returns {Promise<void>}
915
+ * @throws {TransientStateError} On a null/unrepresentable/shape mistake or a
916
+ * transient store failure; {@link PermanentStateError} only if the store
917
+ * reports one.
918
+ */
919
+ set(value) {
920
+ return stateOp((carrier) => this.native.set(value, carrier));
921
+ }
922
+
923
+ /**
924
+ * Deletes the stored value.
925
+ * @returns {Promise<void>}
926
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
927
+ */
928
+ clear() {
929
+ return stateOp((carrier) => this.native.clear(carrier));
930
+ }
931
+
932
+ /**
933
+ * Durably commits the buffered operations mid-handler (at-least-once; the
934
+ * committed floor survives a later rollback or a failed event).
935
+ * @returns {Promise<void>} Resolves with no value — the erased seam drops the outcome.
936
+ * @throws {PermanentStateError|TransientStateError} On a categorized commit failure.
937
+ */
938
+ commit() {
939
+ return stateOp((carrier) => this.native.commit(carrier));
940
+ }
941
+
942
+ /**
943
+ * Discards buffered uncommitted operations back to the last committed floor.
944
+ * @returns {Promise<void>} Resolves with no value.
945
+ */
946
+ rollback() {
947
+ return stateOp((carrier) => this.native.rollback(carrier));
948
+ }
949
+ }
950
+
951
+ /**
952
+ * Typed handle over an ordered-map keyed-state collection, vended by
953
+ * {@link Context#state}. Map keys are always strings. Core records one semantic
954
+ * span per operation; this binding propagates context without adding an N-API
955
+ * span. Handles and iterators are valid only within the handler invocation.
956
+ */
957
+ class MapState {
958
+ /**
959
+ * @param {import('./bindings').NativeMapState} native - The vended native handle.
960
+ */
961
+ constructor(native) {
962
+ this.native = native;
963
+ }
964
+
965
+ /**
966
+ * Reads the value for `key`.
967
+ * @param {string} key - The map key.
968
+ * @returns {Promise<*|null>} The value, or null when the key is absent.
969
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
970
+ */
971
+ get(key) {
972
+ return stateOp((carrier) => this.native.get(key, carrier));
973
+ }
974
+
975
+ /**
976
+ * Reads several keys in a single call. Returns an array with one entry per
977
+ * key, in the same order you asked, so `result[i]` is the value for
978
+ * `keys[i]`. A key that isn't there comes back as `null`, and a key you list
979
+ * more than once is answered at each spot. The whole read happens as one
980
+ * step, so no other change to this event's state can slip in partway through.
981
+ * @param {string[]} keys - The keys to read, in order.
982
+ * @returns {Promise<Array<*|null>>} One entry per key, in the order asked.
983
+ * @throws {PermanentStateError|TransientStateError} If the read fails.
984
+ */
985
+ getMany(keys) {
986
+ return stateOp((carrier) => this.native.getMany(keys, carrier));
987
+ }
988
+
989
+ /**
990
+ * Reports whether `key` currently has a stored value. A presence check that
991
+ * skips the value decode and the resolver: for a message-backed map it
992
+ * answers with zero Kafka fetches and can report `true` for a message that
993
+ * can no longer be fetched. Not zero-I/O — a cache miss can still reach the
994
+ * store — but cheaper than {@link MapState#get} when you only need presence.
995
+ * @param {string} key - The map key.
996
+ * @returns {Promise<boolean>} True when the key is present.
997
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
998
+ */
999
+ has(key) {
1000
+ return stateOp((carrier) => this.native.contains(key, carrier));
1001
+ }
1002
+
1003
+ /**
1004
+ * Inserts or overwrites `key`. Writing JSON `null` (or an unrepresentable
1005
+ * value) is a caller mistake, rejected with a {@link TransientStateError} —
1006
+ * use {@link MapState#delete} to remove an entry instead. The error is
1007
+ * transient so it retries and stays visible rather than discarding the
1008
+ * message and losing data.
1009
+ * @param {string} key - The map key.
1010
+ * @param {*} value - The value to store.
1011
+ * @returns {Promise<void>}
1012
+ * @throws {TransientStateError} On a null/unrepresentable/shape mistake or a
1013
+ * transient store failure; {@link PermanentStateError} only if the store
1014
+ * reports one.
1015
+ */
1016
+ set(key, value) {
1017
+ return stateOp((carrier) => this.native.set(key, value, carrier));
1018
+ }
1019
+
1020
+ /**
1021
+ * Removes `key`.
1022
+ *
1023
+ * Deliberate divergence from `Map#delete`: this returns void, NOT a boolean
1024
+ * "was present" flag — surfacing that boolean would force a hidden read on
1025
+ * every delete. The underlying native operation is named `remove`, which is
1026
+ * the verb core's null-write rejection message uses.
1027
+ * @param {string} key - The map key.
1028
+ * @returns {Promise<void>}
1029
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1030
+ */
1031
+ delete(key) {
1032
+ return stateOp((carrier) => this.native.remove(key, carrier));
1033
+ }
1034
+
1035
+ /**
1036
+ * Removes every entry.
1037
+ * @returns {Promise<void>}
1038
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1039
+ */
1040
+ clear() {
1041
+ return stateOp((carrier) => this.native.clear(carrier));
1042
+ }
1043
+
1044
+ /**
1045
+ * Opens an async iterator over the live entries in key order. Each yielded
1046
+ * item is a `[key, value]` pair. Valid only within the handler invocation
1047
+ * (attempt) that opened it; early exit closes the underlying cursor.
1048
+ * @param {"forward"|"backward"} [direction="forward"] - The scan direction.
1049
+ * @returns {AsyncIterableIterator<[string, *]>} The entries iterator.
1050
+ * @throws {TransientStateError} If the direction token is invalid (a caller
1051
+ * mistake — retries, not discarded).
1052
+ */
1053
+ entries(direction = "forward") {
1054
+ return stateIterator(
1055
+ stateSync(() => this.native.scan(direction, injectedCarrier())),
1056
+ (entry) => entry,
1057
+ );
1058
+ }
1059
+
1060
+ /**
1061
+ * Opens an async iterator over the live keys in key order. Skips the value
1062
+ * decode and the resolver, so a message-backed map enumerates keys with zero
1063
+ * Kafka fetches; it still reads presence, so it is not zero-I/O. Valid only
1064
+ * within the handler invocation (attempt) that opened it; early exit closes
1065
+ * the underlying cursor.
1066
+ * @param {"forward"|"backward"} [direction="forward"] - The scan direction.
1067
+ * @returns {AsyncIterableIterator<string>} The keys iterator.
1068
+ * @throws {TransientStateError} If the direction token is invalid (a caller
1069
+ * mistake — retries, not discarded).
1070
+ */
1071
+ keys(direction = "forward") {
1072
+ return stateIterator(
1073
+ stateSync(() => this.native.keys(direction, injectedCarrier())),
1074
+ (key) => key,
1075
+ );
1076
+ }
1077
+
1078
+ /**
1079
+ * Opens an async iterator over the live values in key order. Valid only
1080
+ * within the handler invocation (attempt) that opened it; early exit closes
1081
+ * the underlying cursor.
1082
+ * @param {"forward"|"backward"} [direction="forward"] - The scan direction.
1083
+ * @returns {AsyncIterableIterator<*>} The values iterator.
1084
+ * @throws {TransientStateError} If the direction token is invalid (a caller
1085
+ * mistake — retries, not discarded).
1086
+ */
1087
+ values(direction = "forward") {
1088
+ return stateIterator(
1089
+ stateSync(() => this.native.scan(direction, injectedCarrier())),
1090
+ (entry) => entry[1],
1091
+ );
1092
+ }
1093
+
1094
+ /**
1095
+ * Forward iteration over `[key, value]` entries — equivalent to
1096
+ * `entries("forward")`. Valid only within the handler invocation (attempt).
1097
+ * @returns {AsyncIterableIterator<[string, *]>} The entries iterator.
1098
+ */
1099
+ [Symbol.asyncIterator]() {
1100
+ return this.entries();
1101
+ }
1102
+
1103
+ /**
1104
+ * Durably commits the buffered operations mid-handler (at-least-once; the
1105
+ * committed floor survives a later rollback or a failed event).
1106
+ * @returns {Promise<void>} Resolves with no value — the erased seam drops the outcome.
1107
+ * @throws {PermanentStateError|TransientStateError} On a categorized commit failure.
1108
+ */
1109
+ commit() {
1110
+ return stateOp((carrier) => this.native.commit(carrier));
1111
+ }
1112
+
1113
+ /**
1114
+ * Discards buffered uncommitted operations back to the last committed floor.
1115
+ * @returns {Promise<void>} Resolves with no value.
1116
+ */
1117
+ rollback() {
1118
+ return stateOp((carrier) => this.native.rollback(carrier));
1119
+ }
1120
+ }
1121
+
1122
+ /**
1123
+ * Typed handle over a double-ended-queue keyed-state collection, vended by
1124
+ * {@link Context#state}. Core records one semantic span per operation; this
1125
+ * binding propagates context without adding an N-API span. Handles and
1126
+ * iterators are valid only within the handler invocation that vended them.
1127
+ */
1128
+ class DequeState {
1129
+ /**
1130
+ * @param {import('./bindings').NativeDequeState} native - The vended native handle.
1131
+ */
1132
+ constructor(native) {
1133
+ this.native = native;
1134
+ }
1135
+
1136
+ /**
1137
+ * Appends an element at the back. Writing JSON `null` is a caller mistake
1138
+ * rejected with a {@link TransientStateError}, so an uncaught handler bug
1139
+ * retries instead of committing the offset and losing the message.
1140
+ * @param {*} item - The element to append.
1141
+ * @returns {Promise<void>}
1142
+ * @throws {TransientStateError} If `item` is JSON `null` or otherwise invalid.
1143
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1144
+ */
1145
+ push(item) {
1146
+ return stateOp((carrier) => this.native.pushBack(item, carrier));
1147
+ }
1148
+
1149
+ /**
1150
+ * Prepends an element at the front. Writing JSON `null` is a caller mistake
1151
+ * rejected with a {@link TransientStateError}, so an uncaught handler bug
1152
+ * retries instead of committing the offset and losing the message.
1153
+ * @param {*} item - The element to prepend.
1154
+ * @returns {Promise<void>}
1155
+ * @throws {TransientStateError} If `item` is JSON `null` or otherwise invalid.
1156
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1157
+ */
1158
+ unshift(item) {
1159
+ return stateOp((carrier) => this.native.pushFront(item, carrier));
1160
+ }
1161
+
1162
+ /**
1163
+ * Removes and returns the back element.
1164
+ * @returns {Promise<*|null>} The removed element, or null when empty.
1165
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1166
+ */
1167
+ pop() {
1168
+ return stateOp((carrier) => this.native.popBack(carrier));
1169
+ }
1170
+
1171
+ /**
1172
+ * Removes and returns the front element.
1173
+ * @returns {Promise<*|null>} The removed element, or null when empty.
1174
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1175
+ */
1176
+ shift() {
1177
+ return stateOp((carrier) => this.native.popFront(carrier));
1178
+ }
1179
+
1180
+ /**
1181
+ * Returns the number of live elements.
1182
+ * @returns {Promise<number>} The element count.
1183
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1184
+ */
1185
+ length() {
1186
+ return stateOp((carrier) => this.native.len(carrier));
1187
+ }
1188
+
1189
+ /**
1190
+ * Reports whether the deque holds no live elements.
1191
+ * @returns {Promise<boolean>} True when the deque is empty.
1192
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1193
+ */
1194
+ isEmpty() {
1195
+ return stateOp((carrier) => this.native.isEmpty(carrier));
1196
+ }
1197
+
1198
+ /**
1199
+ * Removes every element.
1200
+ * @returns {Promise<void>}
1201
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1202
+ */
1203
+ clear() {
1204
+ return stateOp((carrier) => this.native.clear(carrier));
1205
+ }
1206
+
1207
+ /**
1208
+ * Reads the element at `index`, like `Array.prototype.at`. A non-negative
1209
+ * `index` counts from the front (`0` is the front element); a negative
1210
+ * `index` counts back from the end (`-1` is the back element). Any in-range
1211
+ * position resolves to its element; any out-of-range position — including
1212
+ * every index on an empty deque — resolves to null, the same absence sentinel
1213
+ * `pop`/`shift` use.
1214
+ *
1215
+ * `index` must be a safe integer; a fractional, `NaN`, or infinite value is a
1216
+ * caller mistake, rejected with a {@link TransientStateError} so it retries
1217
+ * and stays visible rather than silently reading the wrong element (a bare
1218
+ * `u32` conversion would truncate `1.5` to `1`). The error is transient — a
1219
+ * caller mistake never discards the message.
1220
+ *
1221
+ * The endpoints `at(0)` and `at(-1)` ride the front/back peeks — a single
1222
+ * read, the same core primitive the other clients use; `at(-1)` makes no
1223
+ * length read. Any other negative index is resolved against the current
1224
+ * {@link DequeState#length}, so it makes two boundary crossings (a length
1225
+ * read, then the element read); any other non-negative index makes one.
1226
+ * Within a handler attempt the deque has a single owner, so nothing else
1227
+ * mutates it between the two reads.
1228
+ * @param {number} index - The position: front-relative if `>= 0`, else back-relative.
1229
+ * @returns {Promise<*|null>} The element, or null when the position is out of range.
1230
+ * @throws {TransientStateError} On an index mistake or a transient store
1231
+ * failure; {@link PermanentStateError} only if the store reports one.
1232
+ */
1233
+ async at(index) {
1234
+ if (!Number.isSafeInteger(index)) {
1235
+ throw new TransientStateError(
1236
+ `at: index must be a safe integer, got ${describeValue(index)}`,
1237
+ );
1238
+ }
1239
+ if (index === 0)
1240
+ return stateOp((carrier) => this.native.peekFront(carrier));
1241
+ if (index === -1)
1242
+ return stateOp((carrier) => this.native.peekBack(carrier));
1243
+ let position = index;
1244
+ if (position < 0) {
1245
+ position += await this.length();
1246
+ // Still negative: the deque is shorter than |index|, so nothing is there.
1247
+ if (position < 0) return null;
1248
+ }
1249
+ // Beyond the addressable u32 range can only be past the end, never a wrap.
1250
+ if (position > 0xffffffff) return null;
1251
+ return stateOp((carrier) => this.native.get(position, carrier));
1252
+ }
1253
+
1254
+ /**
1255
+ * Opens an async iterator over the live elements in index order. Valid only
1256
+ * within the handler invocation (attempt) that opened it; early exit closes
1257
+ * the underlying cursor.
1258
+ * @param {"forward"|"backward"} [direction="forward"] - The scan direction.
1259
+ * @returns {AsyncIterableIterator<*>} The values iterator.
1260
+ * @throws {TransientStateError} If the direction token is invalid (a caller
1261
+ * mistake — retries, not discarded).
1262
+ */
1263
+ values(direction = "forward") {
1264
+ return stateIterator(
1265
+ stateSync(() => this.native.scan(direction, injectedCarrier())),
1266
+ (item) => item,
1267
+ );
1268
+ }
1269
+
1270
+ /**
1271
+ * Forward iteration over the elements — equivalent to `values("forward")`.
1272
+ * Valid only within the handler invocation (attempt).
1273
+ * @returns {AsyncIterableIterator<*>} The values iterator.
1274
+ */
1275
+ [Symbol.asyncIterator]() {
1276
+ return this.values();
1277
+ }
1278
+
1279
+ /**
1280
+ * Durably commits the buffered operations mid-handler (at-least-once; the
1281
+ * committed floor survives a later rollback or a failed event).
1282
+ * @returns {Promise<void>} Resolves with no value — the erased seam drops the outcome.
1283
+ * @throws {PermanentStateError|TransientStateError} On a categorized commit failure.
1284
+ */
1285
+ commit() {
1286
+ return stateOp((carrier) => this.native.commit(carrier));
1287
+ }
1288
+
1289
+ /**
1290
+ * Discards buffered uncommitted operations back to the last committed floor.
1291
+ * @returns {Promise<void>} Resolves with no value.
1292
+ */
1293
+ rollback() {
1294
+ return stateOp((carrier) => this.native.rollback(carrier));
1295
+ }
1296
+ }
1297
+
503
1298
  /**
504
1299
  * Context class that automatically injects OpenTelemetry context for all operations.
505
1300
  * This wraps the native Context with automatic OTEL context propagation.
@@ -507,6 +1302,10 @@ const permanent = createErrorDecorator(PermanentError);
507
1302
  class Context {
508
1303
  constructor(nativeContext) {
509
1304
  this.nativeContext = nativeContext;
1305
+ // Cache of vended state wrappers, keyed by collection name (names are
1306
+ // unique per registration), so repeated state(def) calls within one event
1307
+ // return the same handle.
1308
+ this.stateHandles = new Map();
510
1309
  }
511
1310
 
512
1311
  /**
@@ -534,9 +1333,7 @@ class Context {
534
1333
  * @throws {Error} If time conversion or scheduling fails.
535
1334
  */
536
1335
  async schedule(time) {
537
- const carrier = {};
538
- propagation.inject(otelContext.active(), carrier);
539
- return this.nativeContext.schedule(time, carrier);
1336
+ return this.nativeContext.schedule(time, injectedCarrier());
540
1337
  }
541
1338
 
542
1339
  /**
@@ -546,9 +1343,7 @@ class Context {
546
1343
  * @throws {Error} If time conversion or scheduling fails.
547
1344
  */
548
1345
  async clearAndSchedule(time) {
549
- const carrier = {};
550
- propagation.inject(otelContext.active(), carrier);
551
- return this.nativeContext.clearAndSchedule(time, carrier);
1346
+ return this.nativeContext.clearAndSchedule(time, injectedCarrier());
552
1347
  }
553
1348
 
554
1349
  /**
@@ -558,9 +1353,7 @@ class Context {
558
1353
  * @throws {Error} If unscheduling fails.
559
1354
  */
560
1355
  async unschedule(time) {
561
- const carrier = {};
562
- propagation.inject(otelContext.active(), carrier);
563
- return this.nativeContext.unschedule(time, carrier);
1356
+ return this.nativeContext.unschedule(time, injectedCarrier());
564
1357
  }
565
1358
 
566
1359
  /**
@@ -569,9 +1362,7 @@ class Context {
569
1362
  * @throws {Error} If clearing schedules fails.
570
1363
  */
571
1364
  async clearScheduled() {
572
- const carrier = {};
573
- propagation.inject(otelContext.active(), carrier);
574
- return this.nativeContext.clearScheduled(carrier);
1365
+ return this.nativeContext.clearScheduled(injectedCarrier());
575
1366
  }
576
1367
 
577
1368
  /**
@@ -580,25 +1371,122 @@ class Context {
580
1371
  * @throws {Error} If retrieval fails.
581
1372
  */
582
1373
  async scheduled() {
583
- const carrier = {};
584
- propagation.inject(otelContext.active(), carrier);
585
- return this.nativeContext.scheduled(carrier);
1374
+ return this.nativeContext.scheduled(injectedCarrier());
1375
+ }
1376
+
1377
+ /**
1378
+ * Binds a registered keyed-state collection for this event and returns a
1379
+ * typed handle over it.
1380
+ *
1381
+ * Pass a definition built by one of the definition constructors ({@link value},
1382
+ * {@link map}, {@link deque}, {@link messageValue}, {@link messageMap},
1383
+ * {@link messageDeque}) — the same frozen object placed in
1384
+ * `Configuration.stateCollections`. The returned handle (and any iterator it
1385
+ * opens) is scoped to this single event attempt; do not retain it past the
1386
+ * handler invocation. Handles are cached per context by definition identity
1387
+ * (kind, payload, and name), so repeated calls for the same definition return
1388
+ * the same wrapper; a mismatched definition reusing a name misses the cache
1389
+ * and is rejected core-side at vend.
1390
+ *
1391
+ * @param {object} definition - A frozen definition from a definition constructor.
1392
+ * @returns {ValueState|MapState|DequeState} The typed state handle.
1393
+ * @throws {TransientStateError} If the definition is malformed — a missing or
1394
+ * non-string `name`, or an unrecognized `kind`/`payload` (a caller mistake,
1395
+ * so transient rather than a message-discarding permanent).
1396
+ * @throws {PermanentStateError} If the collection name is unregistered or its
1397
+ * durably-registered schema (kind/payload) mismatches (rejected core-side).
1398
+ */
1399
+ state(definition) {
1400
+ let name, kind, payload;
1401
+ try {
1402
+ // Read + validate the definition shape. Wrapped so a hostile definition
1403
+ // (e.g. throwing property getters) still surfaces as a classified caller
1404
+ // mistake rather than a raw synchronous throw.
1405
+ ({ name, kind, payload } = definition ?? {});
1406
+ if (typeof name !== "string" || name.length === 0) {
1407
+ throw new TransientStateError(
1408
+ `state: definition.name must be a non-empty string, got ${describeValue(name)}`,
1409
+ );
1410
+ }
1411
+ if (kind !== "value" && kind !== "map" && kind !== "deque") {
1412
+ throw new TransientStateError(
1413
+ `state: unknown collection kind ${describeValue(kind)}`,
1414
+ );
1415
+ }
1416
+ if (payload !== "json" && payload !== "message") {
1417
+ throw new TransientStateError(
1418
+ `state: unknown collection payload ${describeValue(payload)}`,
1419
+ );
1420
+ }
1421
+ } catch (error) {
1422
+ if (error instanceof EventHandlerError) throw error;
1423
+ throw new TransientStateError(
1424
+ "state: the definition could not be read (malformed or hostile object)",
1425
+ );
1426
+ }
1427
+ const cacheKey = `${kind}:${payload}:${name}`;
1428
+ const cached = this.stateHandles.get(cacheKey);
1429
+ if (cached !== undefined) return cached;
1430
+ const message = payload === "message";
1431
+ let handle;
1432
+ switch (kind) {
1433
+ case "value":
1434
+ handle = new ValueState(
1435
+ stateSync(() =>
1436
+ message
1437
+ ? this.nativeContext.messageValueState(name)
1438
+ : this.nativeContext.valueState(name),
1439
+ ),
1440
+ );
1441
+ break;
1442
+ case "map":
1443
+ handle = new MapState(
1444
+ stateSync(() =>
1445
+ message
1446
+ ? this.nativeContext.messageMapState(name)
1447
+ : this.nativeContext.mapState(name),
1448
+ ),
1449
+ );
1450
+ break;
1451
+ default:
1452
+ handle = new DequeState(
1453
+ stateSync(() =>
1454
+ message
1455
+ ? this.nativeContext.messageDequeState(name)
1456
+ : this.nativeContext.dequeState(name),
1457
+ ),
1458
+ );
1459
+ }
1460
+ this.stateHandles.set(cacheKey, handle);
1461
+ return handle;
586
1462
  }
587
1463
  }
588
1464
 
589
1465
  module.exports = {
590
1466
  ConsumerState,
591
1467
  Context,
1468
+ DequeState,
592
1469
  EventHandlerError,
1470
+ MapState,
593
1471
  Mode,
594
1472
  PermanentError,
1473
+ PermanentStateError,
595
1474
  ProsodyClient,
596
1475
  TransientError,
1476
+ TransientStateError,
1477
+ ValueState,
1478
+ deque,
597
1479
  getCurrentLogger,
598
1480
  initialize,
1481
+ isStateError,
599
1482
  loggerIsSet,
1483
+ map,
1484
+ messageDeque,
1485
+ messageMap,
1486
+ messageValue,
600
1487
  permanent,
601
1488
  setLogger,
602
1489
  setLoggerIfUnset,
603
1490
  transient,
1491
+ value,
604
1492
  };