@classytic/arc 2.23.0 → 2.28.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 (147) hide show
  1. package/README.md +2 -0
  2. package/dist/{BaseController-BQ8KNvWC.mjs → BaseController-B65zbz9m.mjs} +94 -23
  3. package/dist/QueryCache-DOMT_iq7.d.mts +72 -0
  4. package/dist/actionPermissions-CAphcmUG.mjs +24 -0
  5. package/dist/{applyPermissionResult-Tw4iXtlf.mjs → applyPermissionResult-BnyVE9eD.mjs} +10 -0
  6. package/dist/audit/index.d.mts +32 -3
  7. package/dist/audit/index.mjs +7 -4
  8. package/dist/auth/index.d.mts +2 -2
  9. package/dist/auth/index.mjs +263 -259
  10. package/dist/{buildHandler-D_2ULWxZ.mjs → buildHandler-Dtwd8fdz.mjs} +1 -1
  11. package/dist/cache/index.d.mts +41 -3
  12. package/dist/cache/index.mjs +33 -4
  13. package/dist/cleanup/index.d.mts +313 -0
  14. package/dist/cleanup/index.mjs +1267 -0
  15. package/dist/cli/commands/describe.d.mts +1 -1
  16. package/dist/cli/commands/docs.mjs +1 -1
  17. package/dist/cli/commands/generate.mjs +1 -1
  18. package/dist/cli/commands/init.mjs +1 -1
  19. package/dist/cli/index.mjs +2 -2
  20. package/dist/context/index.mjs +2 -64
  21. package/dist/core/index.d.mts +3 -3
  22. package/dist/core/index.mjs +5 -4
  23. package/dist/core-BGa8TXJR.mjs +221 -0
  24. package/dist/{createActionRouter-BuOv-m4Y.mjs → createActionRouter-C-N7z2EL.mjs} +3 -2
  25. package/dist/{createAggregationRouter-CgJv_BU_.mjs → createAggregationRouter-wzwFa_dW.mjs} +3 -3
  26. package/dist/{createApp-CjOeWJ9_.mjs → createApp-Du5G54TM.mjs} +187 -96
  27. package/dist/{core-DQ6rDTyH.mjs → defineResource-2zvVVpxg.mjs} +297 -230
  28. package/dist/docs/index.d.mts +1 -1
  29. package/dist/docs/index.mjs +2 -2
  30. package/dist/{elevation-COpYKt2h.mjs → elevation-CIz1GjGm.mjs} +4 -3
  31. package/dist/{eventPlugin-A6EbthXR.d.mts → eventPlugin-Bg3cP2_u.d.mts} +38 -3
  32. package/dist/{eventPlugin-DJG2D7sy.mjs → eventPlugin-DrQ_FesK.mjs} +22 -4
  33. package/dist/events/index.d.mts +83 -314
  34. package/dist/events/index.mjs +183 -95
  35. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  36. package/dist/events/transports/redis-stream-entry.mjs +24 -2
  37. package/dist/factory/index.d.mts +5 -5
  38. package/dist/factory/index.mjs +30 -8
  39. package/dist/{fastify-augmentation-g_f9omSc.d.mts → fastify-augmentation-OzYGIiU1.d.mts} +26 -2
  40. package/dist/{generate-usRSdQmP.mjs → generate-D0wZupBg.mjs} +1 -1
  41. package/dist/headers-BvlQSDMB.mjs +45 -0
  42. package/dist/health-CB_m4S32.mjs +377 -0
  43. package/dist/hooks/index.d.mts +1 -1
  44. package/dist/idempotency/index.mjs +27 -3
  45. package/dist/{index-wMoWBLkn.d.mts → index-BMnbzvtO.d.mts} +3 -3
  46. package/dist/{integrations/jobs.d.mts → index-CG4yqMDB.d.mts} +82 -2
  47. package/dist/index-CZY4EGga.d.mts +107 -0
  48. package/dist/{index-CC5ruDGh.d.mts → index-CjVg-Epg.d.mts} +37 -5
  49. package/dist/{index-BBsvQt7U.d.mts → index-DPRH0Cme.d.mts} +36 -106
  50. package/dist/index.d.mts +6 -6
  51. package/dist/index.mjs +7 -6
  52. package/dist/{init-CjO4p_q1.mjs → init-tlRSt667.mjs} +206 -208
  53. package/dist/integrations/event-gateway.d.mts +1 -1
  54. package/dist/integrations/event-gateway.mjs +1 -1
  55. package/dist/integrations/index.d.mts +3 -3
  56. package/dist/integrations/jobs/index.d.mts +2 -0
  57. package/dist/integrations/{jobs.mjs → jobs/index.mjs} +110 -67
  58. package/dist/integrations/mcp/index.d.mts +4 -4
  59. package/dist/integrations/mcp/index.mjs +2 -2
  60. package/dist/integrations/mcp/testing.d.mts +1 -1
  61. package/dist/integrations/mcp/testing.mjs +1 -1
  62. package/dist/integrations/webhooks.d.mts +37 -30
  63. package/dist/integrations/webhooks.mjs +51 -62
  64. package/dist/integrations/websocket-redis.d.mts +1 -1
  65. package/dist/integrations/websocket.d.mts +1 -1
  66. package/dist/integrations/websocket.mjs +41 -14
  67. package/dist/{interface-C1_WLTMK.d.mts → interface-BGZCzOGL.d.mts} +24 -31
  68. package/dist/{mcpPlugin-BtzIL_kM.mjs → mcpPlugin-Cs6nRGCt.mjs} +12 -10
  69. package/dist/{memory-CLXA2VeJ.mjs → memory-CmnzXE5Z.mjs} +29 -0
  70. package/dist/middleware/index.d.mts +17 -1
  71. package/dist/middleware/index.mjs +1 -1
  72. package/dist/migrations/index.d.mts +94 -44
  73. package/dist/migrations/index.mjs +196 -44
  74. package/dist/module-_-M7C5ES.mjs +29 -0
  75. package/dist/{multipartBody-CHEQSZ6G.mjs → multipartBody-D0eBvkeq.mjs} +17 -0
  76. package/dist/{openapi-jSDx-VDv.mjs → openapi-BLV4OMXb.mjs} +3 -3
  77. package/dist/permissions/index.d.mts +2 -2
  78. package/dist/permissions/index.mjs +3 -3
  79. package/dist/{permissions-tbxFHxjZ.mjs → permissions-BrtiBKGs.mjs} +229 -229
  80. package/dist/pipeline/index.d.mts +1 -1
  81. package/dist/{actionPermissions-lszoJle1.mjs → pipeline-BGRnV-To.mjs} +4 -27
  82. package/dist/plugins/index.d.mts +6 -77
  83. package/dist/plugins/index.mjs +11 -95
  84. package/dist/plugins/response-cache.mjs +1 -0
  85. package/dist/plugins/tracing-entry.mjs +1 -1
  86. package/dist/{index-Ba10fdKp.d.mts → preloadResources-CM4uUaFv.d.mts} +51 -50
  87. package/dist/preloadResources-DSY5HGpS.mjs +45 -0
  88. package/dist/presets/filesUpload.d.mts +3 -3
  89. package/dist/presets/filesUpload.mjs +3 -3
  90. package/dist/presets/index.d.mts +2 -1
  91. package/dist/presets/index.mjs +1 -1
  92. package/dist/presets/multiTenant.d.mts +1 -1
  93. package/dist/presets/multiTenant.mjs +1 -1
  94. package/dist/presets/search.d.mts +2 -2
  95. package/dist/presets/search.mjs +1 -1
  96. package/dist/{presets-BOe4Ugkl.mjs → presets-DwFDVKP0.mjs} +2 -2
  97. package/dist/{queryCachePlugin-DzqLN904.mjs → queryCachePlugin-DSGuSVLW.mjs} +39 -8
  98. package/dist/{queryCachePlugin-CfYWq-Ak.d.mts → queryCachePlugin-br4otiqF.d.mts} +2 -2
  99. package/dist/{registerRoute-CWO6mdYe.mjs → rateLimit-DrED-C4v.mjs} +2 -109
  100. package/dist/{realtime-I8OrfnMR.mjs → realtime-DEuDu9io.mjs} +3 -3
  101. package/dist/{redis-stream-iVR2l7aS.d.mts → redis-stream-uIaauNpZ.d.mts} +22 -0
  102. package/dist/registerRoute-DTp18Ah_.mjs +108 -0
  103. package/dist/registry/index.d.mts +2 -2
  104. package/dist/registry/index.mjs +1 -1
  105. package/dist/{registry-vcOCtPij.mjs → registry-9km_w1aP.mjs} +15 -0
  106. package/dist/renewingLease-3cuQH7h8.mjs +44 -0
  107. package/dist/{requestContext-BZKOr13H.mjs → requestContext-MrlJwECf.mjs} +2 -2
  108. package/dist/schedules-CjOd2MG9.mjs +113 -0
  109. package/dist/schemas/index.d.mts +67 -67
  110. package/dist/schemas/index.mjs +2 -3
  111. package/dist/scim/index.mjs +1 -2
  112. package/dist/scope/index.d.mts +2 -2
  113. package/dist/scope/index.mjs +4 -4
  114. package/dist/{scope-LONQ3mfZ.mjs → scope-DfaPntpo.mjs} +4 -5
  115. package/dist/{sse-0u4P4-OI.mjs → sse-BQ1w0dfl.mjs} +1 -1
  116. package/dist/testing/cleanupStoreContract.d.mts +10 -0
  117. package/dist/testing/cleanupStoreContract.mjs +375 -0
  118. package/dist/testing/index.d.mts +5 -3
  119. package/dist/testing/index.mjs +4 -2
  120. package/dist/testing/outboxStoreContract.d.mts +13 -0
  121. package/dist/testing/outboxStoreContract.mjs +247 -0
  122. package/dist/transactionContext-PNdoyLLm.mjs +64 -0
  123. package/dist/types/index.d.mts +4 -4
  124. package/dist/{types-bHblZ9YN.mjs → types-CSyCxl2V.mjs} +38 -7
  125. package/dist/types-C_ZZRcD6.d.mts +450 -0
  126. package/dist/{module-B4u10iOJ.d.mts → types-Cu6vVJmh.d.mts} +196 -60
  127. package/dist/{types-DH0GupI7.d.mts → types-DjC-KUCz.d.mts} +118 -20
  128. package/dist/{types-Csxga7d3.d.mts → types-Dmw-6Qeo.d.mts} +96 -4
  129. package/dist/{types-5OuqeTDX.d.mts → types-QFGc3Cjv.d.mts} +1 -1
  130. package/dist/usage/index.d.mts +3 -52
  131. package/dist/usage/index.mjs +0 -0
  132. package/dist/userHelpers-Cg_y3fBz.mjs +29 -0
  133. package/dist/utils/index.d.mts +58 -85
  134. package/dist/utils/index.mjs +4 -2
  135. package/dist/{utils-CZyeRXTh.mjs → utils-taUVD4p3.mjs} +47 -337
  136. package/dist/{validate-C2mGgSXg.mjs → validate-Bk44F2yT.mjs} +1 -1
  137. package/dist/{versioning-D6PvCwpq.d.mts → versioning-DXgY5BUm.d.mts} +11 -29
  138. package/dist/{versioning-Dk-71iIz.mjs → versioning-gKWyryT3.mjs} +8 -1
  139. package/dist/{websocket-uuWqM6UY.d.mts → websocket-_Pd0CgR0.d.mts} +15 -1
  140. package/package.json +541 -513
  141. package/skills/arc/references/api-reference.md +3 -1
  142. package/skills/arc/references/integrations.md +20 -11
  143. package/dist/QueryCache-Z1B38koh.d.mts +0 -40
  144. package/dist/betterAuthOpenApi-CV1lxpRX.mjs +0 -2
  145. package/dist/health-ScyjIVo5.mjs +0 -202
  146. package/dist/requestId-5BhTVzBv.mjs +0 -113
  147. package/dist/types-XTApa8b6.mjs +0 -24
