@classytic/arc 2.11.2 → 2.11.4

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 (113) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +20 -21
  3. package/bin/arc.js +2 -2
  4. package/dist/{BaseController-JNV08qOT.mjs → BaseController-swXruJ2_.mjs} +2 -2
  5. package/dist/EventTransport-BFQjw9pB.mjs +133 -0
  6. package/dist/{QueryCache-DOBNHBE0.d.mts → QueryCache-D41bfdBB.d.mts} +1 -1
  7. package/dist/{actionPermissions-C8YYU92K.mjs → actionPermissions-sUUKDhtP.mjs} +4 -2
  8. package/dist/adapters/index.d.mts +3 -3
  9. package/dist/adapters/index.mjs +2 -2
  10. package/dist/{adapters-D0tT2Tyo.mjs → adapters-DUUiiimH.mjs} +17 -2
  11. package/dist/audit/index.d.mts +2 -2
  12. package/dist/auth/index.d.mts +4 -4
  13. package/dist/auth/index.mjs +1 -1
  14. package/dist/auth/redis-session.d.mts +1 -1
  15. package/dist/cache/index.d.mts +3 -3
  16. package/dist/cli/commands/docs.mjs +1 -1
  17. package/dist/cli/commands/generate.d.mts +0 -2
  18. package/dist/cli/commands/generate.mjs +16 -16
  19. package/dist/cli/commands/init.mjs +149 -65
  20. package/dist/context/index.mjs +1 -1
  21. package/dist/core/index.d.mts +2 -2
  22. package/dist/core/index.mjs +3 -3
  23. package/dist/{core-DXdSSFW-.mjs → core-CbcQRIch.mjs} +25 -8
  24. package/dist/{createActionRouter-BwaSM0No.mjs → createActionRouter-CIKOcNA7.mjs} +74 -14
  25. package/dist/{createApp-P1d6rjPy.mjs → createApp-C9bRrqlX.mjs} +4 -6
  26. package/dist/defineEvent-D1Ky9M1D.mjs +188 -0
  27. package/dist/docs/index.d.mts +2 -2
  28. package/dist/docs/index.mjs +1 -1
  29. package/dist/{eventPlugin--5HIkdPU.mjs → eventPlugin-Cts2-Tfj.mjs} +9 -135
  30. package/dist/{eventPlugin-CUNjYYRY.d.mts → eventPlugin-DDJoNEPL.d.mts} +34 -7
  31. package/dist/events/index.d.mts +164 -5
  32. package/dist/events/index.mjs +138 -182
  33. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  34. package/dist/events/transports/redis-stream-entry.mjs +204 -31
  35. package/dist/events/transports/redis.d.mts +1 -1
  36. package/dist/factory/index.d.mts +1 -1
  37. package/dist/factory/index.mjs +1 -1
  38. package/dist/{fields-C8Y0XLAu.d.mts → fields-BRjxOAFp.d.mts} +1 -1
  39. package/dist/hooks/index.d.mts +1 -1
  40. package/dist/idempotency/index.d.mts +3 -3
  41. package/dist/idempotency/index.mjs +1 -1
  42. package/dist/idempotency/redis.d.mts +1 -1
  43. package/dist/{index-6u4_Gg6G.d.mts → index-CXXRbnf8.d.mts} +51 -5
  44. package/dist/{index-DdQ3O9Pg.d.mts → index-D9t1KNaB.d.mts} +2 -2
  45. package/dist/{index-BbMrcvGp.d.mts → index-Rg8axYPz.d.mts} +12 -4
  46. package/dist/{index-BdXnTPRj.d.mts → index-m8mOOlFW.d.mts} +3 -3
  47. package/dist/{index-BYCqHCVu.d.mts → index-rHjXmJar.d.mts} +3 -3
  48. package/dist/index.d.mts +7 -7
  49. package/dist/index.mjs +7 -7
  50. package/dist/integrations/event-gateway.d.mts +2 -2
  51. package/dist/integrations/index.d.mts +2 -2
  52. package/dist/integrations/mcp/index.d.mts +2 -2
  53. package/dist/integrations/mcp/index.mjs +1 -1
  54. package/dist/integrations/mcp/testing.d.mts +1 -1
  55. package/dist/integrations/mcp/testing.mjs +1 -1
  56. package/dist/integrations/websocket-redis.d.mts +1 -1
  57. package/dist/integrations/websocket.d.mts +1 -1
  58. package/dist/middleware/index.d.mts +1 -1
  59. package/dist/{openapi-C0L9ar7m.mjs → openapi-D7G1V7ex.mjs} +2 -2
  60. package/dist/org/index.d.mts +2 -2
  61. package/dist/permissions/index.d.mts +2 -2
  62. package/dist/permissions/index.mjs +1 -1
  63. package/dist/{permissions-B4vU9L0Q.mjs → permissions-gd_aUWrR.mjs} +42 -0
  64. package/dist/pipeline/index.d.mts +1 -1
  65. package/dist/plugins/index.d.mts +5 -5
  66. package/dist/plugins/index.mjs +1 -1
  67. package/dist/plugins/tracing-entry.d.mts +1 -1
  68. package/dist/plugins/tracing-entry.mjs +1 -1
  69. package/dist/presets/filesUpload.d.mts +4 -4
  70. package/dist/presets/filesUpload.mjs +1 -1
  71. package/dist/presets/index.d.mts +1 -1
  72. package/dist/presets/index.mjs +1 -1
  73. package/dist/presets/multiTenant.d.mts +1 -1
  74. package/dist/presets/search.d.mts +2 -2
  75. package/dist/presets/search.mjs +1 -1
  76. package/dist/{presets-k604Lj99.mjs → presets-Z7P5w4gF.mjs} +1 -1
  77. package/dist/{queryCachePlugin-BUXBSm4F.d.mts → queryCachePlugin-CqMdLI2-.d.mts} +2 -2
  78. package/dist/{redis-Cm1gnRDf.d.mts → redis-DiMkdHEl.d.mts} +1 -1
  79. package/dist/redis-stream-xTGxB2bm.d.mts +232 -0
  80. package/dist/registry/index.d.mts +1 -1
  81. package/dist/{requestContext-CfRkaxwf.mjs → requestContext-C5XeK3VA.mjs} +15 -0
  82. package/dist/{resourceToTools--okX6QBr.mjs → resourceToTools-CxNmI6xF.mjs} +7 -6
  83. package/dist/{routerShared-DeESFp4a.mjs → routerShared-BqLRb5l7.mjs} +60 -3
  84. package/dist/scope/index.d.mts +2 -2
  85. package/dist/testing/index.d.mts +2 -2
  86. package/dist/testing/index.mjs +1 -1
  87. package/dist/testing/storageContract.d.mts +1 -1
  88. package/dist/types/index.d.mts +4 -4
  89. package/dist/types/storage.d.mts +1 -1
  90. package/dist/{types-9beEMe25.d.mts → types-BQ9TJQNy.d.mts} +1 -1
  91. package/dist/{types-BH7dEGvU.d.mts → types-D7KpfiL1.d.mts} +10 -10
  92. package/dist/utils/index.d.mts +1 -1
  93. package/dist/utils/index.mjs +1 -1
  94. package/dist/{utils-D3Yxnrwr.mjs → utils-CcYTj09l.mjs} +1 -1
  95. package/dist/{versioning-M9lNLhO8.d.mts → versioning-DsglKfM_.d.mts} +1 -1
  96. package/package.json +3 -1
  97. package/skills/arc/SKILL.md +409 -769
  98. package/skills/arc/references/events.md +489 -489
  99. package/dist/redis-stream-CM8TXTix.d.mts +0 -110
  100. /package/dist/{EventTransport-CfVEGaEl.d.mts → EventTransport-CYNUXdCJ.d.mts} +0 -0
  101. /package/dist/{elevation-s5ykdNHr.d.mts → elevation-BQQXZ_VR.d.mts} +0 -0
  102. /package/dist/{errorHandler-Co3lnVmJ.d.mts → errorHandler-DEWmGWPz.d.mts} +0 -0
  103. /package/dist/{externalPaths-Bapitwvd.d.mts → externalPaths-BD5nw6St.d.mts} +0 -0
  104. /package/dist/{interface-CkkWm5uR.d.mts → interface-DfLGcus7.d.mts} +0 -0
  105. /package/dist/{interface-Da0r7Lna.d.mts → interface-beEtJyWM.d.mts} +0 -0
  106. /package/dist/{pluralize-BneOJkpi.mjs → pluralize-CWP6MB39.mjs} +0 -0
  107. /package/dist/{schemaIR-BlG9bY7v.mjs → schemaIR-Dy2p4MxS.mjs} +0 -0
  108. /package/dist/{sessionManager-D-oNWHz3.d.mts → sessionManager-C4Le_UB3.d.mts} +0 -0
  109. /package/dist/{storage-BwGQXUpd.d.mts → storage-Dfzt4VTl.d.mts} +0 -0
  110. /package/dist/{store-helpers-BhrzxvyQ.mjs → store-helpers-Cp4uKC1U.mjs} +0 -0
  111. /package/dist/{tracing-DokiEsuz.d.mts → tracing-QJVprktp.d.mts} +0 -0
  112. /package/dist/{types-tgR4Pt8F.d.mts → types-DDyTPc6y.d.mts} +0 -0
  113. /package/dist/{websocket-CyJ1VIFI.d.mts → websocket-ChC2rqe1.d.mts} +0 -0
