@classytic/arc 2.31.0 → 2.32.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 (151) hide show
  1. package/dist/{BaseController-De7GqHck.mjs → BaseController-Dk34dxhI.mjs} +16 -13
  2. package/dist/{EventTransport-9mlTgqzE.mjs → EventTransport-BV10D0oG.mjs} +1 -1
  3. package/dist/{HookSystem-BcmPyq5I.mjs → HookSystem-vimw5D7Z.mjs} +2 -2
  4. package/dist/{ResourceRegistry-qq0mn2s9.mjs → ResourceRegistry-BVr7PgA0.mjs} +17 -15
  5. package/dist/{actionPermissions-BE5NwaQk.mjs → actionPermissions-Dti7ebyv.mjs} +1 -1
  6. package/dist/audit/index.mjs +2 -2
  7. package/dist/auth/audit.mjs +1 -1
  8. package/dist/auth/index.mjs +11 -10
  9. package/dist/auth/redis-session.mjs +1 -1
  10. package/dist/{authorizationDecision-ChvUQGG1.mjs → authorizationDecision-__T3EJDA.mjs} +2 -2
  11. package/dist/{buildHandler-CDrCJzmB.mjs → buildHandler-Dn7rzUJo.mjs} +6 -5
  12. package/dist/cache/index.mjs +4 -4
  13. package/dist/{caching-BFHA6Scf.mjs → caching-DFJqHyFs.mjs} +2 -2
  14. package/dist/cleanup/index.d.mts +36 -14
  15. package/dist/cleanup/index.mjs +45 -29
  16. package/dist/cli/commands/describe.mjs +4 -6
  17. package/dist/cli/commands/docs.mjs +3 -3
  18. package/dist/cli/commands/doctor.mjs +1 -1
  19. package/dist/cli/commands/generate.mjs +2 -2
  20. package/dist/cli/commands/init.mjs +2 -2
  21. package/dist/cli/commands/introspect.mjs +2 -2
  22. package/dist/cli/index.mjs +3 -3
  23. package/dist/{constants-D3Dv4LAV.mjs → constants-J99LGTJI.mjs} +1 -1
  24. package/dist/context/index.mjs +3 -3
  25. package/dist/core/index.d.mts +1 -1
  26. package/dist/core/index.mjs +7 -7
  27. package/dist/{core-BGpIxINN.mjs → core-BwwmNSp7.mjs} +3 -3
  28. package/dist/{createActionRouter-CDXEdasl.mjs → createActionRouter-DkJZWo0m.mjs} +14 -11
  29. package/dist/{createAggregationRouter-KrQDzx-k.mjs → createAggregationRouter-B1exOVD8.mjs} +10 -8
  30. package/dist/{createApp-Df89BEni.mjs → createApp-Bv0m-Q6_.mjs} +182 -61
  31. package/dist/{defineAggregation-DbPPS8RI.mjs → defineAggregation-gX4No1gt.mjs} +1 -1
  32. package/dist/{defineResource-Bv6EtN1x.mjs → defineResource-XFPPUu2c.mjs} +41 -32
  33. package/dist/discovery/index.mjs +1 -1
  34. package/dist/docs/index.mjs +3 -3
  35. package/dist/{elevation-D2l1mh4g.mjs → elevation-DNXXxgjl.mjs} +4 -4
  36. package/dist/encryption/index.mjs +5 -4
  37. package/dist/{errorHandler-C5NeD6bT.mjs → errorHandler-B2AZUJUj.mjs} +3 -3
  38. package/dist/{errors-Cj42AntO.mjs → errors-Bh1KK5ps.mjs} +1 -1
  39. package/dist/{eventPlugin-CkffEihm.mjs → eventPlugin-Dk_TyXeE.mjs} +8 -7
  40. package/dist/events/index.d.mts +108 -41
  41. package/dist/events/index.mjs +166 -142
  42. package/dist/events/transports/redis-stream-entry.mjs +14 -10
  43. package/dist/events/transports/redis.mjs +1 -1
  44. package/dist/factory/index.d.mts +5 -5
  45. package/dist/factory/index.mjs +131 -8
  46. package/dist/fastify-augmentation-BppG5bMT.d.mts +179 -0
  47. package/dist/{fields-CB5Cuzk9.mjs → fields-DEi65pGj.mjs} +6 -11
  48. package/dist/{generate-EcA6ui4i.mjs → generate-CRSG0jTv.mjs} +2 -2
  49. package/dist/{headers-3zvE0rM3.mjs → headers-BXjQYbeC.mjs} +2 -2
  50. package/dist/{health-DjZYjM7H.mjs → health-5XnTa2uo.mjs} +1 -1
  51. package/dist/hooks/index.mjs +2 -2
  52. package/dist/idempotency/index.mjs +8 -6
  53. package/dist/idempotency/redis.mjs +1 -1
  54. package/dist/{index-Bc2ox8_U.d.mts → index-DXlHwvuE.d.mts} +1 -1
  55. package/dist/{index-CFThn1K9.d.mts → index-ZWhLTp_s.d.mts} +70 -4
  56. package/dist/{index-CEJNZFtX.d.mts → index-zq6VrjaB.d.mts} +70 -1
  57. package/dist/index.d.mts +3 -3
  58. package/dist/index.mjs +11 -11
  59. package/dist/{init-BEiNZBsJ.mjs → init-BbfP8aky.mjs} +7 -5
  60. package/dist/integrations/event-gateway.mjs +2 -2
  61. package/dist/integrations/index.mjs +1 -1
  62. package/dist/integrations/jobs/index.mjs +3 -3
  63. package/dist/integrations/mcp/index.mjs +2 -2
  64. package/dist/integrations/mcp/testing.mjs +2 -2
  65. package/dist/integrations/streamline.mjs +3 -3
  66. package/dist/integrations/webhooks.mjs +1 -1
  67. package/dist/integrations/websocket-pushref-redis.mjs +1 -1
  68. package/dist/integrations/websocket-redis.mjs +1 -1
  69. package/dist/integrations/websocket.mjs +38 -35
  70. package/dist/{interface-DYzM0q-u.mjs → interface-BP4IJCBO.mjs} +1 -1
  71. package/dist/{jsonBody-BYbLDi5D.mjs → jsonBody-DDSR2HFl.mjs} +2 -2
  72. package/dist/{keys-C0_pa1tY.mjs → keys-B-HBTGXl.mjs} +1 -1
  73. package/dist/{loadResources-yawi9tAE.mjs → loadResources-DisEAmkm.mjs} +4 -3
  74. package/dist/{loadResourcesFromEntry-BAjYZonx.mjs → loadResourcesFromEntry-D1MJp13f.mjs} +2 -2
  75. package/dist/logger/index.mjs +1 -1
  76. package/dist/{mcpPlugin-B_es0hiF.mjs → mcpPlugin-CpfVpKHE.mjs} +32 -31
  77. package/dist/{memory-DR6wpa9F.mjs → memory-DsuWnj4T.mjs} +5 -5
  78. package/dist/{metrics-CUdw_wLB.mjs → metrics-DdQL8syd.mjs} +2 -2
  79. package/dist/middleware/index.mjs +3 -3
  80. package/dist/migrations/index.mjs +3 -3
  81. package/dist/{module-0pFJA6YW.mjs → module-Br81am6m.mjs} +1 -1
  82. package/dist/{multipartBody-wOgj_fnj.mjs → multipartBody-BnkBLN7v.mjs} +3 -3
  83. package/dist/{openapi-B2L4js1v.mjs → openapi-DIRzvxC0.mjs} +6 -6
  84. package/dist/outbox-admin/index.d.mts +85 -0
  85. package/dist/outbox-admin/index.mjs +115 -0
  86. package/dist/permissions/index.d.mts +2 -2
  87. package/dist/permissions/index.mjs +6 -6
  88. package/dist/{permissions-L--QDAir.mjs → permissions-Dl_m0NCe.mjs} +101 -8
  89. package/dist/{pipe-DRDNsI2T.mjs → pipe-ELHCP3zj.mjs} +3 -5
  90. package/dist/pipeline/index.mjs +2 -2
  91. package/dist/{pipeline-UPQFatit.mjs → pipeline-CPA-jx0M.mjs} +17 -15
  92. package/dist/plugins/index.d.mts +2 -2
  93. package/dist/plugins/index.mjs +16 -16
  94. package/dist/plugins/response-cache.mjs +2 -2
  95. package/dist/plugins/tracing-entry.mjs +2 -2
  96. package/dist/{pluralize-O0FZgESl.mjs → pluralize-C2ZkKbsn.mjs} +1 -1
  97. package/dist/{preloadResources-Dz6BjzM6.mjs → preloadResources-CDs87fTw.mjs} +1 -1
  98. package/dist/{preloadResources-CyoJMSiO.d.mts → preloadResources-T1hmlTEZ.d.mts} +2 -2
  99. package/dist/presets/filesUpload.mjs +6 -6
  100. package/dist/presets/index.mjs +2 -2
  101. package/dist/presets/multiTenant.mjs +3 -3
  102. package/dist/presets/search.mjs +2 -2
  103. package/dist/{presets-C4-g4_4P.mjs → presets-Dntd_pUO.mjs} +6 -5
  104. package/dist/{projection-BHw6xlOQ.mjs → projection-CjHhM5W1.mjs} +2 -2
  105. package/dist/{queryCachePlugin-BZr8v46E.mjs → queryCachePlugin-Dhps-Ldt.mjs} +6 -6
  106. package/dist/{rateLimit-CMPY5Qms.mjs → rateLimit-C7mmAGs3.mjs} +2 -2
  107. package/dist/{rateLimitKey-Dn5JNmkj.mjs → rateLimitKey-CccEFBfk.mjs} +1 -1
  108. package/dist/{realtime-C5NoxWFY.mjs → realtime-DVvVZZFz.mjs} +10 -10
  109. package/dist/{registerRoute-CBN84fc7.mjs → registerRoute-B_QrjYZS.mjs} +10 -8
  110. package/dist/registry/index.d.mts +1 -1
  111. package/dist/registry/index.mjs +3 -3
  112. package/dist/{registry-4213U4zb.mjs → registry-DqUmzA9W.mjs} +2 -2
  113. package/dist/{renewingLease-Dll8mGgc.mjs → renewingLease-BpP9wwj7.mjs} +1 -1
  114. package/dist/{replyHelpers-DIiZKGUt.mjs → replyHelpers-DS-P0b32.mjs} +2 -2
  115. package/dist/{repositoryFilter-f9eAfq_l.mjs → repositoryFilter-DotlxG3L.mjs} +1 -1
  116. package/dist/{requestContext-2TxXOJ7T.mjs → requestContext-B6vQF-sy.mjs} +1 -1
  117. package/dist/{rolldown-runtime-CUsjNX9R.mjs → rolldown-runtime-BhflRrBs.mjs} +1 -1
  118. package/dist/{schedules-DdtlICTf.mjs → schedules-C2Q3uv2d.mjs} +3 -3
  119. package/dist/{schemaConverter-D--wdJM-.mjs → schemaConverter-Crjvczi_.mjs} +1 -1
  120. package/dist/{schemaIR-Bm2enbnh.mjs → schemaIR-CnyKK_1j.mjs} +2 -2
  121. package/dist/schemas/index.mjs +1 -1
  122. package/dist/scim/index.mjs +2 -2
  123. package/dist/scope/index.mjs +5 -5
  124. package/dist/{scope-DHx5Fous.mjs → scope-afz_Ieek.mjs} +4 -4
  125. package/dist/{simpleEqualityMatcher-DjQJ7FKA.mjs → simpleEqualityMatcher-C4KiMWUa.mjs} +1 -1
  126. package/dist/{sse-CUTo9y0X.mjs → sse-B9P4Y-UI.mjs} +7 -6
  127. package/dist/{sseStream-B_5XcVVd.mjs → sseStream-B3zb_d1h.mjs} +2 -2
  128. package/dist/{store-helpers-D1MP_gRG.mjs → store-helpers-DmEMHlPQ.mjs} +1 -1
  129. package/dist/{streaming-PSZwi8JV.mjs → streaming-DPZdQmDz.mjs} +1 -1
  130. package/dist/{subscribe-helpers-C0Tb2aUp.mjs → subscribe-helpers-sp5xTDVA.mjs} +2 -2
  131. package/dist/sync/changelog.mjs +1 -1
  132. package/dist/testing/cleanupStoreContract.mjs +10 -6
  133. package/dist/testing/index.d.mts +31 -4
  134. package/dist/testing/index.mjs +97 -39
  135. package/dist/testing/outboxStoreContract.d.mts +11 -2
  136. package/dist/testing/outboxStoreContract.mjs +110 -21
  137. package/dist/testing/storageContract.mjs +11 -6
  138. package/dist/{transactionContext-dAfBVUrk.mjs → transactionContext-BvLKvOIL.mjs} +1 -1
  139. package/dist/{typeGuards-B0ryPo3I.mjs → typeGuards-B_5-hKjJ.mjs} +1 -1
  140. package/dist/types/index.mjs +1 -1
  141. package/dist/types/storage.mjs +1 -1
  142. package/dist/{types-CBgvkp8O.d.mts → types-CT5vu6M0.d.mts} +87 -11
  143. package/dist/{types-Cys0ko9Z.mjs → types-DXYX7dIR.mjs} +4 -4
  144. package/dist/usage/index.mjs +0 -0
  145. package/dist/{userHelpers-ChYJ6CdK.mjs → userHelpers-BNsOXzOd.mjs} +1 -1
  146. package/dist/utils/index.mjs +10 -10
  147. package/dist/{utils-vL8BrDE2.mjs → utils-BtOha_Sp.mjs} +3 -3
  148. package/dist/{validate-_KbifpHF.mjs → validate-PMCPR9El.mjs} +2 -2
  149. package/dist/{versioning-CHDTor3P.mjs → versioning-CKv2jz_U.mjs} +4 -4
  150. package/package.json +23 -13
  151. package/dist/fastify-augmentation-zh0_AYHa.d.mts +0 -85
