@classytic/arc 2.23.0 → 2.25.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 (132) hide show
  1. package/dist/{BaseController-BQ8KNvWC.mjs → BaseController--M8EMzt0.mjs} +90 -21
  2. package/dist/QueryCache-DOMT_iq7.d.mts +72 -0
  3. package/dist/actionPermissions-CAphcmUG.mjs +24 -0
  4. package/dist/audit/index.d.mts +32 -3
  5. package/dist/audit/index.mjs +7 -4
  6. package/dist/auth/index.d.mts +2 -2
  7. package/dist/auth/index.mjs +263 -259
  8. package/dist/{buildHandler-D_2ULWxZ.mjs → buildHandler-Dtwd8fdz.mjs} +1 -1
  9. package/dist/cache/index.d.mts +41 -3
  10. package/dist/cache/index.mjs +33 -4
  11. package/dist/cli/commands/describe.d.mts +1 -1
  12. package/dist/cli/commands/docs.mjs +1 -1
  13. package/dist/cli/commands/generate.mjs +1 -1
  14. package/dist/cli/commands/init.mjs +1 -1
  15. package/dist/cli/index.mjs +2 -2
  16. package/dist/context/index.mjs +2 -64
  17. package/dist/core/index.d.mts +3 -3
  18. package/dist/core/index.mjs +4 -4
  19. package/dist/{core-DQ6rDTyH.mjs → core-EUNu0u07.mjs} +296 -11
  20. package/dist/{createActionRouter-BuOv-m4Y.mjs → createActionRouter-B6GvAuAX.mjs} +3 -2
  21. package/dist/{createAggregationRouter-CgJv_BU_.mjs → createAggregationRouter-wzwFa_dW.mjs} +3 -3
  22. package/dist/docs/index.d.mts +1 -1
  23. package/dist/docs/index.mjs +2 -2
  24. package/dist/{elevation-COpYKt2h.mjs → elevation-CIz1GjGm.mjs} +4 -3
  25. package/dist/{eventPlugin-A6EbthXR.d.mts → eventPlugin-Bg3cP2_u.d.mts} +38 -3
  26. package/dist/{eventPlugin-DJG2D7sy.mjs → eventPlugin-DrQ_FesK.mjs} +22 -4
  27. package/dist/events/index.d.mts +57 -314
  28. package/dist/events/index.mjs +163 -91
  29. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  30. package/dist/events/transports/redis-stream-entry.mjs +24 -2
  31. package/dist/factory/index.d.mts +4 -5
  32. package/dist/factory/index.mjs +28 -8
  33. package/dist/{fastify-augmentation-g_f9omSc.d.mts → fastify-augmentation-dPNBVeIj.d.mts} +25 -2
  34. package/dist/{generate-usRSdQmP.mjs → generate-D0wZupBg.mjs} +1 -1
  35. package/dist/headers-BvlQSDMB.mjs +45 -0
  36. package/dist/health-CB_m4S32.mjs +377 -0
  37. package/dist/hooks/index.d.mts +1 -1
  38. package/dist/idempotency/index.mjs +27 -3
  39. package/dist/{index-wMoWBLkn.d.mts → index-BMnbzvtO.d.mts} +3 -3
  40. package/dist/{integrations/jobs.d.mts → index-CG4yqMDB.d.mts} +82 -2
  41. package/dist/{index-BBsvQt7U.d.mts → index-DPRH0Cme.d.mts} +36 -106
  42. package/dist/{module-B4u10iOJ.d.mts → index-KUoxjIq1.d.mts} +263 -23
  43. package/dist/{index-CC5ruDGh.d.mts → index-f4PTrk3B.d.mts} +37 -5
  44. package/dist/index.d.mts +6 -6
  45. package/dist/index.mjs +5 -5
  46. package/dist/{init-CjO4p_q1.mjs → init-tlRSt667.mjs} +206 -208
  47. package/dist/integrations/event-gateway.d.mts +1 -1
  48. package/dist/integrations/event-gateway.mjs +1 -1
  49. package/dist/integrations/index.d.mts +3 -3
  50. package/dist/integrations/jobs/index.d.mts +2 -0
  51. package/dist/integrations/{jobs.mjs → jobs/index.mjs} +110 -67
  52. package/dist/integrations/mcp/index.d.mts +4 -4
  53. package/dist/integrations/mcp/index.mjs +2 -2
  54. package/dist/integrations/mcp/testing.d.mts +1 -1
  55. package/dist/integrations/mcp/testing.mjs +1 -1
  56. package/dist/integrations/webhooks.d.mts +37 -30
  57. package/dist/integrations/webhooks.mjs +51 -62
  58. package/dist/integrations/websocket-redis.d.mts +1 -1
  59. package/dist/integrations/websocket.d.mts +1 -1
  60. package/dist/integrations/websocket.mjs +41 -14
  61. package/dist/{interface-C1_WLTMK.d.mts → interface-BGZCzOGL.d.mts} +24 -31
  62. package/dist/{mcpPlugin-BtzIL_kM.mjs → mcpPlugin-Cb7olw0j.mjs} +11 -9
  63. package/dist/{memory-CLXA2VeJ.mjs → memory-CmnzXE5Z.mjs} +29 -0
  64. package/dist/middleware/index.d.mts +17 -1
  65. package/dist/middleware/index.mjs +1 -1
  66. package/dist/migrations/index.d.mts +94 -44
  67. package/dist/migrations/index.mjs +196 -44
  68. package/dist/{multipartBody-CHEQSZ6G.mjs → multipartBody-D0eBvkeq.mjs} +17 -0
  69. package/dist/{openapi-jSDx-VDv.mjs → openapi-DrLtog_B.mjs} +3 -3
  70. package/dist/permissions/index.d.mts +2 -2
  71. package/dist/permissions/index.mjs +2 -2
  72. package/dist/{permissions-tbxFHxjZ.mjs → permissions-BrtiBKGs.mjs} +229 -229
  73. package/dist/pipeline/index.d.mts +1 -1
  74. package/dist/{actionPermissions-lszoJle1.mjs → pipeline-BN5G05rd.mjs} +3 -26
  75. package/dist/plugins/index.d.mts +6 -77
  76. package/dist/plugins/index.mjs +11 -95
  77. package/dist/plugins/response-cache.mjs +1 -0
  78. package/dist/plugins/tracing-entry.mjs +1 -1
  79. package/dist/{index-Ba10fdKp.d.mts → preloadResources-0oe-JSSS.d.mts} +50 -50
  80. package/dist/{createApp-CjOeWJ9_.mjs → preloadResources-DfyngIN9.mjs} +233 -70
  81. package/dist/presets/filesUpload.d.mts +3 -3
  82. package/dist/presets/filesUpload.mjs +3 -3
  83. package/dist/presets/index.d.mts +2 -1
  84. package/dist/presets/index.mjs +1 -1
  85. package/dist/presets/multiTenant.d.mts +1 -1
  86. package/dist/presets/multiTenant.mjs +1 -1
  87. package/dist/presets/search.d.mts +2 -2
  88. package/dist/presets/search.mjs +1 -1
  89. package/dist/{presets-BOe4Ugkl.mjs → presets-DwFDVKP0.mjs} +2 -2
  90. package/dist/{queryCachePlugin-DzqLN904.mjs → queryCachePlugin-DSGuSVLW.mjs} +39 -8
  91. package/dist/{queryCachePlugin-CfYWq-Ak.d.mts → queryCachePlugin-br4otiqF.d.mts} +2 -2
  92. package/dist/{registerRoute-CWO6mdYe.mjs → rateLimit-DrED-C4v.mjs} +2 -109
  93. package/dist/{realtime-I8OrfnMR.mjs → realtime-Cuw_deA-.mjs} +2 -2
  94. package/dist/{redis-stream-iVR2l7aS.d.mts → redis-stream-uIaauNpZ.d.mts} +22 -0
  95. package/dist/registerRoute-v0Wb6Plt.mjs +108 -0
  96. package/dist/registry/index.d.mts +2 -2
  97. package/dist/renewingLease-3cuQH7h8.mjs +44 -0
  98. package/dist/{requestContext-BZKOr13H.mjs → requestContext-MrlJwECf.mjs} +2 -2
  99. package/dist/schedules-CjOd2MG9.mjs +113 -0
  100. package/dist/schemas/index.d.mts +67 -67
  101. package/dist/schemas/index.mjs +2 -3
  102. package/dist/scim/index.mjs +1 -2
  103. package/dist/scope/index.d.mts +2 -2
  104. package/dist/scope/index.mjs +4 -4
  105. package/dist/{scope-LONQ3mfZ.mjs → scope-DfaPntpo.mjs} +4 -5
  106. package/dist/{sse-0u4P4-OI.mjs → sse-BQ1w0dfl.mjs} +1 -1
  107. package/dist/testing/index.d.mts +4 -3
  108. package/dist/testing/index.mjs +2 -2
  109. package/dist/transactionContext-PNdoyLLm.mjs +64 -0
  110. package/dist/types/index.d.mts +4 -4
  111. package/dist/{types-bHblZ9YN.mjs → types-CSyCxl2V.mjs} +38 -7
  112. package/dist/{types-DH0GupI7.d.mts → types-DjC-KUCz.d.mts} +118 -20
  113. package/dist/{types-Csxga7d3.d.mts → types-Dmw-6Qeo.d.mts} +96 -4
  114. package/dist/{types-5OuqeTDX.d.mts → types-QFGc3Cjv.d.mts} +1 -1
  115. package/dist/usage/index.d.mts +3 -52
  116. package/dist/usage/index.mjs +0 -0
  117. package/dist/userHelpers-Cg_y3fBz.mjs +29 -0
  118. package/dist/utils/index.d.mts +58 -85
  119. package/dist/utils/index.mjs +4 -2
  120. package/dist/{utils-CZyeRXTh.mjs → utils-taUVD4p3.mjs} +47 -337
  121. package/dist/{validate-C2mGgSXg.mjs → validate-Bk44F2yT.mjs} +1 -1
  122. package/dist/{versioning-D6PvCwpq.d.mts → versioning-DXgY5BUm.d.mts} +11 -29
  123. package/dist/{versioning-Dk-71iIz.mjs → versioning-gKWyryT3.mjs} +8 -1
  124. package/dist/{websocket-uuWqM6UY.d.mts → websocket-_Pd0CgR0.d.mts} +15 -1
  125. package/package.json +526 -513
  126. package/skills/arc/references/api-reference.md +3 -1
  127. package/skills/arc/references/integrations.md +20 -11
  128. package/dist/QueryCache-Z1B38koh.d.mts +0 -40
  129. package/dist/betterAuthOpenApi-CV1lxpRX.mjs +0 -2
  130. package/dist/health-ScyjIVo5.mjs +0 -202
  131. package/dist/requestId-5BhTVzBv.mjs +0 -113
  132. package/dist/types-XTApa8b6.mjs +0 -24