@@ -1,184 +1,9 @@
1
- import { a as MemoryEventTransport, i as withRetry, o as createChildEvent, r as createDeadLetterPublisher, s as createEvent, t as eventPlugin } from "../eventPlugin--5HIkdPU.mjs";
2
- import { n as createSafeGetOne, t as createIsDuplicateKeyError } from "../store-helpers-BhrzxvyQ.mjs";
1
+ import { n as createChildEvent, r as createEvent, t as MemoryEventTransport } from "../EventTransport-BFQjw9pB.mjs";
2
+ import { n as defineEvent, t as createEventRegistry } from "../defineEvent-D1Ky9M1D.mjs";
3
+ import { i as withRetry, r as createDeadLetterPublisher, t as eventPlugin } from "../eventPlugin-Cts2-Tfj.mjs";
4
+ import { n as createSafeGetOne, t as createIsDuplicateKeyError } from "../store-helpers-Cp4uKC1U.mjs";
3
5
  import { and, anyOf, eq, lte, ne, or } from "@classytic/repo-core/filter";
4
6
  import { update } from "@classytic/repo-core/update";
5
- //#region src/events/defineEvent.ts
6
- /**
7
- * defineEvent — Typed Event Definitions with Optional Schema Validation
8
- *
9
- * Provides:
10
- * 1. defineEvent() — declare an event with name, schema, version, description
11
- * 2. EventRegistry — catalog of all known events + payload validation
12
- * 3. .create() helper — build DomainEvent with auto-generated metadata
13
- *
14
- * The built-in validator checks: object type, required fields, and top-level
15
- * property types. It does NOT recurse into nested objects, validate arrays,
16
- * enums, patterns, formats, or $ref. This is intentional — it's a lightweight
17
- * guard, not a full JSON Schema engine.
18
- *
19
- * For full validation, pass a custom `validate` function to `createEventRegistry()`:
20
- *
21
- * @example
22
- * ```typescript
23
- * import Ajv from 'ajv';
24
- * const ajv = new Ajv();
25
- *
26
- * const registry = createEventRegistry({
27
- * validate: (schema, payload) => {
28
- * const valid = ajv.validate(schema, payload);
29
- * return valid
30
- * ? { valid: true }
31
- * : { valid: false, errors: ajv.errorsText().split(', ') };
32
- * },
33
- * });
34
- * ```
35
- *
36
- * @example
37
- * ```typescript
38
- * import { defineEvent, createEventRegistry } from '@classytic/arc/events';
39
- *
40
- * const OrderCreated = defineEvent({
41
- * name: 'order.created',
42
- * version: 1,
43
- * schema: {
44
- * type: 'object',
45
- * properties: {
46
- * orderId: { type: 'string' },
47
- * total: { type: 'number' },
48
- * },
49
- * required: ['orderId', 'total'],
50
- * },
51
- * });
52
- *
53
- * // Type-safe event creation
54
- * const event = OrderCreated.create({ orderId: 'o-1', total: 100 });
55
- * await fastify.events.publish(event.type, event.payload, event.meta);
56
- *
57
- * // Registry for introspection + validation
58
- * const registry = createEventRegistry();
59
- * registry.register(OrderCreated);
60
- * const result = registry.validate('order.created', payload);
61
- * ```
62
- */
63
- /**
64
- * Define a typed event with optional schema validation.
65
- *
66
- * @example
67
- * const OrderCreated = defineEvent({
68
- * name: 'order.created',
69
- * schema: { type: 'object', properties: { orderId: { type: 'string' } }, required: ['orderId'] },
70
- * });
71
- *
72
- * const event = OrderCreated.create({ orderId: '123' });
73
- */
74
- function defineEvent(input) {
75
- const { name, schema, version = 1, description } = input;
76
- return {
77
- name,
78
- schema,
79
- version,
80
- description,
81
- create(payload, meta) {
82
- return createEvent(name, payload, meta);
83
- }
84
- };
85
- }
86
- /**
87
- * Create an event registry for cataloging and validating events.
88
- *
89
- * The registry is opt-in — unregistered events pass validation.
90
- * This allows gradual adoption without breaking existing code.
91
- *
92
- * @param options.validate - Custom validator replacing the built-in minimal validator.
93
- * The built-in validator only checks top-level object structure (type, required, property types).
94
- * For nested objects, arrays, enums, patterns, or $ref, provide AJV or similar.
95
- */
96
- function createEventRegistry(options) {
97
- const customValidator = options?.validate;
98
- const definitions = /* @__PURE__ */ new Map();
99
- function registryKey(name, version) {
100
- return `${name}:v${version}`;
101
- }
102
- return {
103
- register(definition) {
104
- const key = registryKey(definition.name, definition.version);
105
- if (definitions.has(key)) throw new Error(`Event '${definition.name}' v${definition.version} is already registered. Use a different version number for schema evolution.`);
106
- definitions.set(key, definition);
107
- },
108
- get(name, version) {
109
- if (version !== void 0) return definitions.get(registryKey(name, version));
110
- let latest;
111
- let latestVersion = -1;
112
- for (const def of definitions.values()) if (def.name === name && def.version > latestVersion) {
113
- latest = def;
114
- latestVersion = def.version;
115
- }
116
- return latest;
117
- },
118
- catalog() {
119
- return Array.from(definitions.values()).map((def) => ({
120
- name: def.name,
121
- version: def.version,
122
- description: def.description,
123
- schema: def.schema
124
- }));
125
- },
126
- validate(name, payload) {
127
- let latest;
128
- let latestVersion = -1;
129
- for (const def of definitions.values()) if (def.name === name && def.version > latestVersion) {
130
- latest = def;
131
- latestVersion = def.version;
132
- }
133
- if (!latest) return { valid: true };
134
- if (!latest.schema) return { valid: true };
135
- if (customValidator) return customValidator(latest.schema, payload);
136
- return validatePayload(payload, latest.schema);
137
- }
138
- };
139
- }
140
- /**
141
- * Built-in minimal validator — lightweight guard, NOT a full JSON Schema engine.
142
- *
143
- * Checks:
144
- * - payload is an object (not null, not array)
145
- * - required fields are present
146
- * - top-level property types match (string, number, boolean, array, object)
147
- *
148
- * Does NOT check:
149
- * - nested object properties
150
- * - array item types
151
- * - enum, pattern, format, minLength, minimum, $ref
152
- *
153
- * For full validation, pass a custom `validate` function to `createEventRegistry()`.
154
- */
155
- function validatePayload(payload, schema) {
156
- const errors = [];
157
- if (schema.type === "object") {
158
- if (payload === null || payload === void 0 || typeof payload !== "object" || Array.isArray(payload)) return {
159
- valid: false,
160
- errors: ["Payload must be an object"]
161
- };
162
- const record = payload;
163
- if (schema.required) {
164
- for (const field of schema.required) if (!(field in record) || record[field] === void 0) errors.push(`Missing required field: '${field}'`);
165
- }
166
- if (schema.properties) {
167
- for (const [key, propSchema] of Object.entries(schema.properties)) if (key in record && record[key] !== void 0 && record[key] !== null) {
168
- const expectedType = propSchema.type;
169
- if (expectedType) {
170
- const actualType = Array.isArray(record[key]) ? "array" : typeof record[key];
171
- if (expectedType !== actualType) errors.push(`Field '${key}': expected ${expectedType}, got ${actualType}`);
172
- }
173
- }
174
- }
175
- }
176
- return errors.length === 0 ? { valid: true } : {
177
- valid: false,
178
- errors
179
- };
180
- }
181
- //#endregion
182
7
  //#region src/events/eventTypes.ts