@@ -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
  /**
@@ -71,6 +72,16 @@ interface MemoryEntry {
71
72
  declare class MemoryOutboxStore implements OutboxStore {
72
73
  private readonly entries;
73
74
  private readonly seenDedupeKeys;
75
+ /**
76
+ * Drop every entry (including delivered / failed / dead-lettered rows) and
77
+ * the dedupe-key memory.
78
+ *
79
+ * For tests only — it is the in-memory analogue of truncating the table, and
80
+ * exists so a suite can isolate cases against ONE store instance (the
81
+ * `reset` hook of `runOutboxStoreContract`). Not part of `OutboxStore`; a
82
+ * production store must never expose this.
83
+ */
84
+ clear(): void;
74
85
  save(event: DomainEvent, options?: OutboxWriteOptions): Promise<void>;
75
86
  getPending(limit: number): Promise<DomainEvent[]>;
76
87
  claimPending(options?: OutboxClaimOptions): Promise<DomainEvent[]>;
@@ -82,279 +93,65 @@ declare class MemoryOutboxStore implements OutboxStore {
82
93
  _getEntry(eventId: string): Readonly<MemoryEntry> | undefined;
83
94
  }
84
95
  //#endregion
85
- //#region src/events/outbox.d.ts
86
- /**
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
- */
96
+ //#region src/events/outbox/backoff.d.ts
100
97
  /**
101
- * Options passed to {@link OutboxStore.save} for richer write semantics.
102
- * Stores may ignore fields they don't support — contract is best-effort.
98
+ * Exponential backoff for outbox failure policies — pure date math shared
99
+ * by `failurePolicy({ attempts })` style handlers.
103
100
  */
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
101
  /**
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}.
156
- */
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
- /**
172
- * Decision returned by {@link OutboxFailurePolicy} — controls how the
173
- * relay calls `store.fail()` for a failed event.
102
+ * Options for {@link exponentialBackoff}.
174
103
  */