@@ -1,6 +1,6 @@
1
- /*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
1
+ /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
2
  import { OutboxOwnershipError } from "@classytic/primitives/outbox";
3
- import { afterAll, beforeAll, describe, expect, it } from "vitest";
3
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
4
4
  //#region src/testing/outboxStoreContract.ts
5
5
  let seq = 0;
6
6
  /** A minimal well-formed event. Ids are unique per call. */
@@ -30,9 +30,16 @@ function runOutboxStoreContract(name, setup) {
30
30
  afterAll(async () => {
31
31
  if (teardown) await teardown();
32
32
  });
33
+ /**
34
+ * Isolation for EVERY test, not just the ones that call `seed()`. Resetting inside
35
+ * `seed()` left any test that asserts on an empty store — or that only reads —
36
+ * running against its predecessor's rows.
37
+ */
38
+ beforeEach(async () => {
39
+ await reset();
40
+ });
33
41
  /** Save n events, oldest first. */
34
42
  async function seed(n, type) {
35
- if (reset) await reset();
36
43
  const events = [];
37
44
  for (let i = 0; i < n; i++) {
38
45
  const e = event(type);
@@ -44,10 +51,10 @@ function runOutboxStoreContract(name, setup) {
44
51
  describe("save — rejects malformed events (invariant 1)", () => {
45
52
  it("persists a well-formed event", async () => {
46
53
  const [e] = await seed(1);
47
- expect((await store.getPending(10)).map(idOf)).toContain(idOf(e));
54
+ const pending = await store.getPending(10);
55
+ expect(pending.map(idOf)).toContain(idOf(e));
48
56
  });
49
57
  it("THROWS rather than persisting an event with no `type`", async () => {
50
- if (reset) await reset();
51
58
  await expect(store.save({
52
59
  payload: {},
53
60
  meta: { id: "no-type" }
@@ -55,7 +62,6 @@ function runOutboxStoreContract(name, setup) {
55
62
  expect((await store.getPending(10)).length).toBe(0);
56
63
  });
57
64
  it("THROWS rather than persisting an event with no `meta.id`", async () => {
58
- if (reset) await reset();
59
65
  await expect(store.save({
60
66
  type: "x",
61
67
  payload: {},
@@ -67,7 +73,8 @@ function runOutboxStoreContract(name, setup) {
67
73
  describe("getPending", () => {
68
74
  it("returns events FIFO (oldest first)", async () => {
69
75
  const saved = await seed(3);
70
- expect((await store.getPending(10)).slice(0, 3).map(idOf)).toEqual(saved.map(idOf));
76
+ const pending = await store.getPending(10);
77
+ expect(pending.slice(0, 3).map(idOf)).toEqual(saved.map(idOf));
71
78
  });
72
79
  it("honours the limit", async () => {
73
80
  await seed(3);
@@ -86,10 +93,11 @@ function runOutboxStoreContract(name, setup) {
86
93
  it("claims pending events", async ({ skip }) => {
87
94
  if (!store.claimPending) return skip();
88
95
  await seed(2);
89
- expect((await store.claimPending({
96
+ const claimed = await store.claimPending({
90
97
  consumerId: "c1",
91
98
  limit: 10
92
- })).length).toBe(2);
99
+ });
100
+ expect(claimed.length).toBe(2);
93
101
  });
94
102
  it("two concurrent claimers NEVER receive the same event", async ({ skip }) => {
95
103
  if (!store.claimPending) return skip();
@@ -103,7 +111,8 @@ function runOutboxStoreContract(name, setup) {
103
111
  })]);
104
112
  const idsA = a.map(idOf);
105
113
  const idsB = b.map(idOf);
106
- expect(idsA.filter((id) => idsB.includes(id))).toEqual([]);
114
+ const overlap = idsA.filter((id) => idsB.includes(id));
115
+ expect(overlap).toEqual([]);
107
116
  expect(idsA.length + idsB.length).toBeLessThanOrEqual(6);
108
117
  });
109
118
  it("does not re-claim an event whose lease is still live", async ({ skip }) => {
@@ -114,10 +123,11 @@ function runOutboxStoreContract(name, setup) {
114
123
  limit: 10,
115
124
  leaseMs: 6e4
116
125
  });
117
- expect(await store.claimPending({
126
+ const second = await store.claimPending({
118
127
  consumerId: "c2",
119
128
  limit: 10
120
- })).toEqual([]);
129
+ });
130
+ expect(second).toEqual([]);
121
131
  });
122
132
  it("RECLAIMS an event whose lease expired (crashed worker)", async ({ skip }) => {
123
133
  if (!store.claimPending) return skip();
@@ -127,18 +137,20 @@ function runOutboxStoreContract(name, setup) {
127
137
  limit: 10,
128
138
  leaseMs: 0
129
139
  });
130
- expect((await store.claimPending({
140
+ const reclaimed = await store.claimPending({
131
141
  consumerId: "c2",
132
142
  limit: 10
133
- })).length).toBe(1);
143
+ });
144
+ expect(reclaimed.length).toBe(1);
134
145
  });
135
146
  it("claims FIFO", async ({ skip }) => {
136
147
  if (!store.claimPending) return skip();
137
148
  const saved = await seed(3);
138
- expect((await store.claimPending({
149
+ const claimed = await store.claimPending({
139
150
  consumerId: "c1",
140
151
  limit: 2
141
- })).map(idOf)).toEqual(saved.slice(0, 2).map(idOf));
152
+ });
153
+ expect(claimed.map(idOf)).toEqual(saved.slice(0, 2).map(idOf));
142
154
  });
143
155
  });
144
156
  describe("acknowledge", () => {
@@ -148,7 +160,6 @@ function runOutboxStoreContract(name, setup) {
148
160
  expect((await store.getPending(10)).map(idOf)).not.toContain(idOf(e));
149
161
  });
150
162
  it("is a NO-OP for an unknown id — never an ownership error (invariant 4)", async () => {
151
- if (reset) await reset();
152
163
  await expect(store.acknowledge("does-not-exist")).resolves.not.toThrow();
153
164
  });
154
165
  it("THROWS OutboxOwnershipError when consumerId is not the lease owner (invariant 3)", async ({ skip }) => {
@@ -185,10 +196,11 @@ function runOutboxStoreContract(name, setup) {
185
196
  });
186
197
  if (!claimed) return skip();
187
198
  await store.fail(idOf(claimed), { message: "boom" });
188
- expect((await store.claimPending({
199
+ const again = await store.claimPending({
189
200
  consumerId: "c2",
190
201
  limit: 10
191
- })).map(idOf)).toContain(idOf(claimed));
202
+ });
203
+ expect(again.map(idOf)).toContain(idOf(claimed));
192
204
  });
193
205
  it("THROWS OutboxOwnershipError on a consumerId mismatch (invariant 3)", async ({ skip }) => {
194
206
  if (!store.fail || !store.claimPending) return skip();
@@ -211,10 +223,11 @@ function runOutboxStoreContract(name, setup) {
211
223
  });
212
224
  if (!claimed) return skip();
213
225
  await store.fail(idOf(claimed), { message: "boom" }, { retryAt: new Date(Date.now() + 6e4) });
214
- expect((await store.claimPending({
226
+ const again = await store.claimPending({
215
227
  consumerId: "c2",
216
228
  limit: 10
217
- })).map(idOf)).not.toContain(idOf(claimed));
229
+ });
230
+ expect(again.map(idOf)).not.toContain(idOf(claimed));
218
231
  });
219
232
  it("`deadLetter: true` removes it from the pending stream", async ({ skip }) => {
220
233
  if (!store.fail) return skip();
@@ -242,6 +255,82 @@ function runOutboxStoreContract(name, setup) {
242
255
  expect((await store.getPending(10)).length).toBe(2);
243
256
  });
244
257
  });
258
+ /**
259
+ * `requeue`, `countByStatus` and `oldestPendingAgeMs` were a HOST's three extra
260
+ * methods before they were contract members — which is why they are pinned here.
261
+ * A dead-letter row an operator cannot re-drive, or a health number that lies, fails
262
+ * silently: the operator concludes the queue is fine, and the whole point of the
263
+ * table is that somebody eventually looks.
264
+ */
265
+ describe("requeue", () => {
266
+ it("returns a dead-lettered event to pending and clears the attempt count", async ({ skip }) => {
267
+ if (!store.fail || !store.requeue) return skip();
268
+ const [e] = await seed(1);
269
+ const id = idOf(e);
270
+ await store.fail(id, { message: "fatal" }, { deadLetter: true });
271
+ expect((await store.getPending(10)).length).toBe(0);
272
+ expect(await store.requeue(id)).toBe(true);
273
+ const pending = await store.getPending(10);
274
+ expect(pending.length).toBe(1);
275
+ if (store.countByStatus) expect(await store.countByStatus("dead_letter")).toBe(0);
276
+ });
277
+ it("REFUSES a pending event — requeue is dead-letter-scoped", async ({ skip }) => {
278
+ if (!store.requeue) return skip();
279
+ const [e] = await seed(1);
280
+ expect(await store.requeue(idOf(e))).toBe(false);
281
+ });
282
+ it("is FALSE, not an error, for an unknown id", async ({ skip }) => {
283
+ if (!store.requeue) return skip();
284
+ expect(await store.requeue("no-such-event")).toBe(false);
285
+ });
286
+ });
287
+ describe("countByStatus", () => {
288
+ it("counts pending, and moves the count as events are delivered", async ({ skip }) => {
289
+ if (!store.countByStatus) return skip();
290
+ await seed(3);
291
+ expect(await store.countByStatus("pending")).toBe(3);
292
+ if (!store.claimPending) return skip();
293
+ const [claimed] = await store.claimPending({
294
+ consumerId: "c",
295
+ limit: 1,
296
+ leaseMs: 6e4
297
+ });
298
+ if (!claimed) return skip();
299
+ await store.acknowledge(idOf(claimed), { consumerId: "c" });
300
+ expect(await store.countByStatus("pending")).toBe(2);
301
+ expect(await store.countByStatus("delivered")).toBe(1);
302
+ });
303
+ it("counts dead-lettered rows — the number an alert fires on", async ({ skip }) => {
304
+ if (!store.countByStatus || !store.fail) return skip();
305
+ expect(await store.countByStatus("dead_letter")).toBe(0);
306
+ const [e] = await seed(1);
307
+ await store.fail(idOf(e), { message: "fatal" }, { deadLetter: true });
308
+ expect(await store.countByStatus("dead_letter")).toBe(1);
309
+ });
310
+ });
311
+ describe("oldestPendingAgeMs", () => {
312
+ it("is null on an empty queue, and a non-negative age once something pends", async ({ skip }) => {
313
+ if (!store.oldestPendingAgeMs) return skip();
314
+ expect(await store.oldestPendingAgeMs()).toBeNull();
315
+ await seed(1);
316
+ const age = await store.oldestPendingAgeMs();
317
+ expect(age).not.toBeNull();
318
+ expect(age ?? -1).toBeGreaterThanOrEqual(0);
319
+ });
320
+ it("ignores DELIVERED rows — it answers 'how long undelivered', not 'how old'", async ({ skip }) => {
321
+ if (!store.oldestPendingAgeMs) return skip();
322
+ if (!store.claimPending) return skip();
323
+ await seed(1);
324
+ const [claimed] = await store.claimPending({
325
+ consumerId: "c",
326
+ limit: 1,
327
+ leaseMs: 6e4
328
+ });
329
+ if (!claimed) return skip();
330
+ await store.acknowledge(idOf(claimed), { consumerId: "c" });
331
+ expect(await store.oldestPendingAgeMs()).toBeNull();
332
+ });
333
+ });
245
334
  });
246
335
  }
247
336
  //#endregion
@@ -1,4 +1,4 @@
1
- /*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
1
+ /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
2
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
3
3
  //#region src/testing/storageContract.ts
4
4
  /**
@@ -89,7 +89,8 @@ function runStorageContract(name, setup) {
89
89
  size: bytes.length
90
90
  }, EMPTY_CTX);
91
91
  const read = await storage.read(file.id, EMPTY_CTX);
92
- expect((await readAll(read)).equals(bytes)).toBe(true);
92
+ const actual = await readAll(read);
93
+ expect(actual.equals(bytes)).toBe(true);
93
94
  expect(read.contentType).toBe("application/octet-stream");
94
95
  await storage.delete(file.id, EMPTY_CTX);
95
96
  });
@@ -101,7 +102,8 @@ function runStorageContract(name, setup) {
101
102
  mimeType: "application/octet-stream",
102
103
  size: bytes.length
103
104
  }, EMPTY_CTX);
104
- expect(await storage.delete(file.id, EMPTY_CTX)).toBe(true);
105
+ const first = await storage.delete(file.id, EMPTY_CTX);
106
+ expect(first).toBe(true);
105
107
  let second = "threw";
106
108
  try {
107
109
  second = await storage.delete(file.id, EMPTY_CTX);
@@ -169,8 +171,10 @@ function runStorageContract(name, setup) {
169
171
  mimeType: "application/octet-stream",
170
172
  size: bytes.length
171
173
  }, EMPTY_CTX);
172
- expect((await readAll(await storage.read(file.id, EMPTY_CTX))).equals(bytes)).toBe(true);
173
- expect(await storage.delete(file.id, EMPTY_CTX)).toBe(true);
174
+ const before = await readAll(await storage.read(file.id, EMPTY_CTX));
175
+ expect(before.equals(bytes)).toBe(true);
176
+ const removed = await storage.delete(file.id, EMPTY_CTX);
177
+ expect(removed).toBe(true);
174
178
  let rejected = false;
175
179
  try {
176
180
  if (!(await readAll(await storage.read(file.id, EMPTY_CTX))).equals(bytes)) rejected = true;
@@ -189,7 +193,8 @@ function runStorageContract(name, setup) {
189
193
  }, EMPTY_CTX);
190
194
  const result = await storage.read(file.id, EMPTY_CTX);
191
195
  expect(result.kind === "stream" || result.kind === "buffer").toBe(true);
192
- expect((await readAll(result)).equals(bytes)).toBe(true);
196
+ const actual = await readAll(result);
197
+ expect(actual.equals(bytes)).toBe(true);
193
198
  await storage.delete(file.id, EMPTY_CTX);
194
199
  });
195
200
  it("read() with a mid-object range slices correctly (when adapter supports ranges)", async () => {
@@ -1,4 +1,4 @@
1
- /*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
1
+ /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  //#region src/context/transactionContext.ts
4
4
  /**
@@ -1,4 +1,4 @@
1
- /*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
1
+ /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
2
  //#region src/utils/typeGuards.ts
3
3
  /** Check if fastify has the events plugin registered */
4
4
  function hasEvents(instance) {
@@ -1,2 +1,2 @@
1
- /*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
1
+ /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
2
  export {};
@@ -1,2 +1,2 @@
1
- /*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
1
+ /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
2
  export {};
@@ -320,6 +320,26 @@ interface LoadResourcesOptions<TContext = unknown> {
320
320
  */
321
321
  declare function loadResources<TContext = unknown>(dir: string, options?: LoadResourcesOptions<TContext>): Promise<ResourceLike[]>;
322
322
  //#endregion
323
+ //#region src/factory/module/disposers.d.ts
324
+ /** Teardown callback registered by a setup phase via `defer`. */
325
+ type ModuleDisposer = () => void | Promise<void>;
326
+ /**
327
+ * Second argument to a module's setup phases (`plugins`, `bootstrap`,
328
+ * `afterResources`).
329
+ */
330
+ interface ModuleSetupContext {
331
+ /**
332
+ * Register a teardown callback for the resource just acquired. Call it
333
+ * IMMEDIATELY after acquisition — that is what makes partial-init teardown
334
+ * exact rather than defensive.
335
+ *
336
+ * Disposers run LIFO, after this module's `onClose`, on both the rollback
337
+ * and shutdown paths, and exactly once. A throwing disposer is logged and
338
+ * never blocks the ones behind it.
339
+ */
340
+ defer(disposer: ModuleDisposer): void;
341
+ }
342
+ //#endregion
323
343
  //#region src/factory/module/types.d.ts
324
344
  /**
325
345
  * A module's contribution of some arm (event handlers, scheduled jobs, …).
@@ -430,8 +450,18 @@ interface ArcModule<TExports = unknown> {
430
450
  * published ecosystem packages: `plugins` = "register infra"; `bootstrap` =
431
451
  * "initialise engines (and return the public export)". Registering a plugin
432
452
  * inside `bootstrap` still works — this slot just makes the two distinct.
453
+ *
454
+ * Receives `{ defer }` to register teardown for anything it acquires, and
455
+ * may RETURN a single disposer as shorthand for the one-resource case:
456
+ *
457
+ * ```ts
458
+ * plugins: async (fastify) => {
459
+ * const conn = await connect();
460
+ * return () => conn.close(); // equivalent to defer(...)
461
+ * },
462
+ * ```
433
463
  */
434
- plugins?: (fastify: FastifyInstance) => void | Promise<void>;
464
+ plugins?: (fastify: FastifyInstance, context: ModuleSetupContext) => ModuleDisposer | undefined | Promise<ModuleDisposer | undefined>;
435
465
  /**
436
466
  * Domain error mappers this module ships (see `defineErrorMapper`). Merged
437
467
  * into the app's error handler at boot, AFTER any host-declared mappers —
@@ -468,8 +498,23 @@ interface ArcModule<TExports = unknown> {
468
498
  * // arc-invoice bootstrap, composed AFTER arc-accounting:
469
499
  * const acct = getModuleExports<AccountingEngine>(fastify, "accounting");
470
500
  * ```
501
+ *
502
+ * The return value is the module's export, so teardown here goes through
503
+ * `defer` (the second argument) rather than a returned disposer — register
504
+ * each resource the moment it exists and partial-init cleanup becomes exact
505
+ * instead of a chain of `?.` guards:
506
+ *
507
+ * ```ts
508
+ * bootstrap: async (fastify, { defer }) => {
509
+ * const client = await openClient();
510
+ * defer(() => client.close());
511
+ * const engine = await createEngine(client); // if this throws, only
512
+ * defer(() => engine.stop()); // the client is released
513
+ * return engine;
514
+ * },
515
+ * ```
471
516
  */
472
- bootstrap?: (fastify: FastifyInstance) => TExports | Promise<TExports>;
517
+ bootstrap?: (fastify: FastifyInstance, context: ModuleSetupContext) => TExports | Promise<TExports>;
473
518
  /**
474
519
  * App-level resource NAMES this module authoritatively SUPERSEDES. When the
475
520
  * host also lists (or `resourceDir`-discovers) a resource of the same name,
@@ -489,18 +534,37 @@ interface ArcModule<TExports = unknown> {
489
534
  * any fork exists.
490
535
  * - Collected across ALL modules; the app-side supersession is their union.
491
536
  *
492
- * **Contract (ENFORCED at boot): if you `owns` a name, PROVIDE it** — in
493
- * THIS module's own `resources`. Arc resolves module resources first and
494
- * fails boot when a claimed name is not among them, naming the module, the
495
- * unmet names, and what it did supply. Without that check, a typo or a
496
- * resource deleted without updating `owns` booted "successfully" with the
497
- * app's route silently removed and nothing serving it.
537
+ * **Prefer `owns: "provided"`** (2.32) — arc derives the list from the
538
+ * resources this module actually returned, so the claim and the reality
539
+ * cannot disagree:
540
+ *
541
+ * ```ts
542
+ * defineModule({ name: "order", owns: "provided", resources: () => [...] })
543
+ * ```
544
+ *
545
+ * That is the whole arm for most modules. Resources are resolved exactly
546
+ * once, names are validated (duplicates within a module are rejected), the
547
+ * effective `owns` is derived, and supersession runs afterward — one atomic
548
+ * phase arc owns, rather than a rule each module re-states.
549
+ *
550
+ * **The explicit-array form is ENFORCED at boot: if you `owns` a name,
551
+ * PROVIDE it** — in THIS module's own `resources`. Arc fails boot when a
552
+ * claimed name is not among them, naming the module, the unmet names, and
553
+ * what it did supply. Without that check, a typo or a resource deleted
554
+ * without updating the list booted "successfully" with the app's route
555
+ * silently removed and nothing serving it. Two real cases, both silent 404s:
556
+ * a POS module claiming a checkout resource it mounted only when the host
557
+ * supplied a pipeline, and a device gateway claiming a name it never
558
+ * provided at all.
559
+ *
560
+ * Keep the explicit array only when the list is genuinely NOT the module's
561
+ * own resource set — e.g. pre-declaring a name before the fork exists.
498
562
  *
499
563
  * A sibling module supplying the name does NOT satisfy the claim — ownership
500
564
  * is local to the module that declares it, which is the colocation this arm
501
565
  * exists for.
502
566
  */
503
- readonly owns?: readonly string[];
567
+ readonly owns?: readonly string[] | "provided";
504
568
  /**
505
569
  * The module's resources. Array or factory — the factory form runs AFTER all
506
570
  * bootstraps, so `defineResource(...)` receives fully-booted engines. Flows
@@ -512,8 +576,12 @@ interface ArcModule<TExports = unknown> {
512
576
  * Post-registration wiring — runs in the `afterResources` phase, once this
513
577
  * module's routes are mounted. Use for cross-resource event subscriptions the
514
578
  * module owns.
579
+ *
580
+ * Receives `{ defer }`, and may return a disposer, on the same terms as
581
+ * `plugins` — handy for wiring that must be unwound (a subscription, a
582
+ * listener) without hoisting a handle into module scope.
515
583
  */
516
- afterResources?: (fastify: FastifyInstance) => void | Promise<void>;
584
+ afterResources?: (fastify: FastifyInstance, context: ModuleSetupContext) => ModuleDisposer | undefined | Promise<ModuleDisposer | undefined>;
517
585
  /**
518
586
  * Readiness checks this module contributes to the app's
519
587
  * `healthPlugin` (`/_health/ready`). Collected across ALL modules in
@@ -611,6 +679,14 @@ interface ArcModule<TExports = unknown> {
611
679
  * onClose: async () => { await client?.close(); }, // still runs
612
680
  * });
613
681
  * ```
682
+ *
683
+ * Prefer `defer` (the setup phases' second argument) for anything acquired
684
+ * step-by-step: it registers each teardown at the point of acquisition, so
685
+ * the runtime knows exactly what was reached and the `?.` guards above
686
+ * disappear. `onClose` remains the right slot for tearing down what
687
+ * `bootstrap` RETURNED — the module's engine — and runs FIRST, ahead of the
688
+ * module's deferred disposers (see `disposers.ts` for the ordering rule).
689
+ * The two compose; either alone is complete.
614
690
  */
615
691
  onClose?: (fastify: FastifyInstance) => void | Promise<void>;
616
692
  }
@@ -657,4 +733,4 @@ type ArcModuleInput<TExports = unknown> = ArcModule<TExports> | Promise<ArcModul
657
733
  */
658
734
  interface ArcModuleRegistry {}
659
735
  //#endregion
660
- export { _default$1 as _, ModuleContribution as a, ResourceModule as c, ScheduleStats as d, SchedulesPluginOptions as f, HealthOptions as g, HealthCheck as h, EventHandlerDefinition as i, loadResources as l, schedulesPlugin as m, ArcModuleInput as n, LoadResourcesOptions as o, _default as p, ArcModuleRegistry as r, ResourceLike as s, ArcModule as t, ScheduleDefinition as u, healthPlugin as v };
736
+ export { HealthCheck as _, ModuleContribution as a, healthPlugin as b, LoadResourcesOptions as c, loadResources as d, ScheduleDefinition as f, schedulesPlugin as g, _default as h, EventHandlerDefinition as i, ResourceLike as l, SchedulesPluginOptions as m, ArcModuleInput as n, ModuleDisposer as o, ScheduleStats as p, ArcModuleRegistry as r, ModuleSetupContext as s, ArcModule as t, ResourceModule as u, HealthOptions as v, _default$1 as y };
@@ -1,7 +1,7 @@
1
- /*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
2
- import { l as UnauthorizedError, o as OrgRequiredError } from "./errors-Cj42AntO.mjs";
3
- import { n as requireSingleHeaderValue } from "./headers-3zvE0rM3.mjs";
4
- import { t as getUserId$1 } from "./userHelpers-ChYJ6CdK.mjs";
1
+ /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
+ import { l as UnauthorizedError, o as OrgRequiredError } from "./errors-Bh1KK5ps.mjs";
3
+ import { n as requireSingleHeaderValue } from "./headers-BXjQYbeC.mjs";
4
+ import { t as getUserId$1 } from "./userHelpers-BNsOXzOd.mjs";
5
5
  //#region src/scope/types.ts
6
6
  /**
7
7
  * Request Scope — The One Standard
Binary file
@@ -1,4 +1,4 @@
1
- /*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
1
+ /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
2
  //#region src/utils/userHelpers.ts
3
3
  /**
4
4
  * Extract a user ID from a user object. Accepts `id` or `_id` — returns
@@ -1,11 +1,11 @@
1
- /*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
2
- import { a as OrgAccessDeniedError, c as ServiceUnavailableError, d as createDomainError, f as createError, i as NotFoundError, l as UnauthorizedError, n as ConflictError, o as OrgRequiredError, p as isArcError, r as ForbiddenError, s as RateLimitError, t as ArcError, u as ValidationError } from "../errors-Cj42AntO.mjs";
3
- import { n as requireSingleHeaderValue, t as getHeader } from "../headers-3zvE0rM3.mjs";
4
- import { n as getUserRoles, r as normalizeRoles, t as getUserId } from "../userHelpers-ChYJ6CdK.mjs";
5
- import { t as simpleEqualityMatcher } from "../simpleEqualityMatcher-DjQJ7FKA.mjs";
6
- import { A as createCircuitBreakerRegistry, C as defineCompensation, D as CircuitBreakerRegistry, E as CircuitBreakerError, O as CircuitState, S as defineErrorMapper, T as CircuitBreaker, _ as createQueryParser, a as deleteResponse, b as NEUTRAL_DIALECT, c as getDefaultCrudSchemas, d as listResponse, f as offsetListResponse, g as ArcQueryParser, h as responses, i as bareListResponse, k as createCircuitBreaker, l as getListQueryParams, m as queryParams, n as scheduleBackground, o as errorContractSchema, p as paginationSchema, r as aggregateListResponse, s as errorDetailSchema, t as createStateMachine, u as keysetListResponse, v as handleRaw, w as withCompensation, x as defineGuard, y as MONGO_DIALECT } from "../utils-vL8BrDE2.mjs";
7
- import { a as isZodSchema, i as isJsonSchema, n as convertRouteSchema, o as toJsonSchema, t as convertOpenApiSchemas } from "../schemaConverter-D--wdJM-.mjs";
8
- import { a as promoteStreamTokenToHeader, i as pipeUIMessageStreamToReply, n as forwardedStreamHeaders, r as isReadableStream, t as UI_MESSAGE_STREAM_HEADERS } from "../streaming-PSZwi8JV.mjs";
9
- import { t as openSseStream } from "../sseStream-B_5XcVVd.mjs";
10
- import { t as hasEvents } from "../typeGuards-B0ryPo3I.mjs";
1
+ /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
+ import { a as OrgAccessDeniedError, c as ServiceUnavailableError, d as createDomainError, f as createError, i as NotFoundError, l as UnauthorizedError, n as ConflictError, o as OrgRequiredError, p as isArcError, r as ForbiddenError, s as RateLimitError, t as ArcError, u as ValidationError } from "../errors-Bh1KK5ps.mjs";
3
+ import { n as requireSingleHeaderValue, t as getHeader } from "../headers-BXjQYbeC.mjs";
4
+ import { n as getUserRoles, r as normalizeRoles, t as getUserId } from "../userHelpers-BNsOXzOd.mjs";
5
+ import { t as simpleEqualityMatcher } from "../simpleEqualityMatcher-C4KiMWUa.mjs";
6
+ import { A as createCircuitBreakerRegistry, C as defineCompensation, D as CircuitBreakerRegistry, E as CircuitBreakerError, O as CircuitState, S as defineErrorMapper, T as CircuitBreaker, _ as createQueryParser, a as deleteResponse, b as NEUTRAL_DIALECT, c as getDefaultCrudSchemas, d as listResponse, f as offsetListResponse, g as ArcQueryParser, h as responses, i as bareListResponse, k as createCircuitBreaker, l as getListQueryParams, m as queryParams, n as scheduleBackground, o as errorContractSchema, p as paginationSchema, r as aggregateListResponse, s as errorDetailSchema, t as createStateMachine, u as keysetListResponse, v as handleRaw, w as withCompensation, x as defineGuard, y as MONGO_DIALECT } from "../utils-BtOha_Sp.mjs";
7
+ import { a as isZodSchema, i as isJsonSchema, n as convertRouteSchema, o as toJsonSchema, t as convertOpenApiSchemas } from "../schemaConverter-Crjvczi_.mjs";
8
+ import { a as promoteStreamTokenToHeader, i as pipeUIMessageStreamToReply, n as forwardedStreamHeaders, r as isReadableStream, t as UI_MESSAGE_STREAM_HEADERS } from "../streaming-DPZdQmDz.mjs";
9
+ import { t as openSseStream } from "../sseStream-B3zb_d1h.mjs";
10
+ import { t as hasEvents } from "../typeGuards-B_5-hKjJ.mjs";
11
11
  export { ArcError, ArcQueryParser, CircuitBreaker, CircuitBreakerError, CircuitBreakerRegistry, CircuitState, ConflictError, ForbiddenError, MONGO_DIALECT, NEUTRAL_DIALECT, NotFoundError, OrgAccessDeniedError, OrgRequiredError, RateLimitError, ServiceUnavailableError, UI_MESSAGE_STREAM_HEADERS, UnauthorizedError, ValidationError, aggregateListResponse, bareListResponse, convertOpenApiSchemas, convertRouteSchema, createCircuitBreaker, createCircuitBreakerRegistry, createDomainError, createError, createQueryParser, createStateMachine, defineCompensation, defineErrorMapper, defineGuard, deleteResponse, errorContractSchema, errorDetailSchema, forwardedStreamHeaders, getDefaultCrudSchemas, getHeader, getListQueryParams, getUserId, getUserRoles, handleRaw, hasEvents, isArcError, isJsonSchema, isReadableStream, isZodSchema, keysetListResponse, listResponse, normalizeRoles, offsetListResponse, openSseStream, paginationSchema, pipeUIMessageStreamToReply, promoteStreamTokenToHeader, queryParams, requireSingleHeaderValue, responses, scheduleBackground, simpleEqualityMatcher, toJsonSchema, withCompensation };
@@ -1,7 +1,7 @@
1
- /*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
2
- import { m as RESERVED_QUERY_PARAMS } from "./constants-D3Dv4LAV.mjs";
1
+ /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
+ import { m as RESERVED_QUERY_PARAMS } from "./constants-J99LGTJI.mjs";
3
3
  import { arcLog } from "./logger/index.mjs";
4
- import { t as ArcError } from "./errors-Cj42AntO.mjs";
4
+ import { t as ArcError } from "./errors-Bh1KK5ps.mjs";
5
5
  import { errorContractSchema as errorContractSchema$1, errorDetailSchema as errorDetailSchema$1 } from "@classytic/repo-core/errors";
6
6
  import { scheduleBackground } from "@classytic/repo-core/cache";
7
7
  //#region src/utils/circuitBreaker.ts
@@ -1,5 +1,5 @@
1
- /*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
2
- import { t as __exportAll } from "./rolldown-runtime-CUsjNX9R.mjs";
1
+ /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
+ import { t as __exportAll } from "./rolldown-runtime-BhflRrBs.mjs";
3
3
  //#region src/core/aggregation/normalize.ts
4
4
  /**
5
5
  * Framework cap on URL-driven aggregation limits when the host opts
@@ -1,6 +1,6 @@
1
- /*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
2
- import { t as __exportAll } from "./rolldown-runtime-CUsjNX9R.mjs";
3
- import { d as createDomainError } from "./errors-Cj42AntO.mjs";
1
+ /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
+ import { t as __exportAll } from "./rolldown-runtime-BhflRrBs.mjs";
3
+ import { d as createDomainError } from "./errors-Bh1KK5ps.mjs";
4
4
  import fp from "fastify-plugin";
5
5
  //#region src/plugins/versioning.ts
6
6
  var versioning_exports = /* @__PURE__ */ __exportAll({
@@ -31,7 +31,7 @@ const versioningPlugin = async (fastify, opts) => {
31
31
  reply.header(responseHeader, version);
32
32
  if (deprecatedSet.has(version)) {
33
33
  reply.header("deprecation", "true");
34
- reply.header("sunset", sunset ?? new Date(Date.now() + 2160 * 60 * 60 * 1e3).toISOString());
34
+ reply.header("sunset", sunset ?? new Date(Date.now() + 7776e6).toISOString());
35
35
  }
36
36
  });
37
37
  };