183
8
  /**
184
9
  * Event Type Constants and Helpers
@@ -217,12 +42,16 @@ function crudEventType(resource, suffix) {
217
42
  }
218
43
  /** Arc framework lifecycle events — emitted automatically by the framework */
219
44
  const ARC_LIFECYCLE_EVENTS = Object.freeze({
45
+ /** Emitted when a resource plugin is registered */
220
46
  RESOURCE_REGISTERED: "arc.resource.registered",
47
+ /** Emitted when Arc is fully ready (all resources registered, onReady fired) */
221
48
  READY: "arc.ready"
222
49
  });
223
50
  /** Cache-specific event types for observability and external triggers */
224
51
  const CACHE_EVENTS = Object.freeze({
52
+ /** Emitted when a resource's cache version is bumped */
225
53
  VERSION_BUMPED: "arc.cache.version.bumped",
54
+ /** Emitted when a tag version is bumped */
226
55
  TAG_VERSION_BUMPED: "arc.cache.tag.bumped"
227
56
  });
228
57
  //#endregion
@@ -489,7 +318,7 @@ function repositoryAsOutboxStore(repository) {
489
318
  code: error.code
490
319
  } : { message: error.message };
491
320
  const firstFailedAt = current.firstFailedAt ?? now;
492
- await r.findOneAndUpdate(filter, update({ set: {
321
+ if (await r.findOneAndUpdate(filter, update({ set: {
493
322
  status: targetStatus,
494
323
  visibleAt,
495
324
  leaseOwner: null,
@@ -497,7 +326,11 @@ function repositoryAsOutboxStore(repository) {
497
326
  lastFailedAt: now,
498
327
  lastError: errorInfo,
499
328
  firstFailedAt
500
- } }), { returnDocument: "after" });
329
+ } }), { returnDocument: "after" })) return;
330
+ if (options?.consumerId) {
331
+ const after = await safeGetOne(baseFilter);
332
+ if (after && after.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, after.leaseOwner);
333
+ }
501
334
  },
