@prosody-events/prosody 0.3.1 → 0.5.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.
Files changed (6) hide show
  1. package/README.md +657 -353
  2. package/bindings.d.ts +780 -48
  3. package/bindings.js +226 -83
  4. package/index.d.ts +706 -22
  5. package/index.js +1446 -105
  6. package/package.json +23 -20
package/index.js CHANGED
@@ -15,19 +15,23 @@
15
15
 
16
16
  /**
17
17
  * @typedef {Object} EventHandler
18
- * @property {Function} [onMessage] - Async callback function to handle incoming messages. Receives (context, message, signal).
19
- * @property {Function} [onTimer] - Async callback function to handle timer events. Receives (context, timer, signal).
18
+ * @property {Function} onMessage - Handles a message and returns its response.
19
+ * @property {Function} onExcise - Handles an excise record and returns its response.
20
+ * @property {Function} onTimer - Handles a timer and returns no value.
20
21
  */
21
22
 
22
23
  /**
23
24
  * @typedef {import('./bindings').Configuration} Configuration
24
25
  * @typedef {import('./bindings').ConsumerState} ConsumerState
25
26
  * @typedef {import('./bindings').Context} Context
27
+ * @typedef {import('./bindings').ExciseMessage} ExciseMessage
26
28
  * @typedef {import('./bindings').Message} Message
27
29
  * @typedef {import('./bindings').Timer} Timer
28
30
  * @typedef {import('./bindings').Mode} Mode
29
31
  */
30
32
 