175
- interface OutboxFailureDecision {
176
- /** Schedule retry for this time. Omit for immediate (next-poll) retry. */
177
- readonly retryAt?: Date;
104
+ interface ExponentialBackoffOptions {
105
+ /** Current attempt count (1-indexed — first retry is attempt 1) */
106
+ readonly attempt: number;
107
+ /** Base delay in ms (first retry delay). Default: 1000 (1 second) */
108
+ readonly baseMs?: number;
109
+ /** Maximum delay in ms — caps exponential growth. Default: 60_000 (1 minute) */
110
+ readonly maxMs?: number;
178
111
  /**
179
- * Move the event to dead-letter state (no further retries). When `true`,
180
- * {@link RelayResult.deadLettered} is incremented.
112
+ * Jitter factor [0–1]. The returned delay is multiplied by
113
+ * `1 + (random * jitter)` to spread retry bursts across workers.
114
+ * Default: 0.2 (±20%). Set to 0 to disable.
181
115
  */
182
- readonly deadLetter?: boolean;
116
+ readonly jitter?: number;
117
+ /** Reference time (for deterministic tests). Default: `Date.now()` */
118
+ readonly now?: number;
183
119
  }
184
120
  /**
185
- * Centralised retry/DLQ policy evaluated by {@link EventOutbox.relayBatch}
186
- * on every failure. Returns the options passed to `store.fail()`.
121
+ * Compute a `retryAt` `Date` using exponential backoff with jitter.
122
+ *
123
+ * This is a convenience helper for store authors implementing
124
+ * {@link OutboxStore.fail}: call it to compute the retry visibility window
125
+ * based on the event's current attempt count.
126
+ *
127
+ * Formula: `delay = min(maxMs, baseMs * 2^(attempt - 1)) * (1 + random * jitter)`
187
128
  *
188
- * Typical shape:
129
+ * @example Basic usage inside a store's `fail()` method
189
130
  * ```typescript
190
- * failurePolicy: ({ attempts, error }) => {
191
- * if (attempts >= 5) return { deadLetter: true };
192
- * return { retryAt: exponentialBackoff({ attempt: attempts }) };
131
+ * async fail(eventId, error, options) {
132
+ * const entry = await this.findById(eventId);
133
+ * entry.attempts++;
134
+ * if (entry.attempts >= MAX_ATTEMPTS) {
135
+ * return this.deadLetter(eventId, error);
136
+ * }
137
+ * const retryAt = exponentialBackoff({ attempt: entry.attempts });
138
+ * entry.visibleAt = retryAt;
139
+ * await this.update(entry);
193
140
  * }
194
141
  * ```
195
142
  *
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).
143
+ * @example Tuning for a faster transport
144
+ * ```typescript
145
+ * exponentialBackoff({ attempt: 3, baseMs: 250, maxMs: 10_000, jitter: 0.3 });
146
+ * // attempt=1 → ~250ms ±30%
147
+ * // attempt=2 → ~500ms ±30%
148
+ * // attempt=3 → ~1000ms ±30%
149
+ * // attempt=10 → capped at 10_000ms
150
+ * ```
263
151
  */
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
- }
152
+ declare function exponentialBackoff(options: ExponentialBackoffOptions): Date;
153
+ //#endregion
154
+ //#region src/events/outbox/relay.d.ts
358
155
  /** Reason codes passed to {@link EventOutboxOptions.onError}. */