502
335
  async getDeadLettered(limit) {
503
336
  return unwrapDocs(await r.getAll({
@@ -878,4 +711,127 @@ function exponentialBackoff(options) {
878
711
  return new Date(now + jittered);
879
712
  }
880
713
  //#endregion
881
- export { ARC_LIFECYCLE_EVENTS, CACHE_EVENTS, CRUD_EVENT_SUFFIXES, EventOutbox, InvalidOutboxEventError, MemoryEventTransport, MemoryOutboxStore, OutboxOwnershipError, createChildEvent, createDeadLetterPublisher, createEvent, createEventRegistry, crudEventType, defineEvent, eventPlugin, exponentialBackoff, repositoryAsOutboxStore, withRetry };
714
+ //#region src/events/subscribe-helpers.ts
715
+ /**
716
+ * Pure handler wrapper — returns a new `EventHandler` that validates
717
+ * `event.payload` against the definition's schema before invoking the handler.
718
+ *
719
+ * The returned handler's input is `DomainEvent<unknown>` (since the transport
720
+ * delivers untyped events) but the inner `handler` receives `DomainEvent<T>`.
721
+ * No cast at the call site.
722
+ *
723
+ * @example
724
+ * ```ts
725
+ * await fastify.events.subscribe(
726
+ * OrderPaid.name,
727
+ * wrapWithSchema(OrderPaid, async (event) => {
728
+ * // event.payload is typed via the registered schema — no cast.
729
+ * await postSalesEntry(event.payload.orderId, event.payload.total);
730
+ * }),
731
+ * );
732
+ * ```
733
+ */
734
+ function wrapWithSchema(definition, handler, options = {}) {
735
+ const { validate, registry, onInvalid, logger = console, name } = options;
736
+ const label = name ?? definition.name;
737
+ return async (event) => {
738
+ const eventVersion = typeof event.meta?.schemaVersion === "number" ? event.meta.schemaVersion : definition.version;
739
+ let result;
740
+ if (validate && definition.schema) result = validate(definition.schema, event.payload);
741
+ else if (registry) result = registry.validate(definition.name, event.payload, eventVersion);
742
+ else if (definition.schema) {
743
+ const { createEventRegistry } = await import("../defineEvent-D1Ky9M1D.mjs").then((n) => n.r);
744
+ const adhoc = createEventRegistry();
745
+ adhoc.register(definition);
746
+ result = adhoc.validate(definition.name, event.payload);
747
+ } else result = { valid: true };
748
+ if (!result.valid) {
749
+ const errors = result.errors ?? ["payload failed validation"];
750
+ if (onInvalid) try {
751
+ await onInvalid(event, errors);
752
+ } catch (cbErr) {
753
+ logger.error(`[Arc Events] '${label}' onInvalid callback threw:`, cbErr);
754
+ }
755
+ else logger.warn(`[Arc Events] '${label}' skipped event ${event.meta?.id ?? "<no-id>"} — payload failed validation: ${errors.join("; ")}`);
756
+ return;
757
+ }
758
+ await handler(event);
759
+ };
760
+ }
761
+ /**
762
+ * Convenience: validate + subscribe in one call. Equivalent to
763
+ * `fastify.events.subscribe(definition.name, wrapWithSchema(definition, handler, options))`.
764
+ *
765
+ * Returns the unsubscribe function from the underlying transport.
766
+ *
767
+ * @example
768
+ * ```ts
769
+ * await subscribeWithSchema(fastify, OrderPaid, async (event) => {
770
+ * await postSalesEntry(event.payload.orderId, event.payload.total);
771
+ * });
772
+ *
773
+ * // Compose with withRetry — schema validation runs FIRST, then retry on
774
+ * // handler failure. Invalid payloads skip without burning retry attempts.
775
+ * await subscribeWithSchema(
776
+ * fastify,
777
+ * OrderPaid,
778
+ * withRetry(handler, { maxRetries: 3 }),
779
+ * );
780
+ * ```
781
+ */
782
+ async function subscribeWithSchema(fastify, definition, handler, options) {
783
+ return fastify.events.subscribe(definition.name, wrapWithSchema(definition, handler, options));
784
+ }
785
+ /**
786
+ * Pure handler wrapper — returns a new `EventHandler` that catches handler
787
+ * exceptions and routes them to `onError` (or logs and swallows). For
788
+ * projection / cache-invalidation / fire-and-forget handlers where retry
789
+ * would just delay the next-event resync, and where one bad event must NOT
790
+ * stop processing of subsequent events.
791
+ *
792
+ * Lighter than `withRetry`: no exponential backoff, no DLQ. Composes with
793
+ * `withRetry` if you want both ("retry, then log if exhausted, never throw").
794
+ *
795
+ * @example
796
+ * ```ts
797
+ * await fastify.events.subscribe(
798
+ * 'product:variants.changed',
799
+ * wrapWithBoundary(async (event) => {
800
+ * cache.invalidate(event.payload.productId);
801
+ * }),
802
+ * );
803
+ * ```
804
+ */
805
+ function wrapWithBoundary(handler, options = {}) {
806
+ const { onError, logger = console, name } = options;
807
+ const label = name ?? handler.name ?? "anonymous";
808
+ return async (event) => {
809
+ try {
810
+ await handler(event);
811
+ } catch (err) {
812
+ const error = err instanceof Error ? err : new Error(String(err));
813
+ if (onError) try {
814
+ await onError(error, event);
815
+ } catch (cbErr) {
816
+ logger.error(`[Arc Events] '${label}' onError callback threw:`, cbErr);
817
+ }
818
+ else logger.error(`[Arc Events] '${label}' threw on ${event.type} — swallowed (boundary): ${error.message}`, {
819
+ err: error,
820
+ event: event.type,
821
+ eventId: event.meta?.id,
822
+ handler: label
823
+ });
824
+ }
825
+ };
826
+ }
827
+ /**
828
+ * Convenience: subscribe + error-boundary in one call. Equivalent to
829
+ * `fastify.events.subscribe(pattern, wrapWithBoundary(handler, options))`.
830
+ *
831
+ * Returns the unsubscribe function from the underlying transport.
832
+ */
833
+ async function subscribeWithBoundary(fastify, pattern, handler, options) {
834
+ return fastify.events.subscribe(pattern, wrapWithBoundary(handler, options));
835
+ }
836
+ //#endregion
837
+ export { ARC_LIFECYCLE_EVENTS, CACHE_EVENTS, CRUD_EVENT_SUFFIXES, EventOutbox, InvalidOutboxEventError, MemoryEventTransport, MemoryOutboxStore, OutboxOwnershipError, createChildEvent, createDeadLetterPublisher, createEvent, createEventRegistry, crudEventType, defineEvent, eventPlugin, exponentialBackoff, repositoryAsOutboxStore, subscribeWithBoundary, subscribeWithSchema, withRetry, wrapWithBoundary, wrapWithSchema };
@@ -1,2 +1,2 @@
1
- import { n as RedisStreamTransport, r as RedisStreamTransportOptions, t as RedisStreamLike } from "../../redis-stream-CM8TXTix.mjs";
1
+ import { n as RedisStreamTransport, r as RedisStreamTransportOptions, t as RedisStreamLike } from "../../redis-stream-xTGxB2bm.mjs";
2
2
  export { type RedisStreamLike, RedisStreamTransport, type RedisStreamTransportOptions };
@@ -13,10 +13,34 @@ var RedisStreamTransport = class {
13
13
  maxLen;
14
14
  maxPayloadBytes;
15
15
  logger;
16
+ /** Tracks the lifecycle policy — set in constructor, read in close(). */
17
+ externalLifecycle;
18
+ closeTimeoutMs;
16
19
  handlers = /* @__PURE__ */ new Map();
17
20
  running = false;
18
21
  pollPromise = null;
22
+ /**
23
+ * Monotonic counter bumped every time the poll loop should stop —
24
+ * `unsubscribe` (last handler removed) and `close()` increment it. Each
25
+ * `pollLoop` instance captures its generation at start and exits when
26
+ * `this.generation` no longer matches. Prevents the
27
+ * subscribe → unsubscribe → fast-resubscribe race where the old loop
28
+ * would still be in `XREADGROUP BLOCK` while a new loop started, leading
29
+ * to two concurrent poll loops on the same consumer name.
30
+ */
31
+ generation = 0;
19
32
  groupCreated = false;
33
+ /**
34
+ * Last-seen failure context per message id, populated when an in-process
35
+ * handler throws in {@link processEntry}. Consumed (and cleared) by
36
+ * {@link moveToDlq} so the dead-letter envelope carries the actual error
37
+ * message instead of opaque "reclaimed without context". Bounded by
38
+ * `maxRetries × consumer-throughput` — entries are deleted on ack and
39
+ * on DLQ write, so the map naturally drains.
40
+ */
41
+ failureContext = /* @__PURE__ */ new Map();
42
+ /** One-shot guard so the "client lacks xrange" warning fires once per process. */
43
+ xrangeWarningEmitted = false;
20
44
  constructor(redis, options = {}) {
21
45
  this.redis = redis;
22
46
  this.stream = options.stream ?? "arc:events";
@@ -29,6 +53,8 @@ var RedisStreamTransport = class {
29
53
  this.deadLetterStream = options.deadLetterStream ?? "arc:events:dlq";
30
54
  this.maxLen = options.maxLen ?? 1e4;
31
55
  this.maxPayloadBytes = options.maxPayloadBytes ?? 1e6;
56
+ this.externalLifecycle = options.externalLifecycle ?? false;
57
+ this.closeTimeoutMs = options.closeTimeoutMs ?? 1e3;
32
58
  this.logger = options.logger ?? console;
33
59
  }
34
60
  async publish(event) {
@@ -55,9 +81,10 @@ var RedisStreamTransport = class {
55
81
  if (!this.running) {
56
82
  await this.ensureGroup();
57
83
  this.running = true;
58
- this.pollPromise = this.pollLoop().catch((err) => {
84
+ const myGen = ++this.generation;
85
+ this.pollPromise = this.pollLoop(myGen).catch((err) => {
59
86
  this.logger.error("[RedisStreamTransport] Poll loop crashed:", err);
60
- this.running = false;
87
+ if (this.generation === myGen) this.running = false;
61
88
  });
62
89
  }
63
90
  return () => {
@@ -66,16 +93,59 @@ var RedisStreamTransport = class {
66
93
  set.delete(handler);
67
94
  if (set.size === 0) this.handlers.delete(pattern);
68
95
  }
69
- if (this.handlers.size === 0 && this.running) this.running = false;
96
+ if (this.handlers.size === 0 && this.running) {
97
+ this.running = false;
98
+ this.generation++;
99
+ }
70
100
  };
71
101
  }
102
+ /**
103
+ * Stop polling and release transport state.
104
+ *
105
+ * **Two close contracts** — pick the one that matches your deployment:
106
+ *
107
+ * 1. **Default (`externalLifecycle: false`) — strict bounded close.**
108
+ * `close()` waits up to `closeTimeoutMs` for the in-flight
109
+ * `XREADGROUP BLOCK` to drain. On timeout it calls `redis.disconnect()`
110
+ * (or `quit()` if the client lacks `disconnect`) to break the BLOCK
111
+ * immediately, then awaits the loop's exit. After `close()` returns
112
+ * the transport is fully closed and the connection is released.
113
+ *
114
+ * 2. **`externalLifecycle: true` — bounded RETURN, background drain.**
115
+ * Arc must NOT touch a connection it doesn't own. `close()` returns
116
+ * within `closeTimeoutMs`, but the poll loop is left to drain on its
117
+ * own when its outstanding `XREADGROUP BLOCK` returns (up to
118
+ * `blockTimeMs`). Arc silently absorbs the loop's eventual completion
119
+ * so the host doesn't see unhandled rejections / log spam against a
120
+ * transport it considers closed. The host's own `redis.quit()` /
121
+ * process exit is what ultimately tears the connection down.
122
+ *
123
+ * Practical implication: under `externalLifecycle: true`, set
124
+ * `blockTimeMs` low (e.g. 500ms) so the background drain window is
125
+ * short. The transport is "closed enough" to stop dispatching to
126
+ * handlers (handlers map is cleared and generation is bumped) but is
127
+ * not "fully closed" in the connection-lifecycle sense until the host
128
+ * closes the underlying client.
129
+ *
130
+ * In both modes the generation counter is bumped, so a follow-up
131
+ * `subscribe()` spawns a fresh poll loop with a new generation — the
132
+ * stale loop exits on its next iteration and never overlaps the new one.
133
+ */
72
134
  async close() {
73
135
  this.running = false;
136
+ this.generation++;
74
137
  this.handlers.clear();
75
138
  if (this.pollPromise) {
76
- await this.pollPromise;
139
+ if (await Promise.race([this.pollPromise.then(() => "drained"), this.sleep(this.closeTimeoutMs).then(() => "timeout")]) === "timeout") if (!this.externalLifecycle) {
140
+ if (typeof this.redis.disconnect === "function") this.redis.disconnect();
141
+ else await this.redis.quit().catch((err) => {
142
+ this.logger.error("[RedisStreamTransport] quit() during close raced:", err);
143
+ });
144
+ await this.pollPromise.catch(() => void 0);
145
+ } else this.pollPromise.catch(() => void 0);
77
146
  this.pollPromise = null;
78
147
  }
148
+ if (!this.externalLifecycle) await this.redis.quit().catch(() => void 0);
79
149
  }
80
150
  async ensureGroup() {
81
151
  if (this.groupCreated) return;
@@ -86,12 +156,12 @@ var RedisStreamTransport = class {
86
156
  }
87
157
  this.groupCreated = true;
88
158
  }
89
- async pollLoop() {
90
- while (this.running) try {
159
+ async pollLoop(myGen) {
160
+ while (this.running && this.generation === myGen) try {
91
161
  await this.claimPending();
92
162
  await this.readNewMessages();
93
163
  } catch (err) {
94
- if (this.running) {
164
+ if (this.running && this.generation === myGen) {
95
165
  this.logger.error("[RedisStreamTransport] Poll error:", err);
96
166
  await this.sleep(1e3);
97
167
  }
@@ -123,39 +193,38 @@ var RedisStreamTransport = class {
123
193
  }
124
194
  }
125
195
  async processEntry(messageId, fields) {
126
- const fieldMap = /* @__PURE__ */ new Map();
127
- for (let i = 0; i < fields.length; i += 2) fieldMap.set(fields[i], fields[i + 1]);
128
- const eventType = fieldMap.get("type");
129
- const rawData = fieldMap.get("data");
130
- if (!eventType || !rawData) {
131
- await this.redis.xack(this.stream, this.group, messageId);
132
- return;
133
- }
134
- let event;
135
- try {
136
- const parsed = JSON.parse(rawData, (key, value) => {
137
- if (key === "timestamp" && typeof value === "string") return new Date(value);
138
- return value;
139
- });
140
- if (!parsed || typeof parsed !== "object" || typeof parsed.type !== "string" || !parsed.meta?.id) {
141
- this.logger.warn("[RedisStreamTransport] Malformed event — missing type or meta.id, acking and skipping");
142
- await this.redis.xack(this.stream, this.group, messageId);
143
- return;
144
- }
145
- event = parsed;
146
- } catch {
196
+ const event = parseStreamFields(fields);
197
+ if (!event) {
198
+ this.logger.warn(`[RedisStreamTransport] Malformed entry ${messageId} — missing type/data or invalid JSON, acking and skipping`);
147
199
  await this.redis.xack(this.stream, this.group, messageId);
148
200
  return;
149
201
  }
150
202
  const matchingHandlers = this.getMatchingHandlers(event.type);
151
203
  let allSucceeded = true;
204
+ let lastError;
205
+ let lastHandlerName;
152
206
  for (const handler of matchingHandlers) try {
153
207
  await handler(event);
154
208
  } catch (err) {
155
209
  allSucceeded = false;
210
+ lastError = err instanceof Error ? err : new Error(String(err));
211
+ lastHandlerName = handler.name || lastHandlerName;
156
212
  this.logger.error(`[RedisStreamTransport] Handler error for ${event.type}:`, err);
157
213
  }
158
- if (allSucceeded) await this.redis.xack(this.stream, this.group, messageId);
214
+ if (allSucceeded) {
215
+ await this.redis.xack(this.stream, this.group, messageId);
216
+ this.failureContext.delete(messageId);
217
+ return;
218
+ }
219
+ const now = /* @__PURE__ */ new Date();
220
+ const prior = this.failureContext.get(messageId);
221
+ this.failureContext.set(messageId, {
222
+ error: lastError ? toErrorRecord(lastError) : { message: "handler returned without acking — no error captured" },
223
+ firstFailedAt: prior?.firstFailedAt ?? now,
224
+ lastFailedAt: now,
225
+ attempts: (prior?.attempts ?? 0) + 1,
226
+ handlerName: lastHandlerName ?? prior?.handlerName
227
+ });
159
228
  }
160
229
  getMatchingHandlers(eventType) {
161
230
  const matched = [];
@@ -173,19 +242,123 @@ var RedisStreamTransport = class {
173
242
  }
174
243
  async moveToDlq(ids) {
175
244
  if (this.deadLetterStream === false) {
176
- for (const id of ids) await this.redis.xack(this.stream, this.group, id);
245
+ for (const id of ids) {
246
+ await this.redis.xack(this.stream, this.group, id);
247
+ this.failureContext.delete(id);
248
+ }
177
249
  return;
178
250
  }
179
251
  for (const id of ids) try {
180
- await this.redis.xadd(this.deadLetterStream, "*", "originalStream", this.stream, "originalId", id, "group", this.group, "failedAt", (/* @__PURE__ */ new Date()).toISOString());
252
+ const envelope = await this.buildDlqEnvelope(id);
253
+ if (!envelope) {
254
+ this.logger.error(`[RedisStreamTransport] DLQ for ${id}: source entry missing AND no failure context — acking to drop`);
255
+ await this.redis.xack(this.stream, this.group, id);
256
+ this.failureContext.delete(id);
257
+ continue;
258
+ }
259
+ await this.redis.xadd(this.deadLetterStream, "*", "type", envelope.event.type, "originalStream", this.stream, "originalId", id, "group", this.group, "data", JSON.stringify(envelope));
181
260
  await this.redis.xack(this.stream, this.group, id);
261
+ this.failureContext.delete(id);
182
262
  } catch (err) {
183
263
  this.logger.error(`[RedisStreamTransport] DLQ write failed for ${id}:`, err);
184
264
  }
185
265
  }
266
+ /**
267
+ * Reconstruct a `DeadLetteredEvent` for a message id. Reads the original
268
+ * entry via `xrange` (when the client supports it) and merges in any
269
+ * in-process failure context. Returns `null` only when BOTH sources are
270
+ * missing — callers ack-and-drop rather than re-queuing a ghost.
271
+ *
272
+ * Graceful degradation paths:
273
+ * - Client lacks `xrange` (older custom wrappers) → log once, build the
274
+ * envelope from `failureContext` alone. Payload is absent but the
275
+ * error reason + attempt accounting still survive.
276
+ * - `xrange` throws (network blip, ACL) → same fallback.
277
+ * - Source entry trimmed before DLQ write → same fallback.
278
+ */
279
+ async buildDlqEnvelope(id) {
280
+ const ctx = this.failureContext.get(id);
281
+ let event = null;
282
+ if (typeof this.redis.xrange === "function") try {
283
+ const fields = (await this.redis.xrange(this.stream, id, id))[0]?.[1];
284
+ if (fields) {
285
+ const parsed = parseStreamFields(fields);
286
+ if (parsed) event = parsed;
287
+ }
288
+ } catch (err) {
289
+ this.logger.error(`[RedisStreamTransport] xrange for DLQ source ${id} failed:`, err);
290
+ }
291
+ else if (!this.xrangeWarningEmitted) {
292
+ this.xrangeWarningEmitted = true;
293
+ this.logger.warn("[RedisStreamTransport] Redis client lacks xrange() — DLQ envelopes will not include the original event payload. Upgrade your client (ioredis ≥4 supports it) or use a wrapper that proxies xrange to enable replay.");
294
+ }
295
+ if (!event && !ctx) return null;
296
+ const fallbackTime = /* @__PURE__ */ new Date();
297
+ return {
298
+ event: event ?? {
299
+ type: "<unknown>",
300
+ payload: null,
301
+ meta: {
302
+ id,
303
+ timestamp: fallbackTime
304
+ }
305
+ },
306
+ error: ctx?.error ?? { message: "exhausted retries — failure occurred on a different consumer; error context not preserved across consumer-group failover" },
307
+ attempts: ctx?.attempts ?? this.maxRetries,
308
+ firstFailedAt: ctx?.firstFailedAt ?? fallbackTime,
309
+ lastFailedAt: ctx?.lastFailedAt ?? fallbackTime,
310
+ ...ctx?.handlerName ? { handlerName: ctx.handlerName } : {}
311
+ };
312
+ }
186
313
  sleep(ms) {
187
314
  return new Promise((resolve) => setTimeout(resolve, ms));
188
315
  }
189
316
  };
317
+ /**
318
+ * Convert a thrown value into the `DeadLetteredEvent.error` shape — message
319
+ * always present, optional `code` (string only) and `stack`. Centralised so
320
+ * the failure-context tracker and the DLQ envelope writer agree.
321
+ */
322
+ function toErrorRecord(err) {
323
+ const e = err instanceof Error ? err : new Error(String(err));
324
+ const code = e.code;
325
+ return {
326
+ message: e.message,
327
+ ...typeof code === "string" ? { code } : {},
328
+ ...e.stack ? { stack: e.stack } : {}
329
+ };
330
+ }
331
+ /**
332
+ * Parse a Redis Stream entry's flat `[key, value, key, value, ...]` field
333
+ * array into a typed `DomainEvent`, or `null` when the entry is malformed
334
+ * (missing `type` / `data`, unparseable JSON, or missing required event
335
+ * structure).
336
+ *
337
+ * Pure on purpose — used by both `processEntry` (the live consumer path)
338
+ * and `buildDlqEnvelope` (the dead-letter writer). Keeping the parse logic
339
+ * in one place avoids the silent drift class that produced the original
340
+ * "DLQ has no payload" bug.
341
+ */
342
+ function parseStreamFields(fields) {
343
+ let eventType;
344
+ let rawData;
345
+ for (let i = 0; i < fields.length; i += 2) {
346
+ const key = fields[i];
347
+ const value = fields[i + 1];
348
+ if (key === "type") eventType = value;
349
+ else if (key === "data") rawData = value;
350
+ }
351
+ if (!eventType || !rawData) return null;
352
+ try {
353
+ const parsed = JSON.parse(rawData, (key, value) => {
354
+ if (key === "timestamp" && typeof value === "string") return new Date(value);
355
+ return value;
356
+ });
357
+ if (!parsed || typeof parsed !== "object" || typeof parsed.type !== "string" || !parsed.meta?.id) return null;
358
+ return parsed;
359
+ } catch {
360
+ return null;
361
+ }
362
+ }
190
363
  //#endregion
191
364
  export { RedisStreamTransport };
@@ -1,4 +1,4 @@
1
- import { i as EventLogger, n as DomainEvent, o as EventTransport, r as EventHandler } from "../../EventTransport-CfVEGaEl.mjs";
1
+ import { i as EventLogger, n as DomainEvent, o as EventTransport, r as EventHandler } from "../../EventTransport-CYNUXdCJ.mjs";
2
2
 
3
3
  //#region src/events/transports/redis.d.ts
4
4
  interface RedisLike {