@classytic/arc 2.32.0 → 2.33.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 (192) hide show
  1. package/README.md +1 -1
  2. package/bin/arc.js +58 -17
  3. package/dist/{BaseController-Dk34dxhI.mjs → BaseController-CaQC5EXX.mjs} +293 -33
  4. package/dist/{EventTransport-2uVV34WU.d.mts → EventTransport-BEVP50ka.d.mts} +16 -0
  5. package/dist/{EventTransport-BV10D0oG.mjs → EventTransport-tq4CEB-q.mjs} +39 -2
  6. package/dist/{HookSystem-vimw5D7Z.mjs → HookSystem-DqXkMXkB.mjs} +2 -2
  7. package/dist/{ResourceRegistry-BVr7PgA0.mjs → ResourceRegistry-DoALUlOO.mjs} +3 -2
  8. package/dist/{actionPermissions-Dti7ebyv.mjs → actionPermissions-CUeCmpUd.mjs} +1 -1
  9. package/dist/audit/index.d.mts +2 -2
  10. package/dist/audit/index.mjs +2 -2
  11. package/dist/auth/audit.mjs +1 -1
  12. package/dist/auth/index.d.mts +2 -2
  13. package/dist/auth/index.mjs +39 -7
  14. package/dist/auth/redis-session.mjs +1 -1
  15. package/dist/{authorizationDecision-__T3EJDA.mjs → authorizationDecision-B3mP_QTy.mjs} +2 -2
  16. package/dist/{types-B7XeLEgK.d.mts → base-Csjc2oF-.d.mts} +74 -74
  17. package/dist/{buildHandler-Dn7rzUJo.mjs → buildHandler-DLE9xX-V.mjs} +4 -4
  18. package/dist/cache/index.mjs +4 -4
  19. package/dist/{caching-DFJqHyFs.mjs → caching-BcGmvnHh.mjs} +2 -2
  20. package/dist/cleanup/index.d.mts +17 -5
  21. package/dist/cleanup/index.mjs +240 -7
  22. package/dist/cli/commands/describe.d.mts +1 -1
  23. package/dist/cli/commands/describe.mjs +3 -3
  24. package/dist/cli/commands/docs.mjs +3 -3
  25. package/dist/cli/commands/doctor.mjs +1 -1
  26. package/dist/cli/commands/generate.mjs +2 -2
  27. package/dist/cli/commands/init.mjs +2 -2
  28. package/dist/cli/commands/introspect.mjs +2 -2
  29. package/dist/cli/index.mjs +3 -3
  30. package/dist/{constants-J99LGTJI.mjs → constants-BxCoxmiN.mjs} +1 -1
  31. package/dist/context/index.mjs +3 -3
  32. package/dist/core/index.d.mts +2 -2
  33. package/dist/core/index.mjs +7 -7
  34. package/dist/{core-BwwmNSp7.mjs → core-dxhcHhUD.mjs} +3 -3
  35. package/dist/{createActionRouter-DkJZWo0m.mjs → createActionRouter-DVpoA0do.mjs} +8 -8
  36. package/dist/{createAggregationRouter-B1exOVD8.mjs → createAggregationRouter-B_B48JZ-.mjs} +7 -7
  37. package/dist/{createApp-Bv0m-Q6_.mjs → createApp-CLGegqiG.mjs} +128 -24
  38. package/dist/{defineAggregation-gX4No1gt.mjs → defineAggregation-Nv-DwfOf.mjs} +1 -1
  39. package/dist/{defineResource-XFPPUu2c.mjs → defineResource-DR9vsUYy.mjs} +297 -23
  40. package/dist/discovery/index.mjs +1 -1
  41. package/dist/docs/index.d.mts +1 -1
  42. package/dist/docs/index.mjs +3 -3
  43. package/dist/{elevation-DNXXxgjl.mjs → elevation-B0tlY8y1.mjs} +4 -4
  44. package/dist/encryption/index.mjs +3 -3
  45. package/dist/{errorHandler-B2AZUJUj.mjs → errorHandler-C7xBqHsI.mjs} +42 -5
  46. package/dist/{errorHandler-CpYUJmPA.d.mts → errorHandler-DI5AN2b5.d.mts} +14 -3
  47. package/dist/{errors-Bh1KK5ps.mjs → errors-Cd_yy7aW.mjs} +1 -1
  48. package/dist/{eventPlugin-GF5nnBIq.d.mts → eventPlugin-DSNEZWfV.d.mts} +29 -1
  49. package/dist/{eventPlugin-Dk_TyXeE.mjs → eventPlugin-Dar8qRaj.mjs} +65 -9
  50. package/dist/events/index.d.mts +5 -351
  51. package/dist/events/index.mjs +5 -780
  52. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  53. package/dist/events/transports/redis-stream-entry.mjs +1 -1
  54. package/dist/events/transports/redis.d.mts +1 -1
  55. package/dist/events/transports/redis.mjs +1 -1
  56. package/dist/factory/index.d.mts +20 -5
  57. package/dist/factory/index.mjs +7 -7
  58. package/dist/{fastify-augmentation-BppG5bMT.d.mts → fastify-augmentation-DVsTAy6X.d.mts} +3 -3
  59. package/dist/{fields-DEi65pGj.mjs → fields-CojeUkS4.mjs} +1 -1
  60. package/dist/{generate-CRSG0jTv.mjs → generate-DiB0nHPR.mjs} +2 -2
  61. package/dist/{headers-BXjQYbeC.mjs → headers-CveNO9gR.mjs} +2 -2
  62. package/dist/{health-5XnTa2uo.mjs → health-CasG2A28.mjs} +1 -1
  63. package/dist/hooks/index.d.mts +1 -1
  64. package/dist/hooks/index.mjs +2 -2
  65. package/dist/idempotency/index.d.mts +45 -1
  66. package/dist/idempotency/index.mjs +91 -9
  67. package/dist/idempotency/redis.mjs +1 -1
  68. package/dist/{index-zq6VrjaB.d.mts → index-B-DvUzsr.d.mts} +12 -2
  69. package/dist/{index-CpzxqePp.d.mts → index-BcR2bFZQ.d.mts} +707 -373
  70. package/dist/{index-DXlHwvuE.d.mts → index-Brin0ODU.d.mts} +4 -4
  71. package/dist/{index-ZWhLTp_s.d.mts → index-C5NnMEKF.d.mts} +1 -1
  72. package/dist/{index-C-481Zf2.d.mts → index-ugr1tQlW.d.mts} +17 -1
  73. package/dist/index.d.mts +6 -6
  74. package/dist/index.mjs +11 -11
  75. package/dist/{init-BbfP8aky.mjs → init-mdJ-v4Xp.mjs} +18 -12
  76. package/dist/integrations/event-gateway.d.mts +1 -1
  77. package/dist/integrations/event-gateway.mjs +2 -2
  78. package/dist/integrations/index.d.mts +2 -2
  79. package/dist/integrations/index.mjs +1 -1
  80. package/dist/integrations/jobs/index.d.mts +1 -1
  81. package/dist/integrations/jobs/index.mjs +99 -97
  82. package/dist/integrations/mcp/index.d.mts +12 -3
  83. package/dist/integrations/mcp/index.mjs +2 -2
  84. package/dist/integrations/mcp/testing.d.mts +1 -1
  85. package/dist/integrations/mcp/testing.mjs +2 -2
  86. package/dist/integrations/streamline.mjs +3 -3
  87. package/dist/integrations/webhooks.d.mts +124 -1
  88. package/dist/integrations/webhooks.mjs +180 -2
  89. package/dist/integrations/websocket-pushref-redis.mjs +1 -1
  90. package/dist/integrations/websocket-redis.mjs +1 -1
  91. package/dist/integrations/websocket.mjs +1 -1
  92. package/dist/{interface-BP4IJCBO.mjs → interface-DznJrm9R.mjs} +1 -1
  93. package/dist/{jsonBody-DDSR2HFl.mjs → jsonBody-CfGH-GxO.mjs} +2 -2
  94. package/dist/{keys-B-HBTGXl.mjs → keys-WeKvY1dE.mjs} +1 -1
  95. package/dist/{loadResources-DisEAmkm.mjs → loadResources-Bj-aSsXi.mjs} +2 -2
  96. package/dist/{loadResourcesFromEntry-D1MJp13f.mjs → loadResourcesFromEntry-DwaxNcE2.mjs} +2 -2
  97. package/dist/logger/index.mjs +1 -1
  98. package/dist/{mcpPlugin-CpfVpKHE.mjs → mcpPlugin-aJg5TqHi.mjs} +60 -21
  99. package/dist/{memory-DsuWnj4T.mjs → memory-qYSB7rMQ.mjs} +2 -2
  100. package/dist/{metrics-DdQL8syd.mjs → metrics-D7uchLjN.mjs} +2 -2
  101. package/dist/middleware/index.d.mts +1 -1
  102. package/dist/middleware/index.mjs +3 -3
  103. package/dist/migrations/index.mjs +2 -2
  104. package/dist/{module-Br81am6m.mjs → module-CwKsgZXp.mjs} +1 -1
  105. package/dist/{multipartBody-BnkBLN7v.mjs → multipartBody-4w-S3-PU.mjs} +1 -1
  106. package/dist/{openapi-DIRzvxC0.mjs → openapi-BN5cQaO2.mjs} +6 -6
  107. package/dist/outbox-admin/index.d.mts +2 -2
  108. package/dist/outbox-admin/index.mjs +4 -4
  109. package/dist/outbox-module-CXB79bvt.mjs +839 -0
  110. package/dist/outbox-module-D-VFq37T.d.mts +368 -0
  111. package/dist/permissions/index.d.mts +2 -2
  112. package/dist/permissions/index.mjs +5 -5
  113. package/dist/{permissions-Dl_m0NCe.mjs → permissions-BAfwgBxc.mjs} +25 -6
  114. package/dist/{pipe-ELHCP3zj.mjs → pipe-BzzoEhHj.mjs} +2 -2
  115. package/dist/pipeline/index.d.mts +1 -1
  116. package/dist/pipeline/index.mjs +2 -2
  117. package/dist/{pipeline-CPA-jx0M.mjs → pipeline-q69DZzgG.mjs} +6 -6
  118. package/dist/plugins/index.d.mts +6 -6
  119. package/dist/plugins/index.mjs +16 -16
  120. package/dist/plugins/response-cache.mjs +2 -2
  121. package/dist/plugins/tracing-entry.mjs +2 -2
  122. package/dist/{pluralize-C2ZkKbsn.mjs → pluralize-B7xf8qZF.mjs} +1 -1
  123. package/dist/{preloadResources-CDs87fTw.mjs → preloadResources-BkyqeTDz.mjs} +1 -1
  124. package/dist/{preloadResources-T1hmlTEZ.d.mts → preloadResources-g07ldtzh.d.mts} +30 -7
  125. package/dist/presets/filesUpload.d.mts +2 -2
  126. package/dist/presets/filesUpload.mjs +5 -5
  127. package/dist/presets/index.d.mts +2 -2
  128. package/dist/presets/index.mjs +2 -2
  129. package/dist/presets/multiTenant.d.mts +1 -1
  130. package/dist/presets/multiTenant.mjs +3 -3
  131. package/dist/presets/search.d.mts +2 -2
  132. package/dist/presets/search.mjs +2 -2
  133. package/dist/{presets-Dntd_pUO.mjs → presets-D-h0uWtP.mjs} +4 -4
  134. package/dist/projection-BKM0Ui-s.mjs +42 -0
  135. package/dist/{queryCachePlugin-Dhps-Ldt.mjs → queryCachePlugin-DhsZ0ZIc.mjs} +49 -7
  136. package/dist/{rateLimit-C7mmAGs3.mjs → rateLimit-D4-ASwsI.mjs} +2 -2
  137. package/dist/{rateLimitKey-CccEFBfk.mjs → rateLimitKey-XngqVNEV.mjs} +1 -1
  138. package/dist/{realtime-DVvVZZFz.mjs → realtime-CTkv3O59.mjs} +9 -9
  139. package/dist/{redis-stream-qN6A-RFv.d.mts → redis-stream-mwLWlfHG.d.mts} +1 -1
  140. package/dist/{registerRoute-B_QrjYZS.mjs → registerRoute-DNnd3vkz.mjs} +4 -4
  141. package/dist/registry/index.d.mts +3 -3
  142. package/dist/registry/index.mjs +3 -3
  143. package/dist/{registry-DqUmzA9W.mjs → registry-CLxCR5LF.mjs} +2 -2
  144. package/dist/{renewingLease-BpP9wwj7.mjs → renewingLease-BnWtbA2U.mjs} +1 -1
  145. package/dist/{replyHelpers-DS-P0b32.mjs → replyHelpers-BSBVCwL3.mjs} +2 -2
  146. package/dist/{repositoryFilter-DotlxG3L.mjs → repositoryFilter-DKny7PDn.mjs} +1 -1
  147. package/dist/{requestContext-B6vQF-sy.mjs → requestContext-uYE35JxI.mjs} +1 -1
  148. package/dist/{rolldown-runtime-BhflRrBs.mjs → rolldown-runtime-DNg-KNCk.mjs} +1 -1
  149. package/dist/runtimeCapabilities-Dm88DaTW.mjs +49 -0
  150. package/dist/{schedules-C2Q3uv2d.mjs → schedules-CAcZgia0.mjs} +3 -3
  151. package/dist/{schemaConverter-Crjvczi_.mjs → schemaConverter-XH1RZRyu.mjs} +1 -1
  152. package/dist/{schemaIR-CnyKK_1j.mjs → schemaIR-CDvclAew.mjs} +2 -2
  153. package/dist/schemas/index.mjs +1 -1
  154. package/dist/scim/index.mjs +2 -2
  155. package/dist/scope/index.mjs +5 -5
  156. package/dist/{scope-afz_Ieek.mjs → scope-BaWcqBvr.mjs} +4 -4
  157. package/dist/{simpleEqualityMatcher-C4KiMWUa.mjs → simpleEqualityMatcher-Se8VrsaP.mjs} +1 -1
  158. package/dist/{sse-B9P4Y-UI.mjs → sse-Cf0jUHsh.mjs} +5 -5
  159. package/dist/{sseStream-B3zb_d1h.mjs → sseStream-Bvpi-UXQ.mjs} +2 -2
  160. package/dist/{store-helpers-DmEMHlPQ.mjs → store-helpers-B88p99Z2.mjs} +1 -1
  161. package/dist/{streaming-DPZdQmDz.mjs → streaming-DsT1B5zJ.mjs} +1 -1
  162. package/dist/{subscribe-helpers-sp5xTDVA.mjs → subscribe-helpers-CLIOHsOK.mjs} +2 -2
  163. package/dist/sync/changelog.mjs +1 -1
  164. package/dist/testing/cleanupStoreContract.d.mts +1 -1
  165. package/dist/testing/cleanupStoreContract.mjs +3 -1
  166. package/dist/testing/index.d.mts +24 -12
  167. package/dist/testing/index.mjs +24 -12
  168. package/dist/testing/outboxStoreContract.mjs +1 -1
  169. package/dist/testing/storageContract.mjs +1 -1
  170. package/dist/{transactionContext-BvLKvOIL.mjs → transactionContext-BYtWnwAS.mjs} +1 -1
  171. package/dist/{typeGuards-B_5-hKjJ.mjs → typeGuards-jjKJDN13.mjs} +1 -1
  172. package/dist/types/index.d.mts +3 -3
  173. package/dist/types/index.mjs +1 -1
  174. package/dist/types/storage.mjs +1 -1
  175. package/dist/{types-CT5vu6M0.d.mts → types-BcTumAz_.d.mts} +41 -7
  176. package/dist/{types-CwcUQ610.d.mts → types-BfC83KsP.d.mts} +15 -1
  177. package/dist/{types-DXYX7dIR.mjs → types-CoqIRrkY.mjs} +4 -4
  178. package/dist/{types-C4QE8qnm.d.mts → types-DVLddu2q.d.mts} +144 -1
  179. package/dist/usage/index.d.mts +1 -1
  180. package/dist/usage/index.mjs +0 -0
  181. package/dist/{userHelpers-BNsOXzOd.mjs → userHelpers-CWF5pbZD.mjs} +1 -1
  182. package/dist/utils/index.d.mts +57 -5
  183. package/dist/utils/index.mjs +12 -11
  184. package/dist/{utils-BtOha_Sp.mjs → utils-h1V_sT-t.mjs} +38 -7
  185. package/dist/{validate-PMCPR9El.mjs → validate-CdQlz52F.mjs} +2 -2
  186. package/dist/{versioning-D_rhHfZm.d.mts → versioning-DEs-_hFi.d.mts} +1 -1
  187. package/dist/{versioning-CKv2jz_U.mjs → versioning-rZbyLVD-.mjs} +3 -3
  188. package/package.json +17 -16
  189. package/skills/arc/SKILL.md +96 -4
  190. package/skills/arc/references/api-reference.md +19 -0
  191. package/skills/arc/references/production.md +2 -2
  192. package/dist/projection-CjHhM5W1.mjs +0 -18