@@ -140,12 +140,41 @@ interface RetryOptions {
140
140
  */
141
141
  transport?: Pick<EventTransport, "deadLetter">;
142
142
  /**
143
- * Callback when all retries are exhausted. The event is "dead".
144
- * Use this to publish to a `$deadLetter` channel, log, alert, etc.
143
+ * DURABLE dead-letter persistence for exhausted events (custom store,
144
+ * `$deadLetter` publisher, ...). Treated exactly like
145
+ * {@link transport}.deadLetter: a failure follows {@link dlqFailureMode}
146
+ * — rethrow by default, so the transport redelivers instead of
147
+ * acknowledging an event that is neither processed nor persisted.
145
148
  *
146
- * Fires in addition to {@link transport} routing if both are set.
149
+ * This is what `eventPlugin({ deadLetterQueue })` wires. Use
150
+ * {@link onDead} for metrics/alerts whose failure must NOT affect
151
+ * acknowledgement.
152
+ */
153
+ deadLetter?: (event: DomainEvent, errors: Error[]) => void | Promise<void>;
154
+ /**
155
+ * OBSERVABILITY callback when all retries are exhausted (metrics,
156
+ * alerting, logging). Fires in addition to {@link transport} /
157
+ * {@link deadLetter} routing. Failures are logged and swallowed — this
158
+ * callback can never affect whether the event is acknowledged; durable
159
+ * persistence belongs in {@link deadLetter}.
147
160
  */
148
161
  onDead?: (event: DomainEvent, errors: Error[]) => void | Promise<void>;
162
+ /**
163
+ * What to do when `transport.deadLetter()` ITSELF fails after retries are
164
+ * exhausted.
165
+ *
166
+ * - `'rethrow'` (default) — the wrapper throws, so the transport treats
167
+ * the handler as failed: an at-least-once transport (Redis Streams,
168
+ * SQS) keeps the original message pending and redelivers it. The only
169
+ * mode that cannot LOSE the event — returning normally would
170
+ * acknowledge a message that was neither processed nor persisted.
171
+ * - `'log-and-drop'` — log the DLQ failure and return normally,
172
+ * acknowledging the message. Only correct when losing the event is
173
+ * acceptable (metrics, notifications).
174
+ *
175
+ * @default 'rethrow'
176
+ */
177
+ dlqFailureMode?: "rethrow" | "log-and-drop";
149
178
  /**
150
179
  * Optional name for logging + written into `DeadLetteredEvent.handlerName`.
151
180
  */
@@ -246,6 +275,12 @@ interface EventPluginOptions {
246
275
  * Dead letter queue for events that exhaust all retries.
247
276
  * Requires `retry` to be enabled. If `retry` is set but no custom `store`,
248
277
  * failed events are published to the `$deadLetter` event type by default.
278
+ *
279
+ * The store is DURABLE persistence: if it throws, the wrapped handler
280
+ * rethrows (per `retry.dlqFailureMode`, default `'rethrow'`) so an
281
+ * at-least-once transport redelivers instead of acknowledging an event
282
+ * that was neither processed nor dead-lettered. Metrics/alerting belongs
283
+ * in `retry.onDead`, whose failures never affect acknowledgement.
249
284
  */
250
285
  deadLetterQueue?: {
251
286
  /** Custom store function. If omitted, publishes to '$deadLetter' event type. */
@@ -1,7 +1,7 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
3
  import { d as createDomainError } from "./errors-DkO8Nk6-.mjs";
4
- import { n as requestContext } from "./requestContext-BZKOr13H.mjs";
4
+ import { n as requestContext } from "./requestContext-MrlJwECf.mjs";
5
5
  import fp from "fastify-plugin";
6
6
  import { createEvent, matchEventPattern } from "@classytic/primitives/events";
7
7
  //#region src/events/EventTransport.ts
@@ -118,12 +118,13 @@ var MemoryEventTransport = class {
118
118
  * to `unknown` for raw `subscribe(pattern, withRetry(...))` call sites.
119
119
  */
120
120
  function withRetry(handler, options = {}) {
121
- const { maxRetries = 3, backoffMs = 1e3, maxBackoffMs = 3e4, jitter = .1, transport, onDead, name, logger = console } = options;
121
+ const { maxRetries = 3, backoffMs = 1e3, maxBackoffMs = 3e4, jitter = .1, transport, deadLetter, onDead, dlqFailureMode = "rethrow", name, logger = console } = options;
122
122
  const label = name ?? handler.name ?? "anonymous";
123
123
  return async (event) => {
124
124
  const errors = [];
125
125
  let firstFailedAt;
126
126
  let lastFailedAt;
127
+ let dlqPersistError;
127
128
  for (let attempt = 0; attempt <= maxRetries; attempt++) try {
128
129
  await handler(event);
129
130
  return;
@@ -160,13 +161,21 @@ function withRetry(handler, options = {}) {
160
161
  await transport.deadLetter(dlq);
161
162
  } catch (dlqErr) {
162
163
  logger.error("[Arc Events] transport.deadLetter() failed:", dlqErr);
164
+ dlqPersistError = dlqErr instanceof Error ? dlqErr : new Error(String(dlqErr));
163
165
  }
164
166
  }
167
+ if (deadLetter) try {
168
+ await deadLetter(event, errors);
169
+ } catch (dlqErr) {
170
+ logger.error("[Arc Events] deadLetter() persistence failed:", dlqErr);
171
+ dlqPersistError ??= dlqErr instanceof Error ? dlqErr : new Error(String(dlqErr));
172
+ }
165
173
  if (onDead) try {
166
174
  await onDead(event, errors);
167
175
  } catch (dlqErr) {
168
- logger.error("[Arc Events] Dead letter callback failed:", dlqErr);
176
+ logger.error("[Arc Events] onDead observability callback failed:", dlqErr);
169
177
  }
178
+ if (dlqPersistError && dlqFailureMode === "rethrow") throw new Error(`[Arc Events] Handler '${label}' exhausted retries for ${event.type} AND dead-letter persistence failed — rethrowing so the transport redelivers instead of acknowledging a lost event (set dlqFailureMode: 'log-and-drop' to accept loss).`, { cause: dlqPersistError });
170
179
  };
171
180
  }
172
181
  /**
@@ -279,13 +288,22 @@ const eventPlugin = async (fastify, opts = {}) => {
279
288
  let wrappedHandler = handler;
280
289
  if (retryOpts && pattern !== "$deadLetter") wrappedHandler = withRetry(handler, {
281
290
  ...retryOpts,
282
- onDead: dlqOpts?.store ?? createDeadLetterPublisher(fastify.events),
291
+ deadLetter: dlqOpts?.store ?? createDeadLetterPublisher(fastify.events),
283
292
  logger: fastify.log
284
293
  });
285
294
  if (logEvents) fastify.log?.info?.({
286
295
  pattern,
287
296
  retry: !!retryOpts
288
297
  }, "Subscribing to events");
298
+ if (typeof transport.subscribe !== "function") {
299
+ const error = /* @__PURE__ */ new Error(`[Arc Events] transport '${transport.name}' is publish-only (no subscribe) — cannot register a handler for '${pattern}'. Use MemoryEventTransport / RedisEventTransport, or a transport that implements subscribe().`);
300
+ fastify.log?.error?.({
301
+ transport: transport.name,
302
+ pattern
303
+ }, error.message);
304
+ if (!failOpen) throw error;
305
+ return () => {};
306
+ }
289
307
  try {
290
308
  return await transport.subscribe(pattern, wrappedHandler);
291
309
  } catch (error) {
@@ -1,7 +1,8 @@
1
1
  import { a as EventTransport, i as EventLogger, n as DomainEvent, o as MemoryEventTransport, r as EventHandler, s as MemoryEventTransportOptions, t as DeadLetteredEvent } from "../EventTransport-QmY7A258.mjs";
2
- import { a as withRetry, c as EventDefinitionOutput, d as EventSchema, f as ValidationResult, i as createDeadLetterPublisher, l as EventRegistry, m as defineEvent, n as eventPlugin, o as CustomValidator, p as createEventRegistry, r as RetryOptions, s as EventDefinitionInput, t as EventPluginOptions, u as EventRegistryOptions } from "../eventPlugin-A6EbthXR.mjs";
2
+ import { a as withRetry, c as EventDefinitionOutput, d as EventSchema, f as ValidationResult, i as createDeadLetterPublisher, l as EventRegistry, m as defineEvent, n as eventPlugin, o as CustomValidator, p as createEventRegistry, r as RetryOptions, s as EventDefinitionInput, t as EventPluginOptions, u as EventRegistryOptions } from "../eventPlugin-Bg3cP2_u.mjs";
3
3
  import { RedisEventTransportOptions, RedisLike } from "./transports/redis.mjs";
4
- import { r as RedisStreamTransportOptions, t as RedisStreamLike } from "../redis-stream-iVR2l7aS.mjs";
4
+ import { r as RedisStreamTransportOptions, t as RedisStreamLike } from "../redis-stream-uIaauNpZ.mjs";
5
+ import { OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxErrorInfo, OutboxFailOptions, OutboxFailurePolicy, OutboxStore, OutboxWriteOptions } from "@classytic/primitives/outbox";
5
6
  import { RepositoryLike } from "@classytic/repo-core/adapter";
6
7
  //#region src/events/eventTypes.d.ts
7
8
  /**
@@ -82,279 +83,65 @@ declare class MemoryOutboxStore implements OutboxStore {
82
83
  _getEntry(eventId: string): Readonly<MemoryEntry> | undefined;
83
84
  }
84
85
  //#endregion
85
- //#region src/events/outbox.d.ts
86
+ //#region src/events/outbox/backoff.d.ts
86
87
  /**
87
- * **Terminology (v2.8.1+):**
88
- *
89
- * Arc's outbox uses **`delivered`** as the canonical term for "event has been
90
- * successfully published to the transport and marked by `acknowledge()`".
91
- *
92
- * - **`acknowledge()`** is the operation that transitions an event to delivered
93
- * - **`delivered`** is the resulting state
94
- * - **`deliveredAt`** is the timestamp column/field in every reference implementation
95
- *
96
- * Store authors should use `deliveredAt` for their timestamp field. Older
97
- * drafts of these docs used `acknowledgedAt` — that is a deprecated alias and
98
- * should not be used in new code.
99
- */
100
- /**
101
- * Options passed to {@link OutboxStore.save} for richer write semantics.
102
- * Stores may ignore fields they don't support — contract is best-effort.
103
- */
104
- interface OutboxWriteOptions {
105
- /**
106
- * Host-provided DB session/transaction handle for atomic writes.
107
- * Typed as `unknown` so stores can accept any backend (mongoose session,
108
- * pg client, Prisma tx, etc.) without Arc taking a peer dep.
109
- */
110
- readonly session?: unknown;
111
- /** Earliest time the event should be visible to relay workers (for delayed publishing) */
112
- readonly visibleAt?: Date;
113
- /** Idempotency key — stores that support it should dedupe on this */
114
- readonly dedupeKey?: string;
115
- /** Partition/routing key for sharded transports (Kafka, Redis Streams) */
116
- readonly partitionKey?: string;
117
- /** Arbitrary headers propagated to the transport layer */
118
- readonly headers?: Readonly<Record<string, string>>;
119
- }
120
- /**
121
- * Options for {@link OutboxStore.claimPending} — lease-based work claim.
122
- * Supports safe multi-worker relay: each worker atomically claims a batch
123
- * with a lease TTL, preventing duplicate publishes.
124
- */
125
- interface OutboxClaimOptions {
126
- /** Max events to claim (default: batchSize) */
127
- readonly limit?: number;
128
- /** Unique identifier for the claiming worker */
129
- readonly consumerId?: string;
130
- /** Lease duration in ms — claim is released automatically after this */
131
- readonly leaseMs?: number;
132
- /** Only claim events of these types (optional filter) */
133
- readonly types?: readonly string[];
134
- }
135
- /** Options for {@link OutboxStore.acknowledge} */
136
- interface OutboxAcknowledgeOptions {
137
- /** Worker identifier — stores may enforce "only owner can ack" */
138
- readonly consumerId?: string;
139
- }
140
- /** Options for {@link OutboxStore.fail} */
141
- interface OutboxFailOptions {
142
- /** Worker identifier — stores may enforce "only owner can fail" */
143
- readonly consumerId?: string;
144
- /** Schedule retry for a later time (implements backoff) */
145
- readonly retryAt?: Date;
146
- /** Move the event to dead-letter state (no further retries) */
147
- readonly deadLetter?: boolean;
148
- }
149
- /** Normalized error info passed to {@link OutboxStore.fail} */
150
- interface OutboxErrorInfo {
151
- readonly message: string;
152
- readonly code?: string;
153
- }
154
- /**
155
- * Context passed to {@link OutboxFailurePolicy}.
88
+ * Exponential backoff for outbox failure policies — pure date math shared
89
+ * by `failurePolicy({ attempts })` style handlers.
156
90
  */
157
- interface OutboxFailureContext {
158
- /** The event whose delivery just failed */
159
- readonly event: DomainEvent;
160
- /** The error returned by the transport / handler */
161
- readonly error: Error;
162
- /**
163
- * Attempt count including this failure (1 on the first fail).
164
- *
165
- * Tracked by {@link EventOutbox} in-process — accurate within a single
166
- * relay process, resets on restart. For durable attempt counts, query
167
- * your store directly inside the policy.
168
- */
169
- readonly attempts: number;
170
- }
171
91
  /**
172
- * Decision returned by {@link OutboxFailurePolicy} — controls how the
173
- * relay calls `store.fail()` for a failed event.
92
+ * Options for {@link exponentialBackoff}.
174
93
  */
175
- interface OutboxFailureDecision {
176
- /** Schedule retry for this time. Omit for immediate (next-poll) retry. */
177
- readonly retryAt?: Date;
94
+ interface ExponentialBackoffOptions {
95
+ /** Current attempt count (1-indexed — first retry is attempt 1) */
96
+ readonly attempt: number;
97
+ /** Base delay in ms (first retry delay). Default: 1000 (1 second) */
98
+ readonly baseMs?: number;
99
+ /** Maximum delay in ms — caps exponential growth. Default: 60_000 (1 minute) */
100
+ readonly maxMs?: number;
178
101
  /**
179
- * Move the event to dead-letter state (no further retries). When `true`,
180
- * {@link RelayResult.deadLettered} is incremented.
102
+ * Jitter factor [0–1]. The returned delay is multiplied by
103
+ * `1 + (random * jitter)` to spread retry bursts across workers.
104
+ * Default: 0.2 (±20%). Set to 0 to disable.
181
105
  */
182
- readonly deadLetter?: boolean;
106
+ readonly jitter?: number;
107
+ /** Reference time (for deterministic tests). Default: `Date.now()` */
108
+ readonly now?: number;
183
109
  }
184
110
  /**
185
- * Centralised retry/DLQ policy evaluated by {@link EventOutbox.relayBatch}
186
- * on every failure. Returns the options passed to `store.fail()`.
111
+ * Compute a `retryAt` `Date` using exponential backoff with jitter.
112
+ *
113
+ * This is a convenience helper for store authors implementing
114
+ * {@link OutboxStore.fail}: call it to compute the retry visibility window
115
+ * based on the event's current attempt count.
187
116
  *
188
- * Typical shape:
117
+ * Formula: `delay = min(maxMs, baseMs * 2^(attempt - 1)) * (1 + random * jitter)`
118
+ *
119
+ * @example Basic usage inside a store's `fail()` method
189
120
  * ```typescript
190
- * failurePolicy: ({ attempts, error }) => {
191
- * if (attempts >= 5) return { deadLetter: true };
192
- * return { retryAt: exponentialBackoff({ attempt: attempts }) };
121
+ * async fail(eventId, error, options) {
122
+ * const entry = await this.findById(eventId);
123
+ * entry.attempts++;
124
+ * if (entry.attempts >= MAX_ATTEMPTS) {
125
+ * return this.deadLetter(eventId, error);
126
+ * }
127
+ * const retryAt = exponentialBackoff({ attempt: entry.attempts });
128
+ * entry.visibleAt = retryAt;
129
+ * await this.update(entry);
193
130
  * }
194
131
  * ```
195
132
  *
196
- * Without a policy, the relay uses the legacy "immediate re-visibility" fail
197
- * behaviour (each fail() is equivalent to `{}`).
198
- */
199
- type OutboxFailurePolicy = (ctx: OutboxFailureContext) => OutboxFailureDecision | Promise<OutboxFailureDecision>;
200
- /**
201
- * Thrown by a store when `acknowledge` / `fail` is called by a consumer that
202
- * does not own the event's current lease.
203
- *
204
- * Stores that enforce lease ownership MUST throw this (not silently return)
205
- * so {@link EventOutbox} can detect the mismatch and avoid over-counting
206
- * successful deliveries. {@link EventOutbox.relay} catches it and reports
207
- * via {@link EventOutboxOptions.onError} instead of counting as relayed.
208
- */
209
- declare class OutboxOwnershipError extends Error {
210
- readonly eventId: string;
211
- readonly attemptedBy: string;
212
- readonly currentOwner: string | null;
213
- constructor(eventId: string, attemptedBy: string, currentOwner: string | null);
214
- }
215
- /** Thrown by {@link EventOutbox.store} when an event is missing `type` or `meta.id`. */
216
- declare class InvalidOutboxEventError extends Error {
217
- constructor(reason: string);
218
- }
219
- /**
220
- * Durable storage contract for the transactional outbox pattern.
221
- *
222
- * **Required methods**: `save`, `getPending`, `acknowledge`.
223
- *
224
- * **Optional capabilities** — stores opt-in to richer semantics:
225
- * - `claimPending` — lease-based work claim for multi-worker relay
226
- * - `fail` — failure tracking with retry scheduling and dead-letter
227
- * - `purge` — acknowledged event cleanup
228
- *
229
- * Arc's {@link EventOutbox} detects capabilities at runtime and degrades
230
- * gracefully for legacy stores that only implement the required methods.
231
- *
232
- * ## Required semantics
233
- *
234
- * Implementations MUST honor these contracts — `EventOutbox` depends on them
235
- * for correctness of at-least-once delivery:
236
- *
237
- * 1. **`save` must reject invalid events.** If `event.type` or `event.meta.id`
238
- * is missing/empty, throw — do not persist. `EventOutbox.store()` validates
239
- * first, but stores should defend against direct-save code paths.
240
- *
241
- * 2. **`claimPending` must be atomic.** Two workers calling `claimPending`
242
- * concurrently must never receive the same event. Stores backed by SQL/Mongo
243
- * should use `SELECT ... FOR UPDATE SKIP LOCKED` or `findOneAndUpdate` with
244
- * `{ leaseOwner: null }` as the match condition.
245
- *
246
- * 3. **`acknowledge` / `fail` must throw {@link OutboxOwnershipError} on
247
- * ownership mismatch.** When `options.consumerId` is provided and does not
248
- * match the current lease owner, the method MUST throw — never silently
249
- * no-op. `EventOutbox.relay` relies on this signal to avoid counting
250
- * hijacked events as successfully relayed.
251
- *
252
- * 4. **`acknowledge` on an unknown `eventId` is a no-op.** This keeps relay
253
- * idempotent when the store has been purged or the event was manually
254
- * removed. Do NOT throw `OutboxOwnershipError` in this case.
255
- *
256
- * 5. **`fail` must deterministically update lease/visibility.** On success,
257
- * the event MUST become re-claimable (either immediately or at `retryAt`),
258
- * or transition to dead-letter state. The lease owner should be cleared.
259
- *
260
- * 6. **Malformed events must never be returned by `getPending`/`claimPending`.**
261
- * If the store's underlying storage has corrupt rows, the store is
262
- * responsible for quarantining them (e.g., direct DB delete, DLQ).
133
+ * @example Tuning for a faster transport
134
+ * ```typescript
135
+ * exponentialBackoff({ attempt: 3, baseMs: 250, maxMs: 10_000, jitter: 0.3 });
136
+ * // attempt=1 → ~250ms ±30%
137
+ * // attempt=2 → ~500ms ±30%
138
+ * // attempt=3 → ~1000ms ±30%
139
+ * // attempt=10 → capped at 10_000ms
140
+ * ```
263
141
  */
264
- interface OutboxStore {
265
- /**
266
- * Save event to outbox (typically called within a business transaction).
267
- *
268
- * MUST reject events missing `type` or `meta.id` — throw rather than persist.
269
- *
270
- * @param event - Event to persist
271
- * @param options - Optional write metadata (session, visibleAt, dedupeKey, etc.)
272
- */
273
- save(event: DomainEvent, options?: OutboxWriteOptions): Promise<void>;
274
- /**
275
- * Get pending (unrelayed) events, ordered FIFO.
276
- *
277
- * This is the legacy/simple pull API. Multi-worker deployments should
278
- * prefer {@link OutboxStore.claimPending} to avoid duplicate publishes.
279
- *
280
- * Events returned by this method MUST be well-formed (valid `type` and
281
- * `meta.id`). Corrupt rows must be quarantined by the store, not exposed
282
- * to the relay loop.
283
- */
284
- getPending(limit: number): Promise<DomainEvent[]>;
285
- /**
286
- * Atomically claim pending events with a lease.
287
- *
288
- * When implemented, {@link EventOutbox.relay} prefers this over `getPending`
289
- * so multi-worker relay is safe: each worker holds an exclusive lease on
290
- * its batch, and stale leases are automatically recovered.
291
- *
292
- * **Atomicity is mandatory**: two concurrent callers must never receive
293
- * overlapping events. Use `SELECT ... FOR UPDATE SKIP LOCKED` (SQL) or
294
- * a compound condition on `findOneAndUpdate` (Mongo).
295
- */
296
- claimPending?(options?: OutboxClaimOptions): Promise<DomainEvent[]>;
297
- /**
298
- * Mark event as successfully relayed.
299
- *
300
- * **Ownership contract**: If `options.consumerId` is provided and does not
301
- * match the current lease owner, this method MUST throw
302
- * {@link OutboxOwnershipError}. Unknown `eventId` is a no-op.
303
- *
304
- * @param eventId - Event ID (from `meta.id`)
305
- * @param options - Optional ack metadata (consumerId for lease enforcement)
306
- * @throws {@link OutboxOwnershipError} on ownership mismatch
307
- */
308
- acknowledge(eventId: string, options?: OutboxAcknowledgeOptions): Promise<void>;
309
- /**
310
- * Record a relay failure. When implemented, {@link EventOutbox.relay} calls
311
- * this instead of stopping the batch — enables retry scheduling and DLQ.
312
- *
313
- * **Ownership contract**: Same as `acknowledge` — MUST throw
314
- * {@link OutboxOwnershipError} on mismatch. After a successful call, the
315
- * lease owner MUST be cleared and the event MUST be re-claimable (at
316
- * `retryAt` if provided) or transitioned to dead-letter.
317
- *
318
- * @throws {@link OutboxOwnershipError} on ownership mismatch
319
- */
320
- fail?(eventId: string, error: OutboxErrorInfo, options?: OutboxFailOptions): Promise<void>;
321
- /**
322
- * Return events currently in dead-letter state as typed
323
- * {@link DeadLetteredEvent} envelopes.
324
- *
325
- * Closes the loop between {@link OutboxStore.fail} (with `deadLetter: true`)
326
- * and {@link import('./EventTransport.js').DeadLetteredEvent} — apps get the
327
- * same shape out of the outbox that {@link import('./retry.js').withRetry}
328
- * delivers to transports. No hand-building DLQ envelopes.
329
- *
330
- * Implementations MUST populate `error`, `attempts`, and both
331
- * `firstFailedAt` / `lastFailedAt` from whatever they tracked during `fail()`
332
- * calls. `handlerName` is optional — stores that don't track it can omit.
333
- *
334
- * @param limit - Max envelopes to return
335
- */
336
- getDeadLettered?(limit: number): Promise<DeadLetteredEvent[]>;
337
- /**
338
- * Purge old **delivered** events (optional, DB-agnostic contract).
339
- *
340
- * Cleanup is scoped to events in the `delivered` state — events still
341
- * pending, failed, or dead-lettered MUST NOT be removed by purge.
342
- *
343
- * Arc does **not** ship a concrete implementation — your store owns the
344
- * cleanup strategy that fits your database:
345
- *
346
- * - **MongoDB:** TTL index on `deliveredAt` (automatic, zero-code)
347
- * - **SQL:** Scheduled `DELETE FROM outbox WHERE status = 'delivered' AND delivered_at < :cutoff`
348
- * - **Redis:** Key expiry (`EXPIRE`) on delivered entries
349
- *
350
- * Called by {@link EventOutbox.purge}. If not implemented, cleanup is
351
- * entirely the app's responsibility via native DB tools.
352
- *
353
- * @param olderThanMs - Remove events delivered more than this many ms ago
354
- * @returns Number of purged events
355
- */
356
- purge?(olderThanMs: number): Promise<number>;
357
- }
142
+ declare function exponentialBackoff(options: ExponentialBackoffOptions): Date;
143
+ //#endregion
144
+ //#region src/events/outbox/relay.d.ts
358
145
  /** Reason codes passed to {@link EventOutboxOptions.onError}. */
359
146
  type OutboxRelayErrorKind = "publish_failed" | "acknowledge_failed" | "fail_failed" | "ownership_mismatch" | "malformed_event";
360
147
  /**
@@ -387,6 +174,14 @@ interface RelayResult {
387
174
  readonly deadLettered: number;
388
175
  /** Whether `publishMany` was used (true) or per-event `publish` (false) */
389
176
  readonly usedPublishMany: boolean;
177
+ /**
178
+ * Wall-clock ms spent claiming this batch from the store. With the
179
+ * portable repository adapter this is ~2 DB round trips; a persistently
180
+ * high value under low batch sizes points at store latency, not volume.
181
+ */
182
+ readonly claimMs?: number;
183
+ /** Wall-clock ms spent publishing + acknowledging the batch. */
184
+ readonly publishMs?: number;
390
185
  }
391
186
  /**
392
187
  * Called by {@link EventOutbox.relay} when a non-fatal error occurs during
@@ -598,58 +393,6 @@ declare class EventOutbox {
598
393
  */
599
394
  purge(olderThanMs?: number): Promise<number>;
600
395
  }
601
- /**
602
- * Options for {@link exponentialBackoff}.
603
- */
604
- interface ExponentialBackoffOptions {
605
- /** Current attempt count (1-indexed — first retry is attempt 1) */
606
- readonly attempt: number;
607
- /** Base delay in ms (first retry delay). Default: 1000 (1 second) */
608
- readonly baseMs?: number;
609
- /** Maximum delay in ms — caps exponential growth. Default: 60_000 (1 minute) */
610
- readonly maxMs?: number;
611
- /**
612
- * Jitter factor [0–1]. The returned delay is multiplied by
613
- * `1 + (random * jitter)` to spread retry bursts across workers.
614
- * Default: 0.2 (±20%). Set to 0 to disable.
615
- */
616
- readonly jitter?: number;
617
- /** Reference time (for deterministic tests). Default: `Date.now()` */
618
- readonly now?: number;
619
- }
620
- /**
621
- * Compute a `retryAt` `Date` using exponential backoff with jitter.
622
- *
623
- * This is a convenience helper for store authors implementing
624
- * {@link OutboxStore.fail}: call it to compute the retry visibility window
625
- * based on the event's current attempt count.
626
- *
627
- * Formula: `delay = min(maxMs, baseMs * 2^(attempt - 1)) * (1 + random * jitter)`
628
- *
629
- * @example Basic usage inside a store's `fail()` method
630
- * ```typescript
631
- * async fail(eventId, error, options) {
632
- * const entry = await this.findById(eventId);
633
- * entry.attempts++;
634
- * if (entry.attempts >= MAX_ATTEMPTS) {
635
- * return this.deadLetter(eventId, error);
636
- * }
637
- * const retryAt = exponentialBackoff({ attempt: entry.attempts });
638
- * entry.visibleAt = retryAt;
639
- * await this.update(entry);
640
- * }
641
- * ```
642
- *
643
- * @example Tuning for a faster transport
644
- * ```typescript
645
- * exponentialBackoff({ attempt: 3, baseMs: 250, maxMs: 10_000, jitter: 0.3 });
646
- * // attempt=1 → ~250ms ±30%
647
- * // attempt=2 → ~500ms ±30%
648
- * // attempt=3 → ~1000ms ±30%
649
- * // attempt=10 → capped at 10_000ms
650
- * ```
651
- */
652
- declare function exponentialBackoff(options: ExponentialBackoffOptions): Date;
653
396
  //#endregion
654
397
  //#region src/events/repository-outbox-adapter.d.ts
655
398
  declare function repositoryAsOutboxStore(repository: RepositoryLike): OutboxStore;
@@ -813,4 +556,4 @@ interface FastifyEventBus {
813
556
  };
814
557
  }
815
558
  //#endregion
816
- export { ARC_LIFECYCLE_EVENTS, type ArcLifecycleEvent, CACHE_EVENTS, CRUD_EVENT_SUFFIXES, type CacheEvent, type CrudEventSuffix, type CustomValidator, type EventDefinitionInput, type EventDefinitionOutput, EventOutbox, type EventOutboxOptions, type EventPluginOptions, type EventRegistry, type EventRegistryOptions, type EventSchema, type ExponentialBackoffOptions, InvalidOutboxEventError, MemoryEventTransport, type MemoryEventTransportOptions, MemoryOutboxStore, type OutboxAcknowledgeOptions, type OutboxClaimOptions, type OutboxErrorInfo, type OutboxFailOptions, type OutboxFailureContext, type OutboxFailureDecision, type OutboxFailurePolicy, OutboxOwnershipError, type OutboxRelayErrorHandler, type OutboxRelayErrorKind, type OutboxStore, type OutboxWriteOptions, type PayloadOf, type RedisEventTransportOptions, type RedisLike, type RedisStreamLike, type RedisStreamTransportOptions, type RelayResult, type RetryOptions, type ValidationResult, type WrapWithBoundaryOptions, type WrapWithSchemaOptions, createDeadLetterPublisher, createEventRegistry, crudEventType, defineEvent, eventPlugin, exponentialBackoff, repositoryAsOutboxStore, subscribeWithBoundary, subscribeWithSchema, withRetry, wrapWithBoundary, wrapWithSchema };
559
+ export { ARC_LIFECYCLE_EVENTS, type ArcLifecycleEvent, CACHE_EVENTS, CRUD_EVENT_SUFFIXES, type CacheEvent, type CrudEventSuffix, type CustomValidator, type EventDefinitionInput, type EventDefinitionOutput, EventOutbox, type EventOutboxOptions, type EventPluginOptions, type EventRegistry, type EventRegistryOptions, type EventSchema, type ExponentialBackoffOptions, MemoryEventTransport, type MemoryEventTransportOptions, MemoryOutboxStore, type OutboxRelayErrorHandler, type OutboxRelayErrorKind, type PayloadOf, type RedisEventTransportOptions, type RedisLike, type RedisStreamLike, type RedisStreamTransportOptions, type RelayResult, type RetryOptions, type ValidationResult, type WrapWithBoundaryOptions, type WrapWithSchemaOptions, createDeadLetterPublisher, createEventRegistry, crudEventType, defineEvent, eventPlugin, exponentialBackoff, repositoryAsOutboxStore, subscribeWithBoundary, subscribeWithSchema, withRetry, wrapWithBoundary, wrapWithSchema };