33
+ const { types } = require("node:util");
34
+
31
35
  const {
32
36
  context: otelContext,
33
37
  propagation,
@@ -36,14 +40,18 @@ const {
36
40
  } = require("@opentelemetry/api");
37
41
 
38
42
  const {
43
+ AdminClient,
44
+ Message: NativeMessage,
39
45
  Mode,
40
46
  NativeClient,
41
47
  ConsumerState,
42
48
  NativeContext,
43
49
  initialize,
50
+ flushTelemetry,
44
51
  loggerIsSet,
45
52
  setLogger: setLoggerInternal,
46
53
  setLoggerIfUnset: setLoggerIfUnsetInternal,
54
+ shutdownTelemetry,
47
55
  } = require("./bindings");
48
56
 
49
57
  let _sentry = undefined;
@@ -60,11 +68,10 @@ function getSentry() {
60
68
  return Sentry;
61
69
  } catch (err) {
62
70
  const isMissing =
63
- err?.code === "MODULE_NOT_FOUND" &&
64
- err.message?.includes("@sentry/node");
71
+ err?.code === "MODULE_NOT_FOUND" && err.message?.includes("@sentry/node");
65
72
  if (isMissing) {
66
73
  getCurrentLogger()?.error(
67
- "SENTRY_DSN is set but @sentry/node is not installed. Run: npm install @sentry/node"
74
+ "SENTRY_DSN is set but @sentry/node is not installed. Run: npm install @sentry/node",
68
75
  );
69
76
  } else {
70
77
  getCurrentLogger()?.warn("Unexpected error loading @sentry/node", err);
@@ -84,11 +91,26 @@ function captureException(error, eventType, context) {
84
91
  }
85
92
 
86
93
  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),
94
+ error: (message, metadata) =>
95
+ metadata !== undefined
96
+ ? console.error(message, metadata)
97
+ : console.error(message),
98
+ warn: (message, metadata) =>
99
+ metadata !== undefined
100
+ ? console.warn(message, metadata)
101
+ : console.warn(message),
102
+ info: (message, metadata) =>
103
+ metadata !== undefined
104
+ ? console.info(message, metadata)
105
+ : console.info(message),
106
+ debug: (message, metadata) =>
107
+ metadata !== undefined
108
+ ? console.debug(message, metadata)
109
+ : console.debug(message),
110
+ trace: (message, metadata) =>
111
+ metadata !== undefined
112
+ ? console.debug(message, metadata)
113
+ : console.debug(message),
92
114
  };
93
115
 
94
116
  // Keep reference to current logger for use in handlers
@@ -153,12 +175,23 @@ function setLoggerIfUnset(logger) {
153
175
  */
154
176
  class ProsodyClient {
155
177
  /**
156
- * Creates a new ProsodyClient instance.
178
+ * Use {@link ProsodyClient.create}.
179
+ * @private
180
+ */
181
+ constructor() {
182
+ throw new TypeError("Use await ProsodyClient.create(config)");
183
+ }
184
+
185
+ /**
186
+ * Creates a Prosody client without blocking the Node.js event loop.
157
187
  *
158
- * @param {Configuration} config - The configuration options for the client.
188
+ * @param {Configuration} config - The client configuration.
189
+ * @returns {Promise<ProsodyClient>} The initialized client.
159
190
  */
160
- constructor(config) {
161
- this.nativeClient = new NativeClient(config);
191
+ static async create(config) {
192
+ const client = Object.create(ProsodyClient.prototype);
193
+ client.nativeClient = await NativeClient.create(config);
194
+ return client;
162
195
  }
163
196
 
164
197
  /**
@@ -200,12 +233,36 @@ class ProsodyClient {
200
233
  return this.nativeClient.isStalled();
201
234
  }
202
235
 
236
+ /**
237
+ * Opens a read-only view of another consumer group's published collection.
238
+ * @param {string} subsystem - The publisher's subsystem.
239
+ * @param {Readonly<object>} definition - A JSON value, map, or deque definition.
240
+ * @returns {Promise<PublishedValue|PublishedMap|PublishedDeque>} The reader.
241
+ */
242
+ async state(subsystem, definition) {
243
+ const access = stateDefinitionAccess.get(definition);
244
+ if (access?.published === undefined) {
245
+ throw new TypeError(
246
+ "definition must be a JSON value, map, or deque definition",
247
+ );
248
+ }
249
+ const readCache = definition.readCache;
250
+ return access.published(
251
+ this.nativeClient,
252
+ subsystem,
253
+ definition.name,
254
+ readCache && readCache.ttlMs,
255
+ readCache === false,
256
+ );
257
+ }
258
+
203
259
  /**
204
260
  * Sends a message to a specified topic.
205
261
  *
206
262
  * @param {string} topic - The topic to send the message to.
207
263
  * @param {string} key - The key of the message.
208
- * @param {*} payload - The payload of the message.
264
+ * @param {*} payload - The payload of the message. Serialized here; Kafka
265
+ * receives the bytes verbatim.
209
266
  * @param {AbortSignal} [signal] - Optional abort signal to cancel the send operation. When aborted, the promise will reject with the abort reason.
210
267
  * @returns {Promise<void>} A promise that resolves when the message has been successfully sent.
211
268
  * @throws {Error} If the send operation fails or is aborted.
@@ -217,53 +274,143 @@ class ProsodyClient {
217
274
  await this.nativeClient.send(
218
275
  topic,
219
276
  key,
220
- payload,
277
+ toJson(payload, TransientError),
278
+ eventMetadata(payload),
279
+ carrier,
280
+ signal && onAbort(signal),
281
+ );
282
+ }
283
+
284
+ /**
285
+ * Sends an excise record for a key.
286
+ *
287
+ * @param {string} topic - The topic name.
288
+ * @param {string} key - The key to delete from compacted views.
289
+ * @param {AbortSignal} [signal] - An optional abort signal.
290
+ * @returns {Promise<void>} A promise that resolves after Prosody sends the record.
291
+ */
292
+ async excise(topic, key, signal) {
293
+ const carrier = {};
294
+ propagation.inject(otelContext.active(), carrier);
295
+ await this.nativeClient.excise(
296
+ topic,
297
+ key,
221
298
  carrier,
222
299
  signal && onAbort(signal),
223
300
  );
224
301
  }
225
302
 
303
+ /**
304
+ * Sends a request and returns one outcome for each subsystem.
305
+ *
306
+ * @param {string} topic - The Kafka topic.
307
+ * @param {string} key - The message key.
308
+ * @param {*} payload - The JSON request payload.
309
+ * @param {{subsystems: readonly string[], timeoutMs: number, signal?: AbortSignal}} options - Request policy.
310
+ * @returns {Promise<ReadonlyMap<string, {ok: true, value: *}|{ok: false, error: {kind: "handler"|"timeout"|"formatMismatch"|"malformedResponse", message: string}}>>} One outcome per subsystem.
311
+ * @throws {Error} If the input is invalid, sending fails, shutdown starts, or the signal aborts.
312
+ */
313
+ async request(topic, key, payload, options) {
314
+ const carrier = {};
315
+ propagation.inject(otelContext.active(), carrier);
316
+ const results = await this.nativeClient.request(
317
+ {
318
+ topic,
319
+ key,
320
+ payload: toJson(payload, TransientError),
321
+ metadata: eventMetadata(payload),
322
+ subsystems: options.subsystems,
323
+ timeoutMs: options.timeoutMs,
324
+ },
325
+ carrier,
326
+ options.signal && onAbort(options.signal),
327
+ );
328
+ return responseOutcomes(results);
329
+ }
330
+
331
+ /** Sends an excise request and returns one outcome for each subsystem. */
332
+ async requestExcise(topic, key, options) {
333
+ const carrier = {};
334
+ propagation.inject(otelContext.active(), carrier);
335
+ const results = await this.nativeClient.requestExcise(
336
+ {
337
+ topic,
338
+ key,
339
+ subsystems: options.subsystems,
340
+ timeoutMs: options.timeoutMs,
341
+ },
342
+ carrier,
343
+ options.signal && onAbort(options.signal),
344
+ );
345
+ return responseOutcomes(results);
346
+ }
347
+
226
348
  /**
227
349
  * Subscribes to receive messages using the provided event handler.
228
350
  *
229
351
  * @param {EventHandler} eventHandler - The event handler to process received messages and timers.
230
352
  * @returns {Promise<void>} A promise that resolves when the subscription is successfully established and the consumer is ready to receive messages.
353
+ * @throws {TypeError} If any required handler method is missing.
231
354
  * @throws {Error} If the subscription fails to establish.
232
355
  */
233
356
  async subscribe(eventHandler) {
357
+ for (const name of ["onMessage", "onExcise", "onTimer"]) {
358
+ if (typeof eventHandler?.[name] !== "function") {
359
+ throw new TypeError(`EventHandler.${name} must be a function`);
360
+ }
361
+ }
362
+
234
363
  const tracer = trace.getTracer("prosody");
235
- const {
236
- onMessage = (context, message, _signal) => {
237
- getCurrentLogger()?.error(
238
- "ProsodyClient: Received a message but no onMessage handler was " +
239
- "provided in subscribe(). To handle messages, implement the onMessage " +
240
- "method in your EventHandler:",
241
- {
242
- topic: message.topic,
243
- partition: message.partition,
244
- offset: message.offset,
245
- key: message.key,
246
- solution:
247
- "Add onMessage: async (context, message, signal) => " +
248
- "{ /* your logic here */ } to your subscribe() call",
249
- },
250
- );
251
- },
252
- onTimer = (context, timer, _signal) => {
253
- getCurrentLogger()?.error(
254
- "ProsodyClient: Received a timer event but no onTimer handler was " +
255
- "provided in subscribe(). To handle timers, implement the onTimer " +
256
- "method in your EventHandler:",
257
- {
258
- key: timer.key,
259
- time: timer.time,
260
- solution:
261
- "Add onTimer: async (context, timer, signal) => " +
262
- "{ /* your logic here */ } to your subscribe() call",
263
- },
264
- );
265
- },
266
- } = eventHandler;
364
+ const { onExcise, onMessage, onTimer } = eventHandler;
365
+ const handleRecord = async (
366
+ nativeContext,
367
+ message,
368
+ carrier,
369
+ handler,
370
+ spanName,
371
+ eventType,
372
+ ) => {
373
+ const ctx = propagation.extract(otelContext.active(), carrier);
374
+ return otelContext.with(ctx, () =>
375
+ tracer.startActiveSpan(spanName, async (span) => {
376
+ const controller = new AbortController();
377
+ let completed = false;
378
+ nativeContext.onCancel().then(() => {
379
+ if (!completed) {
380
+ span.setAttribute("cancelled", true);
381
+ controller.abort(new Error(`${eventType} cancelled`));
382
+ }
383
+ });
384
+
385
+ try {
386
+ const result = await handler(
387
+ new Context(nativeContext),
388
+ message,
389
+ controller.signal,
390
+ );
391
+ return toJson(result ?? null, PermanentError);
392
+ } catch (error) {
393
+ const cause = error.cause ?? error;
394
+ getCurrentLogger()?.error(`${eventType} handler error`, cause);
395
+ span.recordException(cause);
396
+ span.setStatus({
397
+ code: SpanStatusCode.ERROR,
398
+ message: cause.message,
399
+ });
400
+ captureException(error, eventType, {
401
+ topic: message.topic,
402
+ partition: message.partition,
403
+ key: message.key,
404
+ offset: message.offset,
405
+ });
406
+ throw error;
407
+ } finally {
408
+ completed = true;
409
+ span.end();
410
+ }
411
+ }),
412
+ );
413
+ };
267
414
 
268
415
  await this.nativeClient.subscribe({
269
416
  isPermanent: ([err]) => {
@@ -276,50 +423,34 @@ class ProsodyClient {
276
423
 
277
424
  onMessage: async (err, [nativeContext, message, carrier]) => {
278
425
  if (err) throw err;
426
+ return handleRecord(
427
+ nativeContext,
428
+ withParsedPayload(message),
429
+ carrier,
430
+ onMessage,
431
+ "onMessage",
432
+ "message",
433
+ );
434
+ },
279
435
 
280
- const ctx = propagation.extract(otelContext.active(), carrier);
281
- await otelContext.with(ctx, async () => {
282
- await tracer.startActiveSpan("onMessage", async (span) => {
283
- const controller = new AbortController();
284
- let completed = false;
285
-
286
- // Signal abort when cancellation occurs (before handler completes)
287
- nativeContext.onCancel().then(() => {
288
- if (!completed) {
289
- span.setAttribute("cancelled", true);
290
- controller.abort(new Error("message cancelled"));
291
- }
292
- });
293
-
294
- try {
295
- const context = new Context(nativeContext);
296
- await onMessage(context, message, controller.signal);
297
- } catch (error) {
298
- getCurrentLogger()?.error("Message handler error", error.cause ?? error);
299
- const cause = error.cause ?? error;
300
- span.recordException(cause);
301
- span.setStatus({ code: SpanStatusCode.ERROR, message: cause.message });
302
- captureException(error, "message", {
303
- topic: message.topic,
304
- partition: message.partition,
305
- key: message.key,
306
- offset: message.offset,
307
- });
308
- throw error;
309
- } finally {
310
- completed = true;
311
- span.end();
312
- }
313
- });
314
- });
436
+ onExcise: async (err, [nativeContext, message, carrier]) => {
437
+ if (err) throw err;
438
+ return handleRecord(
439
+ nativeContext,
440
+ message,
441
+ carrier,
442
+ onExcise,
443
+ "onExcise",
444
+ "excise",
445
+ );
315
446
  },
316
447
 
317
448
  onTimer: async (err, [nativeContext, timer, carrier]) => {
318
449
  if (err) throw err;
319
450
 
320
451
  const ctx = propagation.extract(otelContext.active(), carrier);
321
- await otelContext.with(ctx, async () => {
322
- await tracer.startActiveSpan("onTimer", async (span) => {
452
+ return otelContext.with(ctx, async () => {
453
+ return tracer.startActiveSpan("onTimer", async (span) => {
323
454
  const controller = new AbortController();
324
455
  let completed = false;
325
456
 
@@ -334,11 +465,18 @@ class ProsodyClient {
334
465
  try {
335
466
  const context = new Context(nativeContext);
336
467
  await onTimer(context, timer, controller.signal);
468
+ return "null";
337
469
  } catch (error) {
338
- getCurrentLogger()?.error("Timer handler error", error.cause ?? error);
470
+ getCurrentLogger()?.error(
471
+ "Timer handler error",
472
+ error.cause ?? error,
473
+ );
339
474
  const cause = error.cause ?? error;
340
475
  span.recordException(cause);
341
- span.setStatus({ code: SpanStatusCode.ERROR, message: cause.message });
476
+ span.setStatus({
477
+ code: SpanStatusCode.ERROR,
478
+ message: cause.message,
479
+ });
342
480
  captureException(error, "timer", {
343
481
  key: timer.key,
344
482
  time: timer.time,
@@ -355,7 +493,7 @@ class ProsodyClient {
355
493
  }
356
494
 
357
495
  /**
358
- * Unsubscribes from receiving messages and shuts down the consumer.
496
+ * Stops the consumer. You can subscribe again later.
359
497
  *
360
498
  * @returns {Promise<void>} A promise that resolves when the unsubscribe operation is complete.
361
499
  * @throws {Error} If the unsubscribe operation fails.
@@ -363,6 +501,18 @@ class ProsodyClient {
363
501
  async unsubscribe() {
364
502
  await this.nativeClient.unsubscribe();
365
503
  }
504
+
505
+ /**
506
+ * Shuts down the client and all its services.
507
+ * Concurrent and repeated calls await the same shutdown operation.
508
+ *
509
+ * @returns {Promise<void>} A promise that resolves when shutdown is complete.
510
+ * @throws {Error} If shutdown fails.
511
+ */
512
+ async shutdown() {
513
+ this.shutdownPromise ??= this.nativeClient.shutdown();
514
+ await this.shutdownPromise;
515
+ }
366
516
  }
367
517
 
368
518
  // napi-rs can only surface rejections whose value is an object, function, or
@@ -439,6 +589,77 @@ class PermanentError extends EventHandlerError {
439
589
  }
440
590
  }
441
591
 
592
+ function responseOutcome(outcome) {
593
+ if (typeof outcome !== "string") return { ok: false, error: outcome };
594
+
595
+ try {
596
+ return { ok: true, value: JSON.parse(outcome) };
597
+ } catch (cause) {
598
+ return {
599
+ ok: false,
600
+ error: { kind: "malformedResponse", message: cause.message },
601
+ };
602
+ }
603
+ }
604
+
605
+ function responseOutcomes(results) {
606
+ const outcomes = new Map();
607
+ for (const { subsystem, outcome } of results)
608
+ outcomes.set(subsystem, responseOutcome(outcome));
609
+ return outcomes;
610
+ }
611
+
612
+ /**
613
+ * Represents a transient keyed-state failure that may succeed on a later
614
+ * attempt — a store read/write timeout, AND every caller mistake (a
615
+ * null/unrepresentable write, an item-shape mismatch, an out-of-range index, an
616
+ * invalid scan direction). Caller mistakes are transient on purpose: retrying
617
+ * keeps the failure visible and never discards the message (see the
618
+ * error-classification rule in CLAUDE.md). Thrown by state handles and scan
619
+ * iterators. Because it subclasses {@link TransientError}, rethrowing it from a
620
+ * handler classifies the event transient through the existing error bridge with
621
+ * no bridge change.
622
+ * @extends TransientError
623
+ */
624
+ class TransientStateError extends TransientError {}
625
+
626
+ /**
627
+ * Represents a permanent keyed-state failure — one a retry cannot resolve in
628
+ * the running process: an unregistered collection name, a registered-identity
629
+ * mismatch, or a duplicate registration. Caller mistakes (a null/unrepresentable
630
+ * write, an item-shape mismatch, a bad index, an invalid direction) are NOT
631
+ * permanent — they are {@link TransientStateError} so they retry and stay
632
+ * visible rather than discarding the message (see the error-classification rule
633
+ * in CLAUDE.md). A handler may also throw this to declare its own failure
634
+ * permanent. Because it subclasses {@link PermanentError}, rethrowing it from a
635
+ * handler classifies the event permanent through the existing error bridge with
636
+ * no bridge change.
637
+ * @extends PermanentError
638
+ */
639
+ class PermanentStateError extends PermanentError {}
640
+
641
+ const STATE_ERROR_NAMES = new Set([
642
+ "PermanentStateError",
643
+ "TransientStateError",
644
+ ]);
645
+
646
+ /**
647
+ * Checks whether a value is a keyed-state error of either category.
648
+ *
649
+ * Name-branded: it matches on the error's `name`, so it recognizes state errors
650
+ * across both category classes (and across realms/duplicate module copies)
651
+ * without caring which category the error carries. It uses a realm-neutral
652
+ * native-error check so an error minted in another realm (a Node vm context,
653
+ * worker thread, or duplicate module copy) is still recognized — a bare
654
+ * `instanceof Error` would reject those.
655
+ *
656
+ * @param {unknown} error - The value to test.
657
+ * @returns {boolean} True when the value is a keyed-state error.
658
+ */
659
+ function isStateError(error) {
660
+ return types.isNativeError(error) && STATE_ERROR_NAMES.has(error.name);
661
+ }
662
+
442
663
  /**
443
664
  * Helper function to create error decorators.
444
665
  * @param {Function} ErrorClass - The error class to wrap exceptions with.
@@ -487,7 +708,7 @@ function createErrorDecorator(ErrorClass) {
487
708
  /**
488
709
  * Decorator factory for marking errors as transient.
489
710
  * Can be applied to methods to automatically wrap specified error types as transient.
490
- * @param {...(new(...args: any[]) => Error)} exceptionTypes - The error types to be treated as transient.
711
+ * @param {...(new(...args: never[]) => Error)} exceptionTypes - The error types to be treated as transient.
491
712
  * @returns {Function} A decorator function.
492
713
  */
493
714
  const transient = createErrorDecorator(TransientError);
@@ -495,11 +716,1080 @@ const transient = createErrorDecorator(TransientError);
495
716
  /**
496
717
  * Decorator factory for marking errors as permanent.
497
718
  * Can be applied to methods to automatically wrap specified error types as permanent.
498
- * @param {...(new(...args: any[]) => Error)} exceptionTypes - The error types to be treated as permanent.
719
+ * @param {...(new(...args: never[]) => Error)} exceptionTypes - The error types to be treated as permanent.
499
720
  * @returns {Function} A decorator function.
500
721
  */
501
722
  const permanent = createErrorDecorator(PermanentError);
502
723
 
724
+ /**
725
+ * Injects the active OpenTelemetry context into a fresh carrier for one native
726
+ * operation. Native glue activates the carrier without recording a binding
727
+ * span, so the core semantic operation span joins the JavaScript trace directly.
728
+ * @returns {Record<string, string>} The populated carrier.
729
+ * @private
730
+ */
731
+ function injectedCarrier() {
732
+ const carrier = {};
733
+ propagation.inject(otelContext.active(), carrier);
734
+ return carrier;
735
+ }
736
+
737
+ /**
738
+ * Converts a category-tagged native state error into the matching typed state
739
+ * error. The category crosses the FFI boundary structurally: the native layer
740
+ * sets the error's `cause` to an error whose message is exactly `"permanent"`
741
+ * or `"transient"` — a data channel distinct from the human-readable message,
742
+ * which is never parsed. Untagged errors (e.g. an argument-conversion
743
+ * TypeError) pass through unchanged.
744
+ *
745
+ * The native-error checks are realm-neutral (`util.types.isNativeError`) rather
746
+ * than `instanceof Error`: a napi error is minted in the addon's realm, so under
747
+ * a Node vm context, worker thread, or duplicate module copy a plain
748
+ * `instanceof Error` would be false and the category tag would be missed. The
749
+ * category is still matched exactly against the closed `"permanent"`/
750
+ * `"transient"` token set on the cause.
751
+ * @param {unknown} error - The error thrown by the native layer.
752
+ * @returns {unknown} The typed state error, or the original error if untagged.
753
+ * @private
754
+ */
755
+ function toStateError(error) {
756
+ if (!types.isNativeError(error)) return error;
757
+ const cause = error.cause;
758
+ const category = types.isNativeError(cause) ? cause.message : undefined;
759
+ if (category !== "permanent" && category !== "transient") return error;
760
+ const wrapped =
761
+ category === "permanent"
762
+ ? new PermanentStateError(error.message)
763
+ : new TransientStateError(error.message);
764
+ wrapped.cause = error;
765
+ return wrapped;
766
+ }
767
+
768
+ /**
769
+ * Runs one async native state operation with a fresh carrier, translating a
770
+ * category-tagged failure into the matching typed state error.
771
+ * @param {(carrier: Record<string, string>) => Promise<*>} operation - The native call.
772
+ * @returns {Promise<*>} The operation's resolved value.
773
+ * @private
774
+ */
775
+ /**
776
+ * Renders a value for a diagnostic message without ever throwing. `JSON.stringify`
777
+ * throws on a BigInt or a cyclic object and a template literal throws on a
778
+ * Symbol, so interpolating a hostile argument directly would raise a raw
779
+ * `TypeError` — bypassing the promise/state-error contract. Falls back to the
780
+ * value's `typeof` when it cannot be serialized.
781
+ * @param {*} value - The value to describe.
782
+ * @returns {string} A safe, human-readable rendering.
783
+ * @private
784
+ */
785
+ function describeValue(value) {
786
+ try {
787
+ const rendered = JSON.stringify(value);
788
+ return rendered === undefined ? typeof value : rendered;
789
+ } catch {
790
+ return typeof value;
791
+ }
792
+ }
793
+
794
+ async function stateOp(operation) {
795
+ try {
796
+ return await operation(injectedCarrier());
797
+ } catch (error) {
798
+ throw toStateError(error);
799
+ }
800
+ }
801
+
802
+ /**
803
+ * Runs one synchronous native call (a handle vend or a scan open), translating
804
+ * a category-tagged failure into the matching typed state error.
805
+ * @param {() => *} operation - The native call.
806
+ * @returns {*} The operation's return value.
807
+ * @private
808
+ */
809
+ function stateSync(operation) {
810
+ try {
811
+ return operation();
812
+ } catch (error) {
813
+ throw toStateError(error);
814
+ }
815
+ }
816
+
817
+ /**
818
+ * Builds a frozen state-collection definition. The definition is the single
819
+ * source of typing: the same frozen object is placed in
820
+ * `Configuration.stateCollections` (so the collection is registered once) and
821
+ * passed to `Context.state()` to vend a typed handle. Validation (name/ttl/
822
+ * keyset rules, duplicate names) is core-owned and happens at client
823
+ * construction and registration — this layer only shapes and freezes.
824
+ * @param {string} name - The collection name.
825
+ * @param {string} kind - `"value"`, `"map"`, or `"deque"`.
826
+ * @param {string} payload - `"json"` or `"message"`.
827
+ * @param {object} [options] - Optional ttlSeconds / readUncommitted, map-only
828
+ * keysetLimit, and deque-only capacity.
829
+ * @returns {Readonly<object>} The frozen definition.
830
+ * @private
831
+ */
832
+ const stateDefinitionAccess = new WeakMap();
833
+ const VALUE_ACCESS = Object.freeze({
834
+ owned: (context, collection) =>
835
+ new ValueState(context.valueState(collection), jsonItems),
836
+ published: async (client, subsystem, collection, ttl, disabled) =>
837
+ new PublishedValue(
838
+ await client.publishedValue(subsystem, collection, ttl, disabled),
839
+ ),
840
+ });
841
+ const MAP_ACCESS = Object.freeze({
842
+ owned: (context, collection) =>
843
+ new MapState(context.mapState(collection), jsonItems),
844
+ published: async (client, subsystem, collection, ttl, disabled) =>
845
+ new PublishedMap(
846
+ await client.publishedMap(subsystem, collection, ttl, disabled),
847
+ ),
848
+ });
849
+ const DEQUE_ACCESS = Object.freeze({
850
+ owned: (context, collection) =>
851
+ new DequeState(context.dequeState(collection), jsonItems),
852
+ published: async (client, subsystem, collection, ttl, disabled) =>
853
+ new PublishedDeque(
854
+ await client.publishedDeque(subsystem, collection, ttl, disabled),
855
+ ),
856
+ });
857
+ const MESSAGE_VALUE_ACCESS = Object.freeze({
858
+ owned: (context, collection) =>
859
+ new ValueState(context.messageValueState(collection), messageItems),
860
+ });
861
+ const MESSAGE_MAP_ACCESS = Object.freeze({
862
+ owned: (context, collection) =>
863
+ new MapState(context.messageMapState(collection), messageItems),
864
+ });
865
+ const MESSAGE_DEQUE_ACCESS = Object.freeze({
866
+ owned: (context, collection) =>
867
+ new DequeState(context.messageDequeState(collection), messageItems),
868
+ });
869
+
870
+ function stateDefinition(name, kind, payload, access, options = {}) {
871
+ const definition = { name, kind, payload };
872
+ if (options.ttlSeconds !== undefined)
873
+ definition.ttlSeconds = options.ttlSeconds;
874
+ if (options.readUncommitted !== undefined)
875
+ definition.readUncommitted = options.readUncommitted;
876
+ if (options.published !== undefined) definition.published = options.published;
877
+ if (options.readCache !== undefined) definition.readCache = options.readCache;
878
+ if (options.keysetLimit !== undefined)
879
+ definition.keysetLimit = options.keysetLimit;
880
+ if (options.capacity !== undefined) definition.capacity = options.capacity;
881
+ const frozen = Object.freeze(definition);
882
+ stateDefinitionAccess.set(frozen, access);
883
+ return frozen;
884
+ }
885
+
886
+ /**
887
+ * Declares a single-value JSON collection. The type parameter annotates the
888
+ * stored value; it is compile-time only — payloads cross as plain JSON with no
889
+ * runtime validation.
890
+ * @param {string} name - The collection name (unique per client).
891
+ * @param {object} [options] - `ttlSeconds` (whole seconds) and `readUncommitted`.
892
+ * @returns {Readonly<object>} A frozen definition for `stateCollections` and `state()`.
893
+ */
894
+ function value(name, options) {
895
+ return stateDefinition(name, "value", "json", VALUE_ACCESS, options);
896
+ }
897
+
898
+ /**
899
+ * Declares an ordered-map JSON collection. Map keys are always strings; the
900
+ * type parameter annotates the stored value (compile-time only).
901
+ * @param {string} name - The collection name (unique per client).
902
+ * @param {object} [options] - `ttlSeconds`, `readUncommitted`, and map-only `keysetLimit`.
903
+ * @returns {Readonly<object>} A frozen definition for `stateCollections` and `state()`.
904
+ */
905
+ function map(name, options) {
906
+ return stateDefinition(name, "map", "json", MAP_ACCESS, options);
907
+ }
908
+
909
+ /**
910
+ * Declares a double-ended-queue JSON collection. The type parameter annotates
911
+ * the stored element (compile-time only).
912
+ * @param {string} name - The collection name (unique per client).
913
+ * @param {object} [options] - `ttlSeconds` (whole seconds), `readUncommitted`,
914
+ * and deque-only `capacity` (bounded backlog; enforced lazily on push).
915
+ * @returns {Readonly<object>} A frozen definition for `stateCollections` and `state()`.
916
+ */
917
+ function deque(name, options) {
918
+ return stateDefinition(name, "deque", "json", DEQUE_ACCESS, options);
919
+ }
920
+
921
+ /**
922
+ * Declares a single-value message collection: each stored item is the full
923
+ * Kafka {@link Message} the handler received. The type parameter annotates the
924
+ * message payload (compile-time only).
925
+ * @param {string} name - The collection name (unique per client).
926
+ * @param {object} [options] - `ttlSeconds` (whole seconds) and `readUncommitted`.
927
+ * @returns {Readonly<object>} A frozen definition for `stateCollections` and `state()`.
928
+ */
929
+ function messageValue(name, options) {
930
+ return stateDefinition(
931
+ name,
932
+ "value",
933
+ "message",
934
+ MESSAGE_VALUE_ACCESS,
935
+ options,
936
+ );
937
+ }
938
+
939
+ /**
940
+ * Declares an ordered-map message collection. Map keys are always strings; each
941
+ * stored value is the full Kafka {@link Message}. The type parameter annotates
942
+ * the message payload (compile-time only).
943
+ * @param {string} name - The collection name (unique per client).
944
+ * @param {object} [options] - `ttlSeconds`, `readUncommitted`, and map-only `keysetLimit`.
945
+ * @returns {Readonly<object>} A frozen definition for `stateCollections` and `state()`.
946
+ */
947
+ function messageMap(name, options) {
948
+ return stateDefinition(name, "map", "message", MESSAGE_MAP_ACCESS, options);
949
+ }
950
+
951
+ /**
952
+ * Declares a double-ended-queue message collection: each stored element is the
953
+ * full Kafka {@link Message}. The type parameter annotates the message payload
954
+ * (compile-time only).
955
+ * @param {string} name - The collection name (unique per client).
956
+ * @param {object} [options] - `ttlSeconds` (whole seconds), `readUncommitted`,
957
+ * and deque-only `capacity` (bounded backlog; enforced lazily on push).
958
+ * @returns {Readonly<object>} A frozen definition for `stateCollections` and `state()`.
959
+ */
960
+ function messageDeque(name, options) {
961
+ return stateDefinition(
962
+ name,
963
+ "deque",
964
+ "message",
965
+ MESSAGE_DEQUE_ACCESS,
966
+ options,
967
+ );
968
+ }
969
+
970
+ /**
971
+ * Adapts a chunked native scan cursor to the item-oriented JS async-iterator
972
+ * protocol. A fresh carrier is propagated per native chunk without recording
973
+ * a span, while individual `next()` calls drain the retained chunk without
974
+ * crossing N-API.
975
+ * Native `null` (exhausted) maps to `{ done: true }`. Early exit from a `for await` loop
976
+ * (`break`/`return`/`throw`) invokes `return()`, which awaits the native
977
+ * `close()`; exhaustion and a pull error also close the cursor. Once finished,
978
+ * the cursor is never touched again. A pull error is never masked by the
979
+ * cleanup close (that close is best-effort). A close failure on the exhaustion
980
+ * or early-exit path is normalized through `toStateError`, so every keyed-state
981
+ * failure a caller can observe is a `PermanentStateError`/`TransientStateError`.
982
+ *
983
+ * Owned cursors remain attempt-fenced. Published cursors remain valid with
984
+ * their standalone reader.
985
+ * @param {object|(() => Promise<object>)} source - The native scan cursor, or
986
+ * a lazy asynchronous cursor opener.
987
+ * @param {(item: *) => *} transform - Maps each raw item to the yielded value.
988
+ * @returns {AsyncIterableIterator<*>} The async iterator.
989
+ * @private
990
+ */
991
+ function stateIterator(source, transform) {
992
+ let cursor;
993
+ let finished = false;
994
+ let chunk = [];
995
+ let offset = 0;
996
+ let queue = Promise.resolve();
997
+ const openCursor = async () => {
998
+ if (cursor === undefined) {
999
+ cursor = typeof source === "function" ? await source() : source;
1000
+ }
1001
+ return cursor;
1002
+ };
1003
+ // Serialize the complete iterator protocol, not just native pulls. Without
1004
+ // this queue, concurrent next() continuations can both reset `offset` after
1005
+ // awaiting the same chunk and yield the same first item. return() shares the
1006
+ // queue so it cannot close the cursor underneath an active next().
1007
+ const enqueue = (operation) => {
1008
+ const result = queue.then(operation, operation);
1009
+ // A rejected operation must not poison later cleanup or done checks.
1010
+ queue = result.then(
1011
+ () => undefined,
1012
+ () => undefined,
1013
+ );
1014
+ return result;
1015
+ };
1016
+ // Best-effort close used after a pull or transform failure: it must never
1017
+ // mask the primary error. `try/catch` (not `.catch()`) so a synchronous throw
1018
+ // from `close()` is swallowed too.
1019
+ const closeQuietly = async () => {
1020
+ if (cursor === undefined) return;
1021
+ try {
1022
+ await cursor.close();
1023
+ } catch {
1024
+ /* the primary error is already propagating */
1025
+ }
1026
+ };
1027
+ // Close on clean exhaustion / early exit, where there is no primary error to
1028
+ // mask: a close failure surfaces through the state-error model.
1029
+ const closeOrThrow = async () => {
1030
+ if (cursor === undefined) return;
1031
+ try {
1032
+ await cursor.close();
1033
+ } catch (error) {
1034
+ throw toStateError(error);
1035
+ }
1036
+ };
1037
+ return {
1038
+ next() {
1039
+ return enqueue(async () => {
1040
+ if (finished) return { value: undefined, done: true };
1041
+ while (offset >= chunk.length) {
1042
+ chunk = [];
1043
+ offset = 0;
1044
+ try {
1045
+ chunk = await (await openCursor()).nextChunk(injectedCarrier());
1046
+ } catch (error) {
1047
+ finished = true;
1048
+ await closeQuietly();
1049
+ throw toStateError(error);
1050
+ }
1051
+ if (chunk === null) {
1052
+ chunk = [];
1053
+ finished = true;
1054
+ await closeOrThrow();
1055
+ return { value: undefined, done: true };
1056
+ }
1057
+ }
1058
+ const item = chunk[offset];
1059
+ // Release consumed values even while the rest of a large chunk remains.
1060
+ chunk[offset] = undefined;
1061
+ offset += 1;
1062
+ try {
1063
+ return { value: transform(item), done: false };
1064
+ } catch (error) {
1065
+ // A transform failure is a binding defect, not a store error; close the
1066
+ // cursor and mark the iterator done rather than leaving it live.
1067
+ finished = true;
1068
+ await closeQuietly();
1069
+ throw error;
1070
+ }
1071
+ });
1072
+ },
1073
+ return(value) {
1074
+ return enqueue(async () => {
1075
+ if (!finished) {
1076
+ finished = true;
1077
+ chunk = [];
1078
+ offset = 0;
1079
+ await closeOrThrow();
1080
+ }
1081
+ return { value, done: true };
1082
+ });
1083
+ },
1084
+ [Symbol.asyncIterator]() {
1085
+ return this;
1086
+ },
1087
+ };
1088
+ }
1089
+
1090
+ /**
1091
+ * Read-only handle over a published single-value collection. It is independent
1092
+ * of subscription and remains valid for the lifetime of its client.
1093
+ */
1094
+ class PublishedValue {
1095
+ constructor(native) {
1096
+ this.native = native;
1097
+ }
1098
+
1099
+ async get(key) {
1100
+ return jsonItems.decode(
1101
+ await stateOp((carrier) => this.native.get(key, carrier)),
1102
+ );
1103
+ }
1104
+ }
1105
+
1106
+ class PublishedMap {
1107
+ constructor(native) {
1108
+ this.native = native;
1109
+ }
1110
+
1111
+ async get(key, mapKey) {
1112
+ return jsonItems.decode(
1113
+ await stateOp((carrier) => this.native.get(key, mapKey, carrier)),
1114
+ );
1115
+ }
1116
+
1117
+ async getMany(key, mapKeys) {
1118
+ const values = await stateOp((carrier) =>
1119
+ this.native.getMany(key, mapKeys, carrier),
1120
+ );
1121
+ return values.map(jsonItems.decode);
1122
+ }
1123
+
1124
+ has(key, mapKey) {
1125
+ return stateOp((carrier) => this.native.contains(key, mapKey, carrier));
1126
+ }
1127
+
1128
+ entries(key, direction = "forward") {
1129
+ return stateIterator(
1130
+ () => stateOp((carrier) => this.native.scan(key, direction, carrier)),
1131
+ ([mapKey, value]) => [mapKey, jsonItems.decode(value)],
1132
+ );
1133
+ }
1134
+
1135
+ keys(key, direction = "forward") {
1136
+ return stateIterator(
1137
+ () => stateOp((carrier) => this.native.keys(key, direction, carrier)),
1138
+ (mapKey) => mapKey,
1139
+ );
1140
+ }
1141
+
1142
+ values(key, direction = "forward") {
1143
+ return stateIterator(
1144
+ () => stateOp((carrier) => this.native.scan(key, direction, carrier)),
1145
+ (entry) => jsonItems.decode(entry[1]),
1146
+ );
1147
+ }
1148
+ }
1149
+
1150
+ /**
1151
+ * The native `payload` getter, which answers the raw JSON text read from the
1152
+ * wire. Captured before {@link withParsedPayload} shadows it per instance.
1153
+ * @private
1154
+ */
1155
+ const rawPayload = Object.getOwnPropertyDescriptor(
1156
+ NativeMessage.prototype,
1157
+ "payload",
1158
+ ).get;
1159
+
1160
+ /**
1161
+ * Replaces a message's raw-text `payload` with the parsed document.
1162
+ *
1163
+ * Rust hands the payload across as the bytes it read from the wire, never
1164
+ * parsing them; the parse happens here, on first read, and the result is kept.
1165
+ * A handler that only inspects metadata therefore never pays for one.
1166
+ *
1167
+ * A payload that is not JSON raises a permanent error: no retry makes bytes
1168
+ * parse. Reading `payload` is where that surfaces, since nothing before it
1169
+ * looks at the document. That outcome is kept too, so a handler reading a bad
1170
+ * payload twice re-raises rather than re-parsing.
1171
+ * @param {object} message - The native message.
1172
+ * @returns {object} The same message, with `payload` parsed on demand.
1173
+ * @private
1174
+ */
1175
+ function withParsedPayload(message) {
1176
+ let document;
1177
+ let failure;
1178
+ let parsed = false;
1179
+ // Not enumerable, matching the five native getters: none of a message's
1180
+ // fields are own properties, so spread, `Object.keys`, and `JSON.stringify`
1181
+ // see none of them. An enumerable own `payload` would make `JSON.stringify`
1182
+ // of a message serialize the whole parsed document.
1183
+ Object.defineProperty(message, "payload", {
1184
+ configurable: true,
1185
+ enumerable: false,
1186
+ get() {
1187
+ if (!parsed) {
1188
+ try {
1189
+ document = parseJson(
1190
+ rawPayload.call(this),
1191
+ PermanentError,
1192
+ "message payload is not JSON",
1193
+ );
1194
+ } catch (error) {
1195
+ failure = error;
1196
+ }
1197
+ parsed = true;
1198
+ }
1199
+ if (failure !== undefined) throw failure;
1200
+ return document;
1201
+ },
1202
+ });
1203
+ return message;
1204
+ }
1205
+
1206
+ /**
1207
+ * Serializes a JSON value.
1208
+ *
1209
+ * It returns `undefined` for a function, a symbol, or `undefined` itself. It
1210
+ * throws for a BigInt or a cycle.
1211
+ * @param {*} value - The value to serialize.
1212
+ * @param {Function} ErrorClass - The error to raise on failure.
1213
+ * @returns {string} The JSON text.
1214
+ * @private
1215
+ */
1216
+ function toJson(value, ErrorClass) {
1217
+ let json;
1218
+ try {
1219
+ json = JSON.stringify(value);
1220
+ } catch (error) {
1221
+ throw new ErrorClass(
1222
+ `value is not representable as JSON: ${error.message}`,
1223
+ );
1224
+ }
1225
+ if (json === undefined) {
1226
+ throw new ErrorClass(
1227
+ "value is not representable as JSON (functions, symbols, and " +
1228
+ "`undefined` have no JSON form)",
1229
+ );
1230
+ }
1231
+ return json;
1232
+ }
1233
+
1234
+ /** Parses JSON text and maps invalid input onto the caller's error class. */
1235
+ function parseJson(text, ErrorClass, context) {
1236
+ try {
1237
+ return JSON.parse(text);
1238
+ } catch (error) {
1239
+ throw new ErrorClass(`${context}: ${error.message}`);
1240
+ }
1241
+ }
1242
+
1243
+ /**
1244
+ * Serializes a value for a JSON collection.
1245
+ *
1246
+ * A collection stores whatever JSON it is given, so the only rejections here
1247
+ * are values with no JSON form at all. Both are caller mistakes and reject
1248
+ * transient: the event retries and the mistake stays visible rather than
1249
+ * discarding the message.
1250
+ *
1251
+ * Everything else follows `JSON.stringify`, which is now the serializer of
1252
+ * record. Inside a container a function-valued property is dropped and an
1253
+ * `undefined` element becomes `null`. `NaN` and the infinities become `null`
1254
+ * anywhere they appear. A `toJSON` method decides what its value serializes to.
1255
+ * The previous Rust-side conversion rejected each of those instead.
1256
+ * @param {*} value - The value to store.
1257
+ * @returns {string} The document's JSON text.
1258
+ * @throws {TransientStateError} If the value has no JSON representation.
1259
+ * @private
1260
+ */
1261
+ function encodeJson(value) {
1262
+ if (value instanceof NativeMessage) {
1263
+ throw new TransientStateError(
1264
+ "a Kafka message cannot be stored in a JSON collection; declare it with " +
1265
+ "messageValue/messageMap/messageDeque instead",
1266
+ );
1267
+ }
1268
+ return toJson(value, TransientStateError);
1269
+ }
1270
+
1271
+ /**
1272
+ * Reads the event metadata a payload carries.
1273
+ *
1274
+ * When the payload is an object with a string `id` or `type`, prosody uses
1275
+ * them. Anything else carries no metadata. Each field is read once, so a getter
1276
+ * that answers differently on a second read cannot make the metadata disagree
1277
+ * with itself.
1278
+ * @param {*} payload - The payload about to be sent.
1279
+ * @returns {{eventId?: string, eventType?: string}} The metadata.
1280
+ * @private
1281
+ */
1282
+ function eventMetadata(payload) {
1283
+ const id = payload?.id;
1284
+ const type = payload?.type;
1285
+ return {
1286
+ eventId: typeof id === "string" ? id : undefined,
1287
+ eventType: typeof type === "string" ? type : undefined,
1288
+ };
1289
+ }
1290
+
1291
+ /**
1292
+ * Checks that a message collection is being handed an actual message.
1293
+ *
1294
+ * A message collection stores the Kafka message itself, so it accepts only a
1295
+ * `Message` — an object merely shaped like one is rejected. A message read back
1296
+ * out of a collection is a `Message` and stores fine.
1297
+ * What it stores is the message's own wire bytes, so assigning to a message's
1298
+ * fields, or mutating its parsed `payload`, does not change what is written.
1299
+ * @param {*} value - The value to store.
1300
+ * @returns {object} The message.
1301
+ * @throws {TransientStateError} If the value is not a Kafka message.
1302
+ * @private
1303
+ */
1304
+ function requireMessage(value) {
1305
+ if (!(value instanceof NativeMessage)) {
1306
+ throw new TransientStateError(
1307
+ "expected a Kafka message; a JSON value cannot be stored in a message collection",
1308
+ );
1309
+ }
1310
+ return value;
1311
+ }
1312
+
1313
+ /**
1314
+ * Builds the item codec for one collection payload type.
1315
+ *
1316
+ * The native handle has one payload type. Each write method accepts only that
1317
+ * type.
1318
+ * @param {(item: *) => *} encode - Prepares an item for a write.
1319
+ * @param {(item: *) => *} decode - Turns a read item into what the caller asked for.
1320
+ * @returns {Readonly<object>} The frozen codec.
1321
+ * @private
1322
+ */
1323
+ function itemCodec(encode, decode) {
1324
+ return Object.freeze({
1325
+ decode: (item) => (item === null ? null : decode(item)),
1326
+ set: (native, item, carrier) => native.set(encode(item), carrier),
1327
+ setKey: (native, key, item, carrier) =>
1328
+ native.set(key, encode(item), carrier),
1329
+ pushBack: (native, item, carrier) => native.pushBack(encode(item), carrier),
1330
+ pushFront: (native, item, carrier) =>
1331
+ native.pushFront(encode(item), carrier),
1332
+ });
1333
+ }
1334
+
1335
+ class PublishedDeque {
1336
+ constructor(native) {
1337
+ this.native = native;
1338
+ }
1339
+
1340
+ length(key) {
1341
+ return stateOp((carrier) => this.native.length(key, carrier));
1342
+ }
1343
+
1344
+ isEmpty(key) {
1345
+ return stateOp((carrier) => this.native.isEmpty(key, carrier));
1346
+ }
1347
+
1348
+ async at(key, index) {
1349
+ if (!Number.isSafeInteger(index)) {
1350
+ throw new TransientStateError(
1351
+ `at: index must be a safe integer, got ${describeValue(index)}`,
1352
+ );
1353
+ }
1354
+ if (index === 0) {
1355
+ return jsonItems.decode(
1356
+ await stateOp((carrier) => this.native.peekFront(key, carrier)),
1357
+ );
1358
+ }
1359
+ if (index === -1) {
1360
+ return jsonItems.decode(
1361
+ await stateOp((carrier) => this.native.peekBack(key, carrier)),
1362
+ );
1363
+ }
1364
+ let position = index;
1365
+ if (position < 0) {
1366
+ position += await this.length(key);
1367
+ if (position < 0) return null;
1368
+ }
1369
+ if (position > 0xffffffff) return null;
1370
+ return jsonItems.decode(
1371
+ await stateOp((carrier) => this.native.get(key, position, carrier)),
1372
+ );
1373
+ }
1374
+
1375
+ values(key, direction = "forward") {
1376
+ return stateIterator(
1377
+ () => stateOp((carrier) => this.native.scan(key, direction, carrier)),
1378
+ jsonItems.decode,
1379
+ );
1380
+ }
1381
+ }
1382
+
1383
+ /** JSON documents cross as their text. @private */
1384
+ const jsonItems = itemCodec(encodeJson, (text) =>
1385
+ parseJson(
1386
+ text,
1387
+ PermanentStateError,
1388
+ "stored JSON document could not be parsed",
1389
+ ),
1390
+ );
1391
+
1392
+ /** Kafka messages cross as the `Message` object itself. @private */
1393
+ const messageItems = itemCodec(requireMessage, withParsedPayload);
1394
+
1395
+ /**
1396
+ * What every keyed-state handle shares: the native handle it wraps, the item
1397
+ * codec for its payload flavour, and the two transaction verbs.
1398
+ *
1399
+ * Core records one semantic span per operation; this binding propagates context
1400
+ * without adding an N-API span. Handles are valid only within the handler
1401
+ * invocation (attempt) that vended them.
1402
+ */
1403
+ class StateHandle {
1404
+ /**
1405
+ * @param {object} native - The vended native handle.
1406
+ * @param {object} items - The item codec for the collection's payload flavour.
1407
+ */
1408
+ constructor(native, items) {
1409
+ this.native = native;
1410
+ this.items = items;
1411
+ }
1412
+
1413
+ /**
1414
+ * Durably commits the buffered operations mid-handler (at-least-once; the
1415
+ * committed floor survives a later rollback or a failed event).
1416
+ * @returns {Promise<void>} Resolves with no value — the erased seam drops the outcome.
1417
+ * @throws {PermanentStateError|TransientStateError} On a categorized commit failure.
1418
+ */
1419
+ commit() {
1420
+ return stateOp((carrier) => this.native.commit(carrier));
1421
+ }
1422
+
1423
+ /**
1424
+ * Discards buffered uncommitted operations back to the last committed floor.
1425
+ * @returns {Promise<void>} Resolves with no value.
1426
+ */
1427
+ rollback() {
1428
+ return stateOp((carrier) => this.native.rollback(carrier));
1429
+ }
1430
+ }
1431
+
1432
+ /**
1433
+ * Typed handle over a single-value keyed-state collection, vended by
1434
+ * {@link Context#state}. Core records one semantic span per operation; this
1435
+ * binding propagates context without adding an N-API span. Handles are valid
1436
+ * only within the handler invocation (attempt) that vended them.
1437
+ */
1438
+ class ValueState extends StateHandle {
1439
+ /**
1440
+ * Reads the current value.
1441
+ * @returns {Promise<*|null>} The stored value, or null when absent/cleared.
1442
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1443
+ */
1444
+ get() {
1445
+ return stateOp((carrier) => this.native.get(carrier)).then(
1446
+ this.items.decode,
1447
+ );
1448
+ }
1449
+
1450
+ /**
1451
+ * Buffers a write of the value. Writing JSON `null` (or an unrepresentable
1452
+ * value) is a caller mistake, rejected with a {@link TransientStateError}
1453
+ * naming `clear()` — use {@link ValueState#clear} to delete instead. The
1454
+ * error is transient so it retries and stays visible rather than discarding
1455
+ * the message and losing data.
1456
+ * @param {*} value - The value to store.
1457
+ * @returns {Promise<void>}
1458
+ * @throws {TransientStateError} On a null/unrepresentable/shape mistake or a
1459
+ * transient store failure; {@link PermanentStateError} only if the store
1460
+ * reports one.
1461
+ */
1462
+ set(value) {
1463
+ return stateOp((carrier) => this.items.set(this.native, value, carrier));
1464
+ }
1465
+
1466
+ /**
1467
+ * Deletes the stored value.
1468
+ * @returns {Promise<void>}
1469
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1470
+ */
1471
+ clear() {
1472
+ return stateOp((carrier) => this.native.clear(carrier));
1473
+ }
1474
+ }
1475
+
1476
+ /**
1477
+ * Typed handle over an ordered-map keyed-state collection, vended by
1478
+ * {@link Context#state}. Map keys are always strings. Core records one semantic
1479
+ * span per operation; this binding propagates context without adding an N-API
1480
+ * span. Handles and iterators are valid only within the handler invocation.
1481
+ */
1482
+ class MapState extends StateHandle {
1483
+ /**
1484
+ * Reads the value for `key`.
1485
+ * @param {string} key - The map key.
1486
+ * @returns {Promise<*|null>} The value, or null when the key is absent.
1487
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1488
+ */
1489
+ get(key) {
1490
+ return stateOp((carrier) => this.native.get(key, carrier)).then(
1491
+ this.items.decode,
1492
+ );
1493
+ }
1494
+
1495
+ /**
1496
+ * Reads several keys in a single call. Returns an array with one entry per
1497
+ * key, in the same order you asked, so `result[i]` is the value for
1498
+ * `keys[i]`. A key that isn't there comes back as `null`, and a key you list
1499
+ * more than once is answered at each spot. The whole read happens as one
1500
+ * step, so no other change to this event's state can slip in partway through.
1501
+ * @param {string[]} keys - The keys to read, in order.
1502
+ * @returns {Promise<Array<*|null>>} One entry per key, in the order asked.
1503
+ * @throws {PermanentStateError|TransientStateError} If the read fails.
1504
+ */
1505
+ getMany(keys) {
1506
+ return stateOp((carrier) => this.native.getMany(keys, carrier)).then(
1507
+ (items) => items.map(this.items.decode),
1508
+ );
1509
+ }
1510
+
1511
+ /**
1512
+ * Reports whether `key` currently has a stored value. A presence check that
1513
+ * skips the value decode and the resolver: for a message-backed map it
1514
+ * answers with zero Kafka fetches and can report `true` for a message that
1515
+ * can no longer be fetched. Not zero-I/O — a cache miss can still reach the
1516
+ * store — but cheaper than {@link MapState#get} when you only need presence.
1517
+ * @param {string} key - The map key.
1518
+ * @returns {Promise<boolean>} True when the key is present.
1519
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1520
+ */
1521
+ has(key) {
1522
+ return stateOp((carrier) => this.native.contains(key, carrier));
1523
+ }
1524
+
1525
+ /**
1526
+ * Inserts or overwrites `key`. Writing JSON `null` (or an unrepresentable
1527
+ * value) is a caller mistake, rejected with a {@link TransientStateError} —
1528
+ * use {@link MapState#delete} to remove an entry instead. The error is
1529
+ * transient so it retries and stays visible rather than discarding the
1530
+ * message and losing data.
1531
+ * @param {string} key - The map key.
1532
+ * @param {*} value - The value to store.
1533
+ * @returns {Promise<void>}
1534
+ * @throws {TransientStateError} On a null/unrepresentable/shape mistake or a
1535
+ * transient store failure; {@link PermanentStateError} only if the store
1536
+ * reports one.
1537
+ */
1538
+ set(key, value) {
1539
+ return stateOp((carrier) =>
1540
+ this.items.setKey(this.native, key, value, carrier),
1541
+ );
1542
+ }
1543
+
1544
+ /**
1545
+ * Removes `key`.
1546
+ *
1547
+ * Deliberate divergence from `Map#delete`: this returns void, NOT a boolean
1548
+ * "was present" flag — surfacing that boolean would force a hidden read on
1549
+ * every delete. The underlying native operation is named `remove`, which is
1550
+ * the verb core's null-write rejection message uses.
1551
+ * @param {string} key - The map key.
1552
+ * @returns {Promise<void>}
1553
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1554
+ */
1555
+ delete(key) {
1556
+ return stateOp((carrier) => this.native.remove(key, carrier));
1557
+ }
1558
+
1559
+ /**
1560
+ * Removes every entry.
1561
+ * @returns {Promise<void>}
1562
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1563
+ */
1564
+ clear() {
1565
+ return stateOp((carrier) => this.native.clear(carrier));
1566
+ }
1567
+
1568
+ /**
1569
+ * Opens an async iterator over the live entries in key order. Each yielded
1570
+ * item is a `[key, value]` pair. Valid only within the handler invocation
1571
+ * (attempt) that opened it; early exit closes the underlying cursor.
1572
+ * @param {"forward"|"backward"} [direction="forward"] - The scan direction.
1573
+ * @returns {AsyncIterableIterator<[string, *]>} The entries iterator.
1574
+ * @throws {TransientStateError} If the direction token is invalid (a caller
1575
+ * mistake — retries, not discarded).
1576
+ */
1577
+ entries(direction = "forward") {
1578
+ return stateIterator(
1579
+ stateSync(() => this.native.scan(direction, injectedCarrier())),
1580
+ ([key, item]) => [key, this.items.decode(item)],
1581
+ );
1582
+ }
1583
+
1584
+ /**
1585
+ * Opens an async iterator over the live keys in key order. Skips the value
1586
+ * decode and the resolver, so a message-backed map enumerates keys with zero
1587
+ * Kafka fetches; it still reads presence, so it is not zero-I/O. Valid only
1588
+ * within the handler invocation (attempt) that opened it; early exit closes
1589
+ * the underlying cursor.
1590
+ * @param {"forward"|"backward"} [direction="forward"] - The scan direction.
1591
+ * @returns {AsyncIterableIterator<string>} The keys iterator.
1592
+ * @throws {TransientStateError} If the direction token is invalid (a caller
1593
+ * mistake — retries, not discarded).
1594
+ */
1595
+ keys(direction = "forward") {
1596
+ return stateIterator(
1597
+ stateSync(() => this.native.keys(direction, injectedCarrier())),
1598
+ (key) => key,
1599
+ );
1600
+ }
1601
+
1602
+ /**
1603
+ * Opens an async iterator over the live values in key order. Valid only
1604
+ * within the handler invocation (attempt) that opened it; early exit closes
1605
+ * the underlying cursor.
1606
+ * @param {"forward"|"backward"} [direction="forward"] - The scan direction.
1607
+ * @returns {AsyncIterableIterator<*>} The values iterator.
1608
+ * @throws {TransientStateError} If the direction token is invalid (a caller
1609
+ * mistake — retries, not discarded).
1610
+ */
1611
+ values(direction = "forward") {
1612
+ return stateIterator(
1613
+ stateSync(() => this.native.scan(direction, injectedCarrier())),
1614
+ ([, item]) => this.items.decode(item),
1615
+ );
1616
+ }
1617
+
1618
+ /**
1619
+ * Forward iteration over `[key, value]` entries — equivalent to
1620
+ * `entries("forward")`. Valid only within the handler invocation (attempt).
1621
+ * @returns {AsyncIterableIterator<[string, *]>} The entries iterator.
1622
+ */
1623
+ [Symbol.asyncIterator]() {
1624
+ return this.entries();
1625
+ }
1626
+ }
1627
+
1628
+ /**
1629
+ * Typed handle over a double-ended-queue keyed-state collection, vended by
1630
+ * {@link Context#state}. Core records one semantic span per operation; this
1631
+ * binding propagates context without adding an N-API span. Handles and
1632
+ * iterators are valid only within the handler invocation that vended them.
1633
+ */
1634
+ class DequeState extends StateHandle {
1635
+ /**
1636
+ * Appends an element at the back. Writing JSON `null` is a caller mistake
1637
+ * rejected with a {@link TransientStateError}, so an uncaught handler bug
1638
+ * retries instead of committing the offset and losing the message.
1639
+ * @param {*} item - The element to append.
1640
+ * @returns {Promise<void>}
1641
+ * @throws {TransientStateError} If `item` is JSON `null` or otherwise invalid.
1642
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1643
+ */
1644
+ push(item) {
1645
+ return stateOp((carrier) =>
1646
+ this.items.pushBack(this.native, item, carrier),
1647
+ );
1648
+ }
1649
+
1650
+ /**
1651
+ * Prepends an element at the front. Writing JSON `null` is a caller mistake
1652
+ * rejected with a {@link TransientStateError}, so an uncaught handler bug
1653
+ * retries instead of committing the offset and losing the message.
1654
+ * @param {*} item - The element to prepend.
1655
+ * @returns {Promise<void>}
1656
+ * @throws {TransientStateError} If `item` is JSON `null` or otherwise invalid.
1657
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1658
+ */
1659
+ unshift(item) {
1660
+ return stateOp((carrier) =>
1661
+ this.items.pushFront(this.native, item, carrier),
1662
+ );
1663
+ }
1664
+
1665
+ /**
1666
+ * Removes and returns the back element.
1667
+ * @returns {Promise<*|null>} The removed element, or null when empty.
1668
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1669
+ */
1670
+ pop() {
1671
+ return stateOp((carrier) => this.native.popBack(carrier)).then(
1672
+ this.items.decode,
1673
+ );
1674
+ }
1675
+
1676
+ /**
1677
+ * Removes and returns the front element.
1678
+ * @returns {Promise<*|null>} The removed element, or null when empty.
1679
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1680
+ */
1681
+ shift() {
1682
+ return stateOp((carrier) => this.native.popFront(carrier)).then(
1683
+ this.items.decode,
1684
+ );
1685
+ }
1686
+
1687
+ /**
1688
+ * Returns the number of live elements.
1689
+ * @returns {Promise<number>} The element count.
1690
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1691
+ */
1692
+ length() {
1693
+ return stateOp((carrier) => this.native.len(carrier));
1694
+ }
1695
+
1696
+ /**
1697
+ * Reports whether the deque holds no live elements.
1698
+ * @returns {Promise<boolean>} True when the deque is empty.
1699
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1700
+ */
1701
+ isEmpty() {
1702
+ return stateOp((carrier) => this.native.isEmpty(carrier));
1703
+ }
1704
+
1705
+ /**
1706
+ * Removes every element.
1707
+ * @returns {Promise<void>}
1708
+ * @throws {PermanentStateError|TransientStateError} On a categorized store failure.
1709
+ */
1710
+ clear() {
1711
+ return stateOp((carrier) => this.native.clear(carrier));
1712
+ }
1713
+
1714
+ /**
1715
+ * Reads the element at `index`, like `Array.prototype.at`. A non-negative
1716
+ * `index` counts from the front (`0` is the front element); a negative
1717
+ * `index` counts back from the end (`-1` is the back element). Any in-range
1718
+ * position resolves to its element; any out-of-range position — including
1719
+ * every index on an empty deque — resolves to null, the same absence sentinel
1720
+ * `pop`/`shift` use.
1721
+ *
1722
+ * `index` must be a safe integer; a fractional, `NaN`, or infinite value is a
1723
+ * caller mistake, rejected with a {@link TransientStateError} so it retries
1724
+ * and stays visible rather than silently reading the wrong element (a bare
1725
+ * `u32` conversion would truncate `1.5` to `1`). The error is transient — a
1726
+ * caller mistake never discards the message.
1727
+ *
1728
+ * The endpoints `at(0)` and `at(-1)` ride the front/back peeks — a single
1729
+ * read, the same core primitive the other clients use; `at(-1)` makes no
1730
+ * length read. Any other negative index is resolved against the current
1731
+ * {@link DequeState#length}, so it makes two boundary crossings (a length
1732
+ * read, then the element read); any other non-negative index makes one.
1733
+ * Within a handler attempt the deque has a single owner, so nothing else
1734
+ * mutates it between the two reads.
1735
+ * @param {number} index - The position: front-relative if `>= 0`, else back-relative.
1736
+ * @returns {Promise<*|null>} The element, or null when the position is out of range.
1737
+ * @throws {TransientStateError} On an index mistake or a transient store
1738
+ * failure; {@link PermanentStateError} only if the store reports one.
1739
+ */
1740
+ async at(index) {
1741
+ if (!Number.isSafeInteger(index)) {
1742
+ throw new TransientStateError(
1743
+ `at: index must be a safe integer, got ${describeValue(index)}`,
1744
+ );
1745
+ }
1746
+ if (index === 0)
1747
+ return stateOp((carrier) => this.native.peekFront(carrier)).then(
1748
+ this.items.decode,
1749
+ );
1750
+ if (index === -1)
1751
+ return stateOp((carrier) => this.native.peekBack(carrier)).then(
1752
+ this.items.decode,
1753
+ );
1754
+ let position = index;
1755
+ if (position < 0) {
1756
+ position += await this.length();
1757
+ // Still negative: the deque is shorter than |index|, so nothing is there.
1758
+ if (position < 0) return null;
1759
+ }
1760
+ // Beyond the addressable u32 range can only be past the end, never a wrap.
1761
+ if (position > 0xffffffff) return null;
1762
+ return stateOp((carrier) => this.native.get(position, carrier)).then(
1763
+ this.items.decode,
1764
+ );
1765
+ }
1766
+
1767
+ /**
1768
+ * Opens an async iterator over the live elements in index order. Valid only
1769
+ * within the handler invocation (attempt) that opened it; early exit closes
1770
+ * the underlying cursor.
1771
+ * @param {"forward"|"backward"} [direction="forward"] - The scan direction.
1772
+ * @returns {AsyncIterableIterator<*>} The values iterator.
1773
+ * @throws {TransientStateError} If the direction token is invalid (a caller
1774
+ * mistake — retries, not discarded).
1775
+ */
1776
+ values(direction = "forward") {
1777
+ return stateIterator(
1778
+ stateSync(() => this.native.scan(direction, injectedCarrier())),
1779
+ (item) => this.items.decode(item),
1780
+ );
1781
+ }
1782
+
1783
+ /**
1784
+ * Forward iteration over the elements — equivalent to `values("forward")`.
1785
+ * Valid only within the handler invocation (attempt).
1786
+ * @returns {AsyncIterableIterator<*>} The values iterator.
1787
+ */
1788
+ [Symbol.asyncIterator]() {
1789
+ return this.values();
1790
+ }
1791
+ }
1792
+
503
1793
  /**
504
1794
  * Context class that automatically injects OpenTelemetry context for all operations.
505
1795
  * This wraps the native Context with automatic OTEL context propagation.
@@ -507,6 +1797,10 @@ const permanent = createErrorDecorator(PermanentError);
507
1797
  class Context {
508
1798
  constructor(nativeContext) {
509
1799
  this.nativeContext = nativeContext;
1800
+ // Cache of vended state wrappers, keyed by collection name (names are
1801
+ // unique per registration), so repeated state(def) calls within one event
1802
+ // return the same handle.
1803
+ this.stateHandles = new Map();
510
1804
  }
511
1805
 
512
1806
  /**
@@ -534,9 +1828,7 @@ class Context {
534
1828
  * @throws {Error} If time conversion or scheduling fails.
535
1829
  */
536
1830
  async schedule(time) {
537
- const carrier = {};
538
- propagation.inject(otelContext.active(), carrier);
539
- return this.nativeContext.schedule(time, carrier);
1831
+ return this.nativeContext.schedule(time, injectedCarrier());
540
1832
  }
541
1833
 
542
1834
  /**
@@ -546,9 +1838,7 @@ class Context {
546
1838
  * @throws {Error} If time conversion or scheduling fails.
547
1839
  */
548
1840
  async clearAndSchedule(time) {
549
- const carrier = {};
550
- propagation.inject(otelContext.active(), carrier);
551
- return this.nativeContext.clearAndSchedule(time, carrier);
1841
+ return this.nativeContext.clearAndSchedule(time, injectedCarrier());
552
1842
  }
553
1843
 
554
1844
  /**
@@ -558,9 +1848,7 @@ class Context {
558
1848
  * @throws {Error} If unscheduling fails.
559
1849
  */
560
1850
  async unschedule(time) {
561
- const carrier = {};
562
- propagation.inject(otelContext.active(), carrier);
563
- return this.nativeContext.unschedule(time, carrier);
1851
+ return this.nativeContext.unschedule(time, injectedCarrier());
564
1852
  }
565
1853
 
566
1854
  /**
@@ -569,9 +1857,7 @@ class Context {
569
1857
  * @throws {Error} If clearing schedules fails.
570
1858
  */
571
1859
  async clearScheduled() {
572
- const carrier = {};
573
- propagation.inject(otelContext.active(), carrier);
574
- return this.nativeContext.clearScheduled(carrier);
1860
+ return this.nativeContext.clearScheduled(injectedCarrier());
575
1861
  }
576
1862
 
577
1863
  /**
@@ -580,25 +1866,80 @@ class Context {
580
1866
  * @throws {Error} If retrieval fails.
581
1867
  */
582
1868
  async scheduled() {
583
- const carrier = {};
584
- propagation.inject(otelContext.active(), carrier);
585
- return this.nativeContext.scheduled(carrier);
1869
+ return this.nativeContext.scheduled(injectedCarrier());
1870
+ }
1871
+
1872
+ /**
1873
+ * Binds a registered keyed-state collection for this event and returns a
1874
+ * typed handle over it.
1875
+ *
1876
+ * Pass a definition built by one of the definition constructors ({@link value},
1877
+ * {@link map}, {@link deque}, {@link messageValue}, {@link messageMap},
1878
+ * {@link messageDeque}) — the same frozen object placed in
1879
+ * `Configuration.stateCollections`. The returned handle (and any iterator it
1880
+ * opens) is scoped to this single event attempt; do not retain it past the
1881
+ * handler invocation. Handles are cached per context by definition identity
1882
+ * (kind, payload, and name), so repeated calls for the same definition return
1883
+ * the same wrapper; a mismatched definition reusing a name misses the cache
1884
+ * and is rejected core-side at vend.
1885
+ *
1886
+ * @param {object} definition - A frozen definition from a definition constructor.
1887
+ * @returns {ValueState|MapState|DequeState} The typed state handle.
1888
+ * @throws {TransientStateError} If the definition is malformed — a missing or
1889
+ * non-string `name`, or an unrecognized `kind`/`payload` (a caller mistake,
1890
+ * so transient rather than a message-discarding permanent).
1891
+ * @throws {PermanentStateError} If the collection name is unregistered or its
1892
+ * durably-registered schema (kind/payload) mismatches (rejected core-side).
1893
+ */
1894
+ state(definition) {
1895
+ const access = stateDefinitionAccess.get(definition);
1896
+ if (access === undefined) {
1897
+ throw new TransientStateError(
1898
+ "state: definition must come from a Prosody state definition constructor",
1899
+ );
1900
+ }
1901
+ const cacheKey = definition;
1902
+ const cached = this.stateHandles.get(cacheKey);
1903
+ if (cached !== undefined) return cached;
1904
+ const handle = stateSync(() =>
1905
+ access.owned(this.nativeContext, definition.name),
1906
+ );
1907
+ this.stateHandles.set(cacheKey, handle);
1908
+ return handle;
586
1909
  }
587
1910
  }
588
1911
 
589
1912
  module.exports = {
1913
+ AdminClient,
590
1914
  ConsumerState,
591
1915
  Context,
1916
+ DequeState,
592
1917
  EventHandlerError,
1918
+ MapState,
593
1919
  Mode,
594
1920
  PermanentError,
1921
+ PermanentStateError,
595
1922
  ProsodyClient,
1923
+ PublishedDeque,
1924
+ PublishedMap,
1925
+ PublishedValue,
596
1926
  TransientError,
1927
+ TransientStateError,
1928
+ ValueState,
1929
+ deque,
597
1930
  getCurrentLogger,
1931
+ flushTelemetry,
598
1932
  initialize,
1933
+ isStateError,
599
1934
  loggerIsSet,
1935
+ map,
1936
+ messageDeque,
1937
+ messageMap,
1938
+ messageValue,
600
1939
  permanent,
601
1940
  setLogger,
602
1941
  setLoggerIfUnset,
1942
+ shutdownTelemetry,
603
1943
  transient,
1944
+ value,
604
1945
  };