@@ -1,12 +1,9 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
- import { t as MemoryEventTransport } from "../EventTransport-BV10D0oG.mjs";
3
- import { a as createEventRegistry, i as wrapWithSchema, n as subscribeWithSchema, o as defineEvent, r as wrapWithBoundary, t as subscribeWithBoundary } from "../subscribe-helpers-sp5xTDVA.mjs";
4
- import { i as withRetry, r as createDeadLetterPublisher, t as eventPlugin } from "../eventPlugin-Dk_TyXeE.mjs";
5
- import { n as createSafeGetOne, t as createIsDuplicateKeyError } from "../store-helpers-DmEMHlPQ.mjs";
6
- import { and, anyOf, eq, lte, ne, or } from "@classytic/repo-core/filter";
1
+ /*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
2
+ import { n as MemoryEventTransport } from "../EventTransport-tq4CEB-q.mjs";
3
+ import { a as createEventRegistry, i as wrapWithSchema, n as subscribeWithSchema, o as defineEvent, r as wrapWithBoundary, t as subscribeWithBoundary } from "../subscribe-helpers-CLIOHsOK.mjs";
4
+ import { i as withRetry, r as createDeadLetterPublisher, t as eventPlugin } from "../eventPlugin-Dar8qRaj.mjs";
5
+ import { i as exponentialBackoff, n as EventOutbox, r as repositoryAsOutboxStore, t as createOutboxModule } from "../outbox-module-CXB79bvt.mjs";
7
6
  import { MemoryOutboxStore } from "@classytic/primitives/memory-outbox";
8
- import { InvalidOutboxEventError, OutboxOwnershipError } from "@classytic/primitives/outbox";
9
- import { update } from "@classytic/repo-core/update";
10
7
  //#region src/events/eventTypes.ts
11
8
  /**
12
9
  * Event Type Constants and Helpers
@@ -58,776 +55,4 @@ const CACHE_EVENTS = Object.freeze({
58
55
  TAG_VERSION_BUMPED: "arc.cache.tag.bumped"
59
56
  });
60
57
  //#endregion
61
- //#region src/events/outbox/backoff.ts
62
- /**
63
- * Compute a `retryAt` `Date` using exponential backoff with jitter.
64
- *
65
- * This is a convenience helper for store authors implementing
66
- * {@link OutboxStore.fail}: call it to compute the retry visibility window
67
- * based on the event's current attempt count.
68
- *
69
- * Formula: `delay = min(maxMs, baseMs * 2^(attempt - 1)) * (1 + random * jitter)`
70
- *
71
- * @example Basic usage inside a store's `fail()` method
72
- * ```typescript
73
- * async fail(eventId, error, options) {
74
- * const entry = await this.findById(eventId);
75
- * entry.attempts++;
76
- * if (entry.attempts >= MAX_ATTEMPTS) {
77
- * return this.deadLetter(eventId, error);
78
- * }
79
- * const retryAt = exponentialBackoff({ attempt: entry.attempts });
80
- * entry.visibleAt = retryAt;
81
- * await this.update(entry);
82
- * }
83
- * ```
84
- *
85
- * @example Tuning for a faster transport
86
- * ```typescript
87
- * exponentialBackoff({ attempt: 3, baseMs: 250, maxMs: 10_000, jitter: 0.3 });
88
- * // attempt=1 → ~250ms ±30%
89
- * // attempt=2 → ~500ms ±30%
90
- * // attempt=3 → ~1000ms ±30%
91
- * // attempt=10 → capped at 10_000ms
92
- * ```
93
- */
94
- function exponentialBackoff(options) {
95
- const attempt = Math.max(1, Math.floor(options.attempt));
96
- const baseMs = options.baseMs ?? 1e3;
97
- const maxMs = options.maxMs ?? 6e4;
98
- const jitter = Math.max(0, Math.min(1, options.jitter ?? .2));
99
- const now = options.now ?? Date.now();
100
- const exp = baseMs * 2 ** (attempt - 1);
101
- const capped = Math.min(maxMs, exp);
102
- const jittered = jitter > 0 ? capped * (1 + Math.random() * jitter) : capped;
103
- return new Date(now + jittered);
104
- }
105
- //#endregion
106
- //#region src/events/repository-outbox-adapter.ts
107
- /**
108
- * RepositoryLike → OutboxStore adapter.
109
- *
110
- * Maps the `OutboxStore` vocabulary (save / claimPending / acknowledge /
111
- * fail / getDeadLettered / purge) onto arc's own `RepositoryLike` primitives
112
- * (create / getOne / findAll / deleteMany / findOneAndUpdate). `EventOutbox`
113
- * wraps a passed repository with this helper when you use the
114
- * `{ repository }` option; the function is also re-exported from
115
- * `@classytic/arc/events` so consumers can build and decorate the store
116
- * manually (metrics, tracing, multi-transport fan-out).
117
- *
118
- * Portability: filters compose via `@classytic/repo-core/filter` and
119
- * updates via `@classytic/repo-core/update`. The primary-key column name
120
- * is read from `repository.idField` — mongokit defaults to `_id`,
121
- * sqlitekit / pgkit / prismakit to the schema's declared PK. The adapter
122
- * therefore runs on any kit that implements `StandardRepo.findOneAndUpdate`
123
- * + `getOne` + `getAll` + `deleteMany` + `create`.
124
- *
125
- * `fail()` uses a lease-gated read-then-write pair to preserve
126
- * `firstFailedAt` across retries without relying on Mongo's aggregation-
127
- * pipeline `$ifNull`. Leases guarantee single-writer during the failure
128
- * window (`claimPending` filters out non-owned rows), so the two calls are
129
- * safe under concurrent relayers.
130
- *
131
- * Why we DON'T use `StandardRepo.claim()` (repo-core 0.4+) anywhere in this
132
- * adapter — design note for the next reader who wants to "modernize" us:
133
- *
134
- * • `claimPending` (FIFO claim-next loop): `claim(id, ...)` requires the
135
- * candidate id upfront, but the relayer doesn't have it — that's the
136
- * whole point of `findOneAndUpdate({ filter, sort: { createdAt: 1 } })`,
137
- * which finds + claims in a SINGLE round-trip per claimed event.
138
- * Replacing with `getOne(filter, sort) → claim(id, ...)` doubles the
139
- * round-trips, opens a TOCTOU window where the candidate state can
140
- * change between read and CAS (we'd need a retry loop on null-on-race),
141
- * and gains nothing — the existing call is already atomic and
142
- * race-free. Throughput regression with no upside; do not adopt.
143
- *
144
- * • `acknowledge` (pending → delivered): id is in scope, but the current
145
- * filter uses `ne('status', 'delivered')` to allow ack from any
146
- * non-delivered state (defensive; lease invariant means it's pending
147
- * in practice). `claim` requires an exact `from` value, so adopting
148
- * would tighten the source-state predicate from "not delivered" to
149
- * "exactly pending". That's a behavior change — even if the new
150
- * behavior is arguably tighter/safer, the constraint here is "no
151
- * semantic regression". Keep `findOneAndUpdate`.
152
- *
153
- * • `fail` (any → pending|dead_letter): the current `baseFilter` is
154
- * id-only (with optional `leaseOwner` guard) — there's NO source-state
155
- * predicate at all. Adopting `claim` would ADD `from: 'pending'`,
156
- * blocking `fail()` calls against rows in any other state. Same "no
157
- * semantic regression" concern; keep `findOneAndUpdate`. The
158
- * read-then-write pair also exists here to preserve `firstFailedAt`
159
- * portably (see paragraph above) — `claim` doesn't simplify that.
160
- *
161
- * No kit currently ships a filter-based `claimNext`/`claimPending` (Path C
162
- * in the v2.11.x evaluation) — checked mongokit 3.13.0 + sqlitekit 0.3.0.
163
- * If/when one appears, the FIFO loop becomes a 1-call adoption candidate.
164
- */
165
- const DEFAULT_LEASE_MS$1 = 3e4;
166
- const DEFAULT_CLAIM_LIMIT = 100;
167
- const DEFAULT_PURGE_BATCH = 500;
168
- /** Column this adapter reads/writes for the visibility timestamp. */
169
- const DEFAULT_VISIBLE_AT_FIELD = "visibleAt";
170
- function repositoryAsOutboxStore(repository, options) {
171
- const visibleAtField = options?.visibleAtField ?? DEFAULT_VISIBLE_AT_FIELD;
172
- const missing = [];
173
- if (typeof repository.create !== "function") missing.push("create");
174
- if (typeof repository.getOne !== "function") missing.push("getOne");
175
- if (typeof repository.getAll !== "function") missing.push("getAll");
176
- if (typeof repository.deleteMany !== "function") missing.push("deleteMany");
177
- if (typeof repository.findOneAndUpdate !== "function") missing.push("findOneAndUpdate");
178
- if (missing.length > 0) throw new Error(`EventOutbox: repository is missing required methods: ${missing.join(", ")}. mongokit ≥3.10.2 satisfies all five; other kits must implement them to back the outbox.`);
179
- const r = repository;
180
- const idField = repository.idField ?? "_id";
181
- /**
182
- * Unwrap mongokit's pagination envelope ({ data, total, ... }) — some
183
- * kits may return a bare array when pagination is disabled. Handle both.
184
- */
185
- const unwrapDocs = (result) => {
186
- if (Array.isArray(result)) return result;
187
- return result?.data ?? [];
188
- };
189
- const isDuplicateKeyError = createIsDuplicateKeyError(repository);
190
- const safeGetOne = createSafeGetOne(repository);
191
- const isWellFormed = (event) => !!event && typeof event.type === "string" && !!event.meta?.id;
192
- /**
193
- * Filter matching every row that's eligible to be claimed by a relayer:
194
- * status=pending, visible now, and either unleased or under an expired
195
- * lease. Used by `getPending` and `claimPending` — defined once so the
196
- * two code paths stay in lockstep.
197
- */
198
- const claimableFilter = (now) => and(eq("status", "pending"), lte(visibleAtField, now), or(eq("leaseOwner", null), lte("leaseExpiresAt", now)));
199
- return {
200
- /**
201
- * Transactional: `save` forwards `options.session` to the backing
202
- * repository's `create`, so the event row joins the caller's transaction on
203
- * the SAME connection and commits or rolls back with the domain write.
204
- *
205
- * Declared explicitly because `OutboxWriteOptions.session` is best-effort by
206
- * contract — a caller cannot infer atomicity from the presence of an outbox,
207
- * only from this flag. See `OutboxStore.transactionalSave` in
208
- * `@classytic/primitives/outbox`.
209
- */
210
- transactionalSave: true,
211
- async save(event, options) {
212
- if (!event?.type || typeof event.type !== "string") throw new InvalidOutboxEventError("event.type is required");
213
- if (!event.meta?.id || typeof event.meta.id !== "string") throw new InvalidOutboxEventError("event.meta.id is required");
214
- const now = /* @__PURE__ */ new Date();
215
- const doc = {
216
- [idField]: event.meta.id,
217
- event,
218
- type: event.type,
219
- status: "pending",
220
- attempts: 0,
221
- [visibleAtField]: options?.visibleAt ?? now,
222
- leaseOwner: null,
223
- leaseExpiresAt: null,
224
- deliveredAt: null,
225
- firstFailedAt: null,
226
- lastFailedAt: null,
227
- lastError: null,
228
- dedupeKey: options?.dedupeKey ?? null,
229
- partitionKey: options?.partitionKey ?? null,
230
- headers: options?.headers ? { ...options.headers } : null,
231
- createdAt: now
232
- };
233
- try {
234
- await r.create(doc, options?.session ? { session: options.session } : void 0);
235
- } catch (err) {
236
- if (isDuplicateKeyError(err)) return;
237
- throw err;
238
- }
239
- },
240
- async getPending(limit) {
241
- const now = /* @__PURE__ */ new Date();
242
- const result = await r.getAll({
243
- filters: claimableFilter(now),
244
- sort: { createdAt: 1 },
245
- page: 1,
246
- limit
247
- });
248
- return unwrapDocs(result).map((d) => d.event).filter(isWellFormed);
249
- },
250
- async claimPending(options) {
251
- const limit = options?.limit ?? DEFAULT_CLAIM_LIMIT;
252
- const leaseMs = options?.leaseMs ?? DEFAULT_LEASE_MS$1;
253
- const consumerId = options?.consumerId ?? "anonymous";
254
- const typeFilter = options?.types?.length ? anyOf("type", options.types) : null;
255
- const now = /* @__PURE__ */ new Date();
256
- const candidateFilter = typeFilter ? and(claimableFilter(now), typeFilter) : claimableFilter(now);
257
- const candidates = unwrapDocs(await r.getAll({
258
- filters: candidateFilter,
259
- sort: { createdAt: 1 },
260
- page: 1,
261
- limit
262
- }));
263
- if (candidates.length === 0) return [];
264
- return (await Promise.all(candidates.map((candidate) => {
265
- const claimNow = /* @__PURE__ */ new Date();
266
- const leaseExpiresAt = new Date(claimNow.getTime() + leaseMs);
267
- return r.findOneAndUpdate(and(eq(idField, candidate.event.meta.id), claimableFilter(claimNow)), update({
268
- set: {
269
- leaseOwner: consumerId,
270
- leaseExpiresAt
271
- },
272
- inc: { attempts: 1 }
273
- }), { returnDocument: "after" });
274
- }))).filter((doc) => doc !== null).map((doc) => doc.event).filter(isWellFormed);
275
- },
276
- async acknowledge(eventId, options) {
277
- const now = /* @__PURE__ */ new Date();
278
- const baseFilter = and(eq(idField, eventId), ne("status", "delivered"));
279
- const filter = options?.consumerId ? and(baseFilter, eq("leaseOwner", options.consumerId)) : baseFilter;
280
- if (await r.findOneAndUpdate(filter, update({ set: {
281
- status: "delivered",
282
- deliveredAt: now,
283
- leaseOwner: null,
284
- leaseExpiresAt: null
285
- } }), { returnDocument: "after" })) return;
286
- const current = await safeGetOne(eq(idField, eventId));
287
- if (!current) return;
288
- if (current.status === "delivered") return;
289
- if (options?.consumerId && current.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, current.leaseOwner);
290
- },
291
- async fail(eventId, error, options) {
292
- const now = /* @__PURE__ */ new Date();
293
- const targetStatus = options?.deadLetter ? "dead_letter" : "pending";
294
- const visibleAt = options?.retryAt ?? now;
295
- const baseFilter = eq(idField, eventId);
296
- const filter = options?.consumerId ? and(baseFilter, eq("leaseOwner", options.consumerId)) : baseFilter;
297
- const current = await safeGetOne(baseFilter);
298
- if (!current) return;
299
- if (options?.consumerId && current.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, current.leaseOwner);
300
- const errorInfo = error.code ? {
301
- message: error.message,
302
- code: error.code
303
- } : { message: error.message };
304
- const firstFailedAt = current.firstFailedAt ?? now;
305
- if (await r.findOneAndUpdate(filter, update({ set: {
306
- status: targetStatus,
307
- [visibleAtField]: visibleAt,
308
- leaseOwner: null,
309
- leaseExpiresAt: null,
310
- lastFailedAt: now,
311
- lastError: errorInfo,
312
- firstFailedAt
313
- } }), { returnDocument: "after" })) return;
314
- if (options?.consumerId) {
315
- const after = await safeGetOne(baseFilter);
316
- if (after && after.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, after.leaseOwner);
317
- }
318
- },
319
- async getDeadLettered(limit) {
320
- const result = await r.getAll({
321
- filters: eq("status", "dead_letter"),
322
- sort: { [idField]: 1 },
323
- page: 1,
324
- limit
325
- });
326
- return unwrapDocs(result).filter((d) => isWellFormed(d.event)).map((d) => ({
327
- event: d.event,
328
- error: {
329
- message: d.lastError?.message ?? "unknown",
330
- ...d.lastError?.code !== void 0 ? { code: d.lastError.code } : {}
331
- },
332
- attempts: d.attempts,
333
- firstFailedAt: d.firstFailedAt ?? d.lastFailedAt ?? d.createdAt,
334
- lastFailedAt: d.lastFailedAt ?? d.firstFailedAt ?? d.createdAt
335
- }));
336
- },
337
- async purge(olderThanMs) {
338
- const cutoff = new Date(Date.now() - olderThanMs);
339
- let totalDeleted = 0;
340
- for (;;) {
341
- const result = await r.getAll({
342
- filters: and(eq("status", "delivered"), lte("deliveredAt", cutoff)),
343
- sort: { deliveredAt: 1 },
344
- page: 1,
345
- limit: DEFAULT_PURGE_BATCH,
346
- select: idField
347
- });
348
- const batch = unwrapDocs(result);
349
- if (batch.length === 0) break;
350
- const ids = batch.map((d) => d[idField]);
351
- const res = await r.deleteMany(anyOf(idField, ids));
352
- totalDeleted += res.deletedCount ?? 0;
353
- if (batch.length < DEFAULT_PURGE_BATCH) break;
354
- }
355
- return totalDeleted;
356
- },
357
- /**
358
- * Re-drive one dead-lettered event — the other half of `fail({ deadLetter: true })`.
359
- *
360
- * Filtered on `status: 'dead_letter'` as well as the id, so this is a no-op on a row
361
- * that is merely retrying. Requeuing a backing-off row would zero its attempt count
362
- * and defeat the failure policy, and the id alone cannot distinguish the two.
363
- *
364
- * `attempts` resets because the operator's whole claim is that the CAUSE is fixed:
365
- * carrying the old count forward would dead-letter the retry again after one more
366
- * blip, which reads as "the fix did not work".
367
- */
368
- async requeue(eventId) {
369
- return await r.findOneAndUpdate(and(eq(idField, eventId), eq("status", "dead_letter")), update({ set: {
370
- status: "pending",
371
- attempts: 0,
372
- leaseOwner: null,
373
- leaseExpiresAt: null,
374
- [visibleAtField]: /* @__PURE__ */ new Date(),
375
- lastError: null
376
- } }), { returnDocument: "after" }) != null;
377
- },
378
- /** Rows in `status` — the count behind an operator dashboard or a dead-letter alert. */
379
- async countByStatus(status) {
380
- const filters = eq("status", status);
381
- const counter = repository.count;
382
- if (typeof counter === "function") return counter.call(repository, filters);
383
- /**
384
- * Fallback for a kit without `count`. Two shapes, and the difference decides the
385
- * query — getting it wrong here produces a number that LIES, which is worse than
386
- * no number at all: the operator reads a healthy dashboard and stops looking.
387
- *
388
- * A paginated kit reports the match count in its envelope, so one row is enough
389
- * to read it — deliberately not a hydrate-and-length, since a `delivered` count
390
- * would drag the whole retention window into memory for one integer.
391
- *
392
- * A kit that returns a BARE ARRAY has no envelope to read, so the rows ARE the
393
- * count and every match has to come back. `unwrapDocs` documents that shape as
394
- * real, so it is not hypothetical. `select: idField` keeps it to one column.
395
- */
396
- const probe = await r.getAll({
397
- filters,
398
- page: 1,
399
- limit: 1,
400
- select: idField
401
- });
402
- if (Array.isArray(probe)) {
403
- const all = await r.getAll({
404
- filters,
405
- select: idField
406
- });
407
- return unwrapDocs(all).length;
408
- }
409
- const total = probe?.total ?? probe?.meta?.total;
410
- if (typeof total === "number") return total;
411
- /**
412
- * An envelope in a shape neither branch recognises. Throwing beats returning 0:
413
- * a zero is indistinguishable from a healthy queue, so it would silence exactly
414
- * the alert this method exists to raise.
415
- */
416
- throw new Error("repositoryAsOutboxStore.countByStatus: the repository's `getAll` returned an envelope with no `total` (and is not a bare array), so the count cannot be read. Implement `count(filter)` on the repository — every kit that has it answers this in one query.");
417
- },
418
- /**
419
- * Age of the oldest PENDING row, or `null` when the queue is empty.
420
- *
421
- * The signal a pending count cannot give: a count of 40 looks the same whether the
422
- * relay is draining briskly or has been wedged for an hour behind one poison row.
423
- *
424
- * Measured from `createdAt`, not the last attempt — the question is how long the
425
- * event has gone undelivered, and restarting the clock on every retry would make a
426
- * permanently-failing row look permanently fresh, which is the exact row an alert
427
- * exists to surface.
428
- */
429
- async oldestPendingAgeMs() {
430
- const result = await r.getAll({
431
- filters: eq("status", "pending"),
432
- sort: { createdAt: 1 },
433
- page: 1,
434
- limit: 1
435
- });
436
- const [oldest] = unwrapDocs(result);
437
- if (oldest?.createdAt == null) return null;
438
- return Date.now() - new Date(oldest.createdAt).getTime();
439
- }
440
- };
441
- }
442
- //#endregion
443
- //#region src/events/outbox/relay.ts
444
- /** Default outbox retention — delivered events older than this are eligible for purge */
445
- const DEFAULT_OUTBOX_RETENTION_MS = 6048e5;
446
- const DEFAULT_LEASE_MS = 3e4;
447
- /**
448
- * Hard cap on the in-process attempt-counter map. Entries are cleared on
449
- * local ack and on dead-letter, but an event that fails HERE and is later
450
- * acked by a DIFFERENT relay worker (lease expiry → reclaim) never drains
451
- * locally — without a cap the map grows for the life of the process in
452
- * multi-worker deployments. Eviction is oldest-write-first; evicting an
453
- * entry only resets its non-authoritative in-process count (the documented
454
- * contract already says counts reset on restart — durable counts live in
455
- * the store).
456
- */
457
- const MAX_TRACKED_ATTEMPTS = 1e4;
458
- var EventOutbox = class {
459
- _store;
460
- _transport;
461
- _batchSize;
462
- _consumerId;
463
- _leaseMs;
464
- _onError;
465
- _usePublishMany;
466
- _sessionProvider;
467
- _traceContextProvider;
468
- _failurePolicy;
469
- /**
470
- * In-process attempt counter per event id. Accurate within this relay
471
- * process; resets on restart. Populated as failures occur and cleared on
472
- * successful ack or dead-letter transition; additionally hard-capped at
473
- * {@link MAX_TRACKED_ATTEMPTS} (oldest-write evicted first) so events
474
- * resolved by another relay worker can't accumulate forever. For durable
475
- * authoritative counts, apps can query the store directly inside
476
- * {@link OutboxFailurePolicy}.
477
- */
478
- _attempts = /* @__PURE__ */ new Map();
479
- constructor(opts) {
480
- if (opts.repository) this._store = repositoryAsOutboxStore(opts.repository);
481
- else if (opts.store) this._store = opts.store;
482
- else throw new Error("EventOutbox: either `repository` or `store` must be provided. Pass a RepositoryLike (mongokit / prismakit) for the common case, or a concrete OutboxStore (memory / custom) for non-repository backends.");
483
- this._transport = opts.transport;
484
- this._batchSize = opts.batchSize ?? 100;
485
- this._consumerId = opts.consumerId ?? `relay-${Math.random().toString(36).slice(2, 10)}`;
486
- this._leaseMs = opts.leaseMs ?? DEFAULT_LEASE_MS;
487
- this._onError = opts.onError;
488
- this._usePublishMany = opts.usePublishMany ?? true;
489
- this._failurePolicy = opts.failurePolicy;
490
- this._sessionProvider = opts.sessionProvider;
491
- this._traceContextProvider = opts.traceContextProvider;
492
- }
493
- /** Unique consumer ID used for lease ownership when the store supports claims */
494
- get consumerId() {
495
- return this._consumerId;
496
- }
497
- /**
498
- * Store event in outbox.
499
- *
500
- * Validates that `event.type` and `event.meta.id` are present — throws
501
- * {@link InvalidOutboxEventError} otherwise, so corrupt rows can never
502
- * be persisted via this API.
503
- *
504
- * Pass `options.session` to participate in a host-managed DB transaction
505
- * (store must support session-aware writes). Other options (`visibleAt`,
506
- * `dedupeKey`, `partitionKey`, `headers`) are forwarded to stores that
507
- * implement them and ignored otherwise.
508
- */
509
- async store(event, options) {
510
- if (!event || typeof event !== "object") throw new InvalidOutboxEventError("event is not an object");
511
- if (!event.type || typeof event.type !== "string") throw new InvalidOutboxEventError("event.type is required");
512
- if (!event.meta?.id || typeof event.meta.id !== "string") throw new InvalidOutboxEventError("event.meta.id is required");
513
- let effective = options;
514
- if (effective?.session === void 0 && this._sessionProvider) {
515
- const autoSession = this._sessionProvider();
516
- if (autoSession !== void 0) effective = {
517
- ...effective ?? {},
518
- session: autoSession
519
- };
520
- }
521
- if (!effective?.headers?.traceparent && this._traceContextProvider) {
522
- const traceHeaders = this._traceContextProvider();
523
- if (traceHeaders && Object.keys(traceHeaders).length > 0) effective = {
524
- ...effective ?? {},
525
- headers: {
526
- ...traceHeaders,
527
- ...effective?.headers ?? {}
528
- }
529
- };
530
- }
531
- if (effective?.dedupeKey === void 0 && event.meta.idempotencyKey) effective = {
532
- ...effective ?? {},
533
- dedupeKey: event.meta.idempotencyKey
534
- };
535
- await this._store.save(event, effective);
536
- }
537
- _reportError(kind, error, event) {
538
- if (!this._onError) return;
539
- const err = error instanceof Error ? error : new Error(String(error));
540
- try {
541
- this._onError({
542
- kind,
543
- event,
544
- error: err
545
- });
546
- } catch {}
547
- }
548
- /**
549
- * Relay pending events to transport and return the number of successful
550
- * publish+acknowledge pairs.
551
- *
552
- * For richer observability (per-kind counts, publishMany detection, etc.)
553
- * use {@link relayBatch} which returns a {@link RelayResult}. This method
554
- * is the backward-compatible shortcut that returns just the count.
555
- *
556
- * @returns Number of successfully published AND acknowledged events
557
- */
558
- async relay() {
559
- return (await this.relayBatch()).relayed;
560
- }
561
- /**
562
- * Relay a batch of pending events to the transport and return a rich
563
- * {@link RelayResult} describing the outcome of each event.
564
- *
565
- * Behavior summary:
566
- *
567
- * - **Claim path**: uses {@link OutboxStore.claimPending} when the store
568
- * supports it (safe for multi-worker relay) or falls back to
569
- * {@link OutboxStore.getPending} (single-worker only).
570
- *
571
- * - **Publish path**: if the transport implements
572
- * {@link EventTransport.publishMany} and `usePublishMany` is not disabled,
573
- * the entire batch is sent in one call. Otherwise each event is published
574
- * individually. Either way, per-event outcomes are tracked.
575
- *
576
- * - **Failure path**: if the store implements `fail`, per-event failures
577
- * are reported via `store.fail(...)` and the batch continues. Without
578
- * `fail`, the batch stops on the first failure (legacy behavior).
579
- *
580
- * - **Malformed events**: events missing `type` or `meta.id` abort the
581
- * batch — a well-behaved store must never return them (see
582
- * {@link OutboxStore} semantics #6). The error is reported via `onError`.
583
- *
584
- * - **Ownership mismatches**: if `acknowledge`/`fail` throws
585
- * {@link OutboxOwnershipError} (our lease expired and another worker
586
- * claimed the event), the event is NOT counted as relayed. The other
587
- * worker will re-publish — at-least-once semantics preserved.
588
- *
589
- * @returns Per-kind outcome counts for the batch
590
- */
591
- async relayBatch() {
592
- const empty = {
593
- relayed: 0,
594
- attempted: 0,
595
- publishFailed: 0,
596
- ackFailed: 0,
597
- ownershipMismatches: 0,
598
- malformed: 0,
599
- failHookErrors: 0,
600
- deadLettered: 0,
601
- usedPublishMany: false
602
- };
603
- if (!this._transport) return empty;
604
- const claimStartedAt = Date.now();
605
- const pending = this._store.claimPending ? await this._store.claimPending({
606
- limit: this._batchSize,
607
- consumerId: this._consumerId,
608
- leaseMs: this._leaseMs
609
- }) : await this._store.getPending(this._batchSize);
610
- const claimMs = Date.now() - claimStartedAt;
611
- const publishStartedAt = Date.now();
612
- const valid = [];
613
- let malformed = 0;
614
- for (const event of pending) {
615
- if (!event?.type || !event.meta?.id) {
616
- this._reportError("malformed_event", new InvalidOutboxEventError("store returned event missing type or meta.id — batch aborted"), event);
617
- malformed++;
618
- break;
619
- }
620
- valid.push(event);
621
- }
622
- const counts = {
623
- relayed: 0,
624
- publishFailed: 0,
625
- ackFailed: 0,
626
- ownershipMismatches: 0,
627
- failHookErrors: 0,
628
- deadLettered: 0
629
- };
630
- const canPublishMany = this._usePublishMany && typeof this._transport.publishMany === "function";
631
- const canFail = typeof this._store.fail === "function";
632
- let publishOutcomes;
633
- if (canPublishMany && valid.length > 0) try {
634
- const result = await this._transport.publishMany?.(valid);
635
- publishOutcomes = new Map(result);
636
- } catch (batchErr) {
637
- publishOutcomes = /* @__PURE__ */ new Map();
638
- const err = batchErr instanceof Error ? batchErr : new Error(String(batchErr));
639
- for (const ev of valid) publishOutcomes.set(ev.meta.id, err);
640
- }
641
- else {
642
- publishOutcomes = /* @__PURE__ */ new Map();
643
- for (const event of valid) try {
644
- await this._transport.publish(event);
645
- publishOutcomes.set(event.meta.id, null);
646
- } catch (err) {
647
- publishOutcomes.set(event.meta.id, err instanceof Error ? err : new Error(String(err)));
648
- if (!canFail) break;
649
- }
650
- }
651
- let stopBatch = false;
652
- for (const event of valid) {
653
- if (stopBatch) break;
654
- const publishErr = publishOutcomes.get(event.meta.id);
655
- if (publishErr instanceof Error) {
656
- counts.publishFailed++;
657
- this._reportError("publish_failed", publishErr, event);
658
- if (!canFail) {
659
- stopBatch = true;
660
- continue;
661
- }
662
- const attempts = (this._attempts.get(event.meta.id) ?? 0) + 1;
663
- this._attempts.delete(event.meta.id);
664
- this._attempts.set(event.meta.id, attempts);
665
- if (this._attempts.size > MAX_TRACKED_ATTEMPTS) {
666
- const oldest = this._attempts.keys().next().value;
667
- if (oldest !== void 0) this._attempts.delete(oldest);
668
- }
669
- let failOpts = { consumerId: this._consumerId };
670
- if (this._failurePolicy) try {
671
- const decision = await this._failurePolicy({
672
- event,
673
- error: publishErr,
674
- attempts
675
- });
676
- failOpts = {
677
- ...failOpts,
678
- ...decision
679
- };
680
- } catch (policyErr) {
681
- this._reportError("fail_failed", policyErr, event);
682
- }
683
- try {
684
- await this._store.fail?.(event.meta.id, normalizeError(publishErr), failOpts);
685
- if (failOpts.deadLetter) {
686
- counts.deadLettered++;
687
- this._attempts.delete(event.meta.id);
688
- }
689
- } catch (failErr) {
690
- if (failErr instanceof OutboxOwnershipError) {
691
- counts.ownershipMismatches++;
692
- this._reportError("ownership_mismatch", failErr, event);
693
- } else {
694
- counts.failHookErrors++;
695
- this._reportError("fail_failed", failErr, event);
696
- }
697
- }
698
- continue;
699
- }
700
- try {
701
- await this._store.acknowledge(event.meta.id, { consumerId: this._consumerId });
702
- counts.relayed++;
703
- this._attempts.delete(event.meta.id);
704
- } catch (ackErr) {
705
- counts.ackFailed++;
706
- if (ackErr instanceof OutboxOwnershipError) {
707
- counts.ownershipMismatches++;
708
- this._reportError("ownership_mismatch", ackErr, event);
709
- } else this._reportError("acknowledge_failed", ackErr, event);
710
- }
711
- }
712
- return {
713
- relayed: counts.relayed,
714
- attempted: valid.length,
715
- publishFailed: counts.publishFailed,
716
- ackFailed: counts.ackFailed,
717
- ownershipMismatches: counts.ownershipMismatches,
718
- malformed,
719
- failHookErrors: counts.failHookErrors,
720
- deadLettered: counts.deadLettered,
721
- usedPublishMany: canPublishMany && valid.length > 0,
722
- claimMs,
723
- publishMs: Date.now() - publishStartedAt
724
- };
725
- }
726
- /**
727
- * Fetch current dead-lettered events as typed {@link DeadLetteredEvent}
728
- * envelopes. Delegates to {@link OutboxStore.getDeadLettered} — returns
729
- * `[]` when the store doesn't implement it.
730
- *
731
- * Pairs with {@link OutboxFailurePolicy}: apps set a policy that routes to
732
- * `deadLetter: true` after N attempts, then read back with this to alert,
733
- * replay, or archive.
734
- */
735
- async getDeadLettered(limit = 100) {
736
- if (!this._store.getDeadLettered) return [];
737
- return this._store.getDeadLettered(limit);
738
- }
739
- /**
740
- * Purge old **delivered** events from the outbox store.
741
- * Delegates to `store.purge()` if implemented; no-op otherwise.
742
- * @param olderThanMs - Remove events delivered more than this many ms ago (default: 7 days)
743
- * @returns Number of purged events, or 0 if store doesn't support purge
744
- */
745
- async purge(olderThanMs = DEFAULT_OUTBOX_RETENTION_MS) {
746
- if (!this._store.purge) return 0;
747
- return this._store.purge(olderThanMs);
748
- }
749
- };
750
- function normalizeError(err) {
751
- if (err instanceof Error) return {
752
- message: err.message,
753
- code: err.code
754
- };
755
- return { message: String(err) };
756
- }
757
- //#endregion
758
- //#region src/events/outbox-module.ts
759
- /** Default relay cadence. Frequent enough that a retry is not a user-visible delay. */
760
- const DEFAULT_RELAY_EVERY_MS = 5e3;
761
- /**
762
- * Compose the outbox as a module.
763
- *
764
- * ```ts
765
- * // host
766
- * const model = createOutboxModel(connection, { visibleAtField: 'nextVisibleAt' });
767
- * createApp({ modules: [
768
- * createOutboxModule({
769
- * store: repositoryAsOutboxStore(new Repository(model), { visibleAtField: 'nextVisibleAt' }),
770
- * onError: (err, ctx) => app.log.error({ err, ...ctx }, 'outbox relay'),
771
- * }),
772
- * …
773
- * ]});
774
- *
775
- * // consumer module
776
- * defineModule({
777
- * name: 'order',
778
- * dependsOn: ['outbox'],
779
- * bootstrap: (f) => createOrder({
780
- * outbox: getModuleExports<OutboxModuleExports>(f, 'outbox').store,
781
- * }),
782
- * });
783
- * ```
784
- *
785
- * `dependsOn` is doing real work there: it both orders the bootstraps and makes a missing outbox a
786
- * boot error naming the module that needed it.
787
- */
788
- function createOutboxModule(options) {
789
- const name = options.name ?? "outbox";
790
- const relayEveryMs = options.relayEveryMs ?? DEFAULT_RELAY_EVERY_MS;
791
- /**
792
- * Built at bootstrap and reused by the schedule.
793
- *
794
- * Not rebuilt per tick: `EventOutbox` carries the consumer id the lease is keyed on, and a fresh
795
- * instance per tick would be a fresh claimant every few seconds — which defeats the lease it
796
- * depends on for exactly-once publishing.
797
- */
798
- let exports;
799
- return {
800
- name,
801
- bootstrap: (fastify) => {
802
- const store = typeof options.store === "function" ? options.store(fastify) : options.store;
803
- const transport = options.transport === void 0 ? fastify.events : typeof options.transport === "function" ? options.transport(fastify) : options.transport;
804
- exports = {
805
- store,
806
- relay: new EventOutbox({
807
- store,
808
- ...transport !== void 0 ? { transport } : {},
809
- ...options.batchSize !== void 0 ? { batchSize: options.batchSize } : {},
810
- ...options.leaseMs !== void 0 ? { leaseMs: options.leaseMs } : {},
811
- consumerId: options.consumerId ?? "arc-outbox-relay",
812
- ...options.onError !== void 0 ? { onError: options.onError } : {},
813
- ...options.failurePolicy !== void 0 ? { failurePolicy: options.failurePolicy } : {}
814
- })
815
- };
816
- return exports;
817
- },
818
- /**
819
- * The relay runs on the module's OWN schedule arm, so the host stops owning the tick — and
820
- * a host that forgets to start one cannot exist.
821
- */
822
- scheduledJobs: () => [{
823
- name: `${name}.relay`,
824
- every: relayEveryMs,
825
- runOnStart: options.runOnStart ?? true,
826
- handler: async () => {
827
- await exports?.relay.relay();
828
- }
829
- }]
830
- };
831
- }
832
- //#endregion
833
58
  export { ARC_LIFECYCLE_EVENTS, CACHE_EVENTS, CRUD_EVENT_SUFFIXES, EventOutbox, MemoryEventTransport, MemoryOutboxStore, createDeadLetterPublisher, createEventRegistry, createOutboxModule, crudEventType, defineEvent, eventPlugin, exponentialBackoff, repositoryAsOutboxStore, subscribeWithBoundary, subscribeWithSchema, withRetry, wrapWithBoundary, wrapWithSchema };