359
156
  type OutboxRelayErrorKind = "publish_failed" | "acknowledge_failed" | "fail_failed" | "ownership_mismatch" | "malformed_event";
360
157
  /**
@@ -387,6 +184,14 @@ interface RelayResult {
387
184
  readonly deadLettered: number;
388
185
  /** Whether `publishMany` was used (true) or per-event `publish` (false) */
389
186
  readonly usedPublishMany: boolean;
187
+ /**
188
+ * Wall-clock ms spent claiming this batch from the store. With the
189
+ * portable repository adapter this is ~2 DB round trips; a persistently
190
+ * high value under low batch sizes points at store latency, not volume.
191
+ */
192
+ readonly claimMs?: number;
193
+ /** Wall-clock ms spent publishing + acknowledging the batch. */
194
+ readonly publishMs?: number;
390
195
  }
391
196
  /**
392
197
  * Called by {@link EventOutbox.relay} when a non-fatal error occurs during
@@ -598,61 +403,25 @@ declare class EventOutbox {
598
403
  */
599
404
  purge(olderThanMs?: number): Promise<number>;
600
405
  }
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;
406
+ //#endregion
407
+ //#region src/events/repository-outbox-adapter.d.ts
408
+ interface RepositoryOutboxStoreOptions {
611
409
  /**
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.
410
+ * Column holding the "not claimable before" timestamp. Default
411
+ * `'visibleAt'`.
412
+ *
413
+ * Exists so a host with an EXISTING outbox table can adopt this adapter
414
+ * without renaming a column and rebuilding its claim index — the one thing
415
+ * that otherwise forces a migration on the table that guarantees delivery.
416
+ * A store using `nextVisibleAt` (with a `{status, nextVisibleAt, createdAt}`
417
+ * index) passes `{ visibleAtField: 'nextVisibleAt' }` and keeps both.
418
+ *
419
+ * The field is used in the claimable filter, on `save`, and on `fail`'s
420
+ * retry scheduling — all three stay in lockstep by construction.
615
421
  */
616
- readonly jitter?: number;
617
- /** Reference time (for deterministic tests). Default: `Date.now()` */
618
- readonly now?: number;
422
+ readonly visibleAtField?: string;
619
423
  }
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
- //#endregion
654
- //#region src/events/repository-outbox-adapter.d.ts
655
- declare function repositoryAsOutboxStore(repository: RepositoryLike): OutboxStore;
424
+ declare function repositoryAsOutboxStore(repository: RepositoryLike, options?: RepositoryOutboxStoreOptions): OutboxStore;
656
425
  //#endregion
657
426
  //#region src/events/subscribe-helpers.d.ts
658
427
  /**
@@ -813,4 +582,4 @@ interface FastifyEventBus {
813
582
  };
814
583
  }
815
584
  //#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 };
585
+ 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 RepositoryOutboxStoreOptions, type RetryOptions, type ValidationResult, type WrapWithBoundaryOptions, type WrapWithSchemaOptions, createDeadLetterPublisher, createEventRegistry, crudEventType, defineEvent, eventPlugin, exponentialBackoff, repositoryAsOutboxStore, subscribeWithBoundary, subscribeWithSchema, withRetry, wrapWithBoundary, wrapWithSchema };