@classytic/arc 2.25.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 (48) hide show
  1. package/README.md +2 -0
  2. package/dist/{BaseController--M8EMzt0.mjs → BaseController-B65zbz9m.mjs} +4 -2
  3. package/dist/{applyPermissionResult-Tw4iXtlf.mjs → applyPermissionResult-BnyVE9eD.mjs} +10 -0
  4. package/dist/cleanup/index.d.mts +313 -0
  5. package/dist/cleanup/index.mjs +1267 -0
  6. package/dist/cli/commands/docs.mjs +1 -1
  7. package/dist/core/index.d.mts +1 -1
  8. package/dist/core/index.mjs +4 -3
  9. package/dist/core-BGa8TXJR.mjs +221 -0
  10. package/dist/{createActionRouter-B6GvAuAX.mjs → createActionRouter-C-N7z2EL.mjs} +2 -2
  11. package/dist/{preloadResources-DfyngIN9.mjs → createApp-Du5G54TM.mjs} +2 -74
  12. package/dist/{core-EUNu0u07.mjs → defineResource-2zvVVpxg.mjs} +6 -224
  13. package/dist/docs/index.mjs +1 -1
  14. package/dist/events/index.d.mts +28 -2
  15. package/dist/events/index.mjs +20 -4
  16. package/dist/factory/index.d.mts +4 -3
  17. package/dist/factory/index.mjs +3 -1
  18. package/dist/{fastify-augmentation-dPNBVeIj.d.mts → fastify-augmentation-OzYGIiU1.d.mts} +2 -1
  19. package/dist/index-CZY4EGga.d.mts +107 -0
  20. package/dist/{index-f4PTrk3B.d.mts → index-CjVg-Epg.d.mts} +1 -1
  21. package/dist/index.d.mts +2 -2
  22. package/dist/index.mjs +6 -5
  23. package/dist/integrations/mcp/index.mjs +1 -1
  24. package/dist/integrations/mcp/testing.mjs +1 -1
  25. package/dist/{mcpPlugin-Cb7olw0j.mjs → mcpPlugin-Cs6nRGCt.mjs} +3 -3
  26. package/dist/module-_-M7C5ES.mjs +29 -0
  27. package/dist/{openapi-DrLtog_B.mjs → openapi-BLV4OMXb.mjs} +1 -1
  28. package/dist/permissions/index.mjs +1 -1
  29. package/dist/{pipeline-BN5G05rd.mjs → pipeline-BGRnV-To.mjs} +1 -1
  30. package/dist/plugins/index.d.mts +2 -2
  31. package/dist/plugins/index.mjs +1 -1
  32. package/dist/plugins/tracing-entry.mjs +1 -1
  33. package/dist/{preloadResources-0oe-JSSS.d.mts → preloadResources-CM4uUaFv.d.mts} +2 -1
  34. package/dist/preloadResources-DSY5HGpS.mjs +45 -0
  35. package/dist/{realtime-Cuw_deA-.mjs → realtime-DEuDu9io.mjs} +1 -1
  36. package/dist/{registerRoute-v0Wb6Plt.mjs → registerRoute-DTp18Ah_.mjs} +1 -1
  37. package/dist/registry/index.d.mts +1 -1
  38. package/dist/registry/index.mjs +1 -1
  39. package/dist/{registry-vcOCtPij.mjs → registry-9km_w1aP.mjs} +15 -0
  40. package/dist/testing/cleanupStoreContract.d.mts +10 -0
  41. package/dist/testing/cleanupStoreContract.mjs +375 -0
  42. package/dist/testing/index.d.mts +3 -2
  43. package/dist/testing/index.mjs +4 -2
  44. package/dist/testing/outboxStoreContract.d.mts +13 -0
  45. package/dist/testing/outboxStoreContract.mjs +247 -0
  46. package/dist/types-C_ZZRcD6.d.mts +450 -0
  47. package/dist/{index-KUoxjIq1.d.mts → types-Cu6vVJmh.d.mts} +1 -105
  48. package/package.json +27 -12
@@ -0,0 +1,375 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ //#region src/testing/cleanupStoreContract.ts
3
+ /**
4
+ * Cleanup Run-Store Contract Suite
5
+ *
6
+ * `CleanupRunStore` is the durability substrate of the Data Cleanup Center: its
7
+ * atomic guarantees are the only thing standing between a crash and a
8
+ * *double-executed destructive operation*. The semantics are subtle —
9
+ * conditional insert by `concurrencyKey`, EXCLUSIVE lease claim with crash
10
+ * recovery, lease-guarded CAS, admission-guarded re-arm — and a plausible-looking
11
+ * adapter can get them wrong in ways no ordinary test catches.
12
+ *
13
+ * Any implementation can import this suite and run it against a live instance.
14
+ * Passing it IS the contract.
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * import { runCleanupRunStoreContract } from '@classytic/arc/testing/cleanup';
19
+ * import { mongoCleanupRunStore } from '../src/cleanup/mongo-run-store.js';
20
+ *
21
+ * runCleanupRunStoreContract('mongoCleanupRunStore', async () => {
22
+ * const store = mongoCleanupRunStore(connection);
23
+ * return { store, teardown: async () => connection.dropCollection('cleanup_runs') };
24
+ * });
25
+ * ```
26
+ *
27
+ * Time is exercised through lease `expiresAt` values in the past/future rather
28
+ * than a clock hook, so the suite works unmodified against a real database
29
+ * comparing to wall-clock `now`.
30
+ *
31
+ * This module statically imports `vitest`. Only load it from test code — arc's
32
+ * production bundle never references this subpath, so the import tree stays
33
+ * clean under tree-shaking.
34
+ */
35
+ const ACTOR = {
36
+ ref: "user:contract",
37
+ kind: "user"
38
+ };
39
+ function plan(recipeId) {
40
+ return {
41
+ recipeId,
42
+ parameters: {},
43
+ items: [{
44
+ resource: "orders",
45
+ estimated: 1
46
+ }],
47
+ retains: [],
48
+ blockers: [],
49
+ rebuildActions: [],
50
+ warnings: [],
51
+ estimatedTotal: 1,
52
+ confirmationPhrase: recipeId,
53
+ digest: `digest-${recipeId}`
54
+ };
55
+ }
56
+ /** A queued run. `concurrencyKey` is opt-in so key-free runs can be exercised. */
57
+ function makeRun(id, overrides = {}) {
58
+ return {
59
+ id,
60
+ recipeId: "contract.recipe",
61
+ recipeVersion: "1",
62
+ status: "queued",
63
+ planDigest: `digest-${id}`,
64
+ sealedPlan: plan("contract.recipe"),
65
+ parameters: {},
66
+ actor: ACTOR,
67
+ requestedBy: ACTOR.ref,
68
+ reason: "contract suite",
69
+ operationId: `op-${id}`,
70
+ destructive: true,
71
+ attempt: 0,
72
+ progress: {
73
+ processed: 0,
74
+ steps: 0
75
+ },
76
+ cancelRequested: false,
77
+ queuedAt: /* @__PURE__ */ new Date(),
78
+ ...overrides
79
+ };
80
+ }
81
+ const future = (ms = 6e4) => new Date(Date.now() + ms);
82
+ const past = (ms = 6e4) => new Date(Date.now() - ms);
83
+ function runCleanupRunStoreContract(name, setup) {
84
+ describe(`CleanupRunStore contract: ${name}`, () => {
85
+ let store;
86
+ let teardown;
87
+ let seq = 0;
88
+ const uid = (label) => `${label}-${Date.now()}-${seq++}`;
89
+ beforeAll(async () => {
90
+ const result = await setup();
91
+ store = result.store;
92
+ teardown = result.teardown;
93
+ });
94
+ afterAll(async () => {
95
+ if (teardown) await teardown();
96
+ });
97
+ describe("createIfPermitted — admission control", () => {
98
+ it("inserts a run and makes it readable", async () => {
99
+ const run = makeRun(uid("insert"), { concurrencyKey: void 0 });
100
+ expect(await store.createIfPermitted(run)).toEqual({ created: true });
101
+ const stored = await store.get(run.id);
102
+ expect(stored?.id).toBe(run.id);
103
+ expect(stored?.status).toBe("queued");
104
+ });
105
+ it("returns null from get() for an unknown id", async () => {
106
+ expect(await store.get(uid("missing"))).toBeNull();
107
+ });
108
+ it("refuses a second non-terminal run holding the same concurrencyKey", async () => {
109
+ const key = uid("key");
110
+ const first = makeRun(uid("first"), { concurrencyKey: key });
111
+ const second = makeRun(uid("second"), { concurrencyKey: key });
112
+ expect(await store.createIfPermitted(first)).toEqual({ created: true });
113
+ const result = await store.createIfPermitted(second);
114
+ expect(result.created).toBe(false);
115
+ if (result.created === false) expect(result.activeRunId).toBe(first.id);
116
+ expect(await store.get(second.id)).toBeNull();
117
+ });
118
+ it("admits the key again once the holder reaches a terminal status", async () => {
119
+ const key = uid("key-free");
120
+ const first = makeRun(uid("holder"), { concurrencyKey: key });
121
+ await store.createIfPermitted(first);
122
+ await store.compareAndTransition(first.id, ["queued"], "completed");
123
+ const next = makeRun(uid("next"), { concurrencyKey: key });
124
+ expect(await store.createIfPermitted(next)).toEqual({ created: true });
125
+ });
126
+ it("never serializes runs that carry no concurrencyKey", async () => {
127
+ const a = makeRun(uid("keyless-a"), { concurrencyKey: void 0 });
128
+ const b = makeRun(uid("keyless-b"), { concurrencyKey: void 0 });
129
+ expect(await store.createIfPermitted(a)).toEqual({ created: true });
130
+ expect(await store.createIfPermitted(b)).toEqual({ created: true });
131
+ });
132
+ });
133
+ describe("claim — exclusive worker lease", () => {
134
+ it("grants the lease on a queued run and counts the attempt", async () => {
135
+ const run = makeRun(uid("claim"), { concurrencyKey: void 0 });
136
+ await store.createIfPermitted(run);
137
+ const token = uid("token");
138
+ const claimed = await store.claim(run.id, {
139
+ token,
140
+ expiresAt: future()
141
+ });
142
+ expect(claimed).not.toBeNull();
143
+ expect(claimed?.leaseToken).toBe(token);
144
+ expect(claimed?.attempt).toBe(run.attempt + 1);
145
+ });
146
+ it("refuses a run whose lease is still live (another worker owns it)", async () => {
147
+ const run = makeRun(uid("owned"), { concurrencyKey: void 0 });
148
+ await store.createIfPermitted(run);
149
+ await store.claim(run.id, {
150
+ token: uid("owner"),
151
+ expiresAt: future()
152
+ });
153
+ await store.compareAndTransition(run.id, ["queued"], "running");
154
+ expect(await store.claim(run.id, {
155
+ token: uid("thief"),
156
+ expiresAt: future()
157
+ })).toBeNull();
158
+ });
159
+ it("recovers a running run whose lease EXPIRED (crash recovery)", async () => {
160
+ const run = makeRun(uid("crashed"), { concurrencyKey: void 0 });
161
+ await store.createIfPermitted(run);
162
+ await store.claim(run.id, {
163
+ token: uid("dead"),
164
+ expiresAt: past()
165
+ });
166
+ await store.compareAndTransition(run.id, ["queued"], "running");
167
+ const token = uid("recovered");
168
+ const reclaimed = await store.claim(run.id, {
169
+ token,
170
+ expiresAt: future()
171
+ });
172
+ expect(reclaimed).not.toBeNull();
173
+ expect(reclaimed?.leaseToken).toBe(token);
174
+ });
175
+ it("recovers a finalizing run whose lease expired", async () => {
176
+ const run = makeRun(uid("finalizing"), { concurrencyKey: void 0 });
177
+ await store.createIfPermitted(run);
178
+ await store.claim(run.id, {
179
+ token: uid("dead"),
180
+ expiresAt: past()
181
+ });
182
+ await store.compareAndTransition(run.id, ["queued"], "running");
183
+ await store.compareAndTransition(run.id, ["running"], "finalizing");
184
+ const reclaimed = await store.claim(run.id, {
185
+ token: uid("recovered"),
186
+ expiresAt: future()
187
+ });
188
+ expect(reclaimed).not.toBeNull();
189
+ expect(reclaimed?.status).toBe("finalizing");
190
+ });
191
+ it("refuses a terminal run", async () => {
192
+ const run = makeRun(uid("terminal"), { concurrencyKey: void 0 });
193
+ await store.createIfPermitted(run);
194
+ await store.compareAndTransition(run.id, ["queued"], "completed");
195
+ expect(await store.claim(run.id, {
196
+ token: uid("t"),
197
+ expiresAt: future()
198
+ })).toBeNull();
199
+ });
200
+ it("refuses an unknown run", async () => {
201
+ expect(await store.claim(uid("ghost"), {
202
+ token: "t",
203
+ expiresAt: future()
204
+ })).toBeNull();
205
+ });
206
+ });
207
+ describe("compareAndTransition — guarded status change", () => {
208
+ it("applies the status and the patch when the current status matches", async () => {
209
+ const run = makeRun(uid("cas"), { concurrencyKey: void 0 });
210
+ await store.createIfPermitted(run);
211
+ const started = /* @__PURE__ */ new Date();
212
+ const updated = await store.compareAndTransition(run.id, ["queued"], "running", { startedAt: started });
213
+ expect(updated?.status).toBe("running");
214
+ expect(updated?.startedAt?.getTime()).toBe(started.getTime());
215
+ expect((await store.get(run.id))?.status).toBe("running");
216
+ });
217
+ it("refuses when the current status is not in `expected`", async () => {
218
+ const run = makeRun(uid("wrong-state"), { concurrencyKey: void 0 });
219
+ await store.createIfPermitted(run);
220
+ expect(await store.compareAndTransition(run.id, ["running"], "completed")).toBeNull();
221
+ expect((await store.get(run.id))?.status).toBe("queued");
222
+ });
223
+ it("refuses a stale ex-owner's write (lease-token mismatch)", async () => {
224
+ const run = makeRun(uid("stale"), { concurrencyKey: void 0 });
225
+ await store.createIfPermitted(run);
226
+ await store.claim(run.id, {
227
+ token: uid("dead"),
228
+ expiresAt: past()
229
+ });
230
+ await store.compareAndTransition(run.id, ["queued"], "running");
231
+ const live = uid("live");
232
+ await store.claim(run.id, {
233
+ token: live,
234
+ expiresAt: future()
235
+ });
236
+ expect(await store.compareAndTransition(run.id, ["running"], "completed", { completedAt: /* @__PURE__ */ new Date() }, "a-stale-token")).toBeNull();
237
+ expect((await store.get(run.id))?.status).toBe("running");
238
+ expect((await store.compareAndTransition(run.id, ["running"], "completed", void 0, live))?.status).toBe("completed");
239
+ });
240
+ });
241
+ describe("requestCancel — durable cancellation flag", () => {
242
+ it("sets the flag with audit metadata WITHOUT changing status", async () => {
243
+ const run = makeRun(uid("cancel"), { concurrencyKey: void 0 });
244
+ await store.createIfPermitted(run);
245
+ await store.compareAndTransition(run.id, ["queued"], "running");
246
+ const requestedAt = /* @__PURE__ */ new Date();
247
+ await store.requestCancel(run.id, {
248
+ actor: ACTOR,
249
+ reason: "operator changed their mind",
250
+ requestedAt
251
+ });
252
+ const stored = await store.get(run.id);
253
+ expect(stored?.cancelRequested).toBe(true);
254
+ expect(stored?.cancelReason).toBe("operator changed their mind");
255
+ expect(stored?.cancelRequestedBy?.ref).toBe(ACTOR.ref);
256
+ expect(stored?.status).toBe("running");
257
+ });
258
+ it("is idempotent — the FIRST request's audit metadata wins", async () => {
259
+ const run = makeRun(uid("cancel-twice"), { concurrencyKey: void 0 });
260
+ await store.createIfPermitted(run);
261
+ await store.requestCancel(run.id, {
262
+ actor: ACTOR,
263
+ reason: "first",
264
+ requestedAt: /* @__PURE__ */ new Date()
265
+ });
266
+ await store.requestCancel(run.id, {
267
+ actor: {
268
+ ref: "user:other",
269
+ kind: "user"
270
+ },
271
+ reason: "second",
272
+ requestedAt: /* @__PURE__ */ new Date()
273
+ });
274
+ const stored = await store.get(run.id);
275
+ expect(stored?.cancelReason).toBe("first");
276
+ expect(stored?.cancelRequestedBy?.ref).toBe(ACTOR.ref);
277
+ });
278
+ });
279
+ describe("saveProgress — heartbeat + bounded summary", () => {
280
+ it("updates progress and extends the lease for the current owner", async () => {
281
+ const run = makeRun(uid("progress"), { concurrencyKey: void 0 });
282
+ await store.createIfPermitted(run);
283
+ const token = uid("owner");
284
+ await store.claim(run.id, {
285
+ token,
286
+ expiresAt: future(1e3)
287
+ });
288
+ const renewed = future(12e4);
289
+ await store.saveProgress(run.id, {
290
+ processed: 7,
291
+ steps: 2
292
+ }, {
293
+ token,
294
+ expiresAt: renewed
295
+ });
296
+ const stored = await store.get(run.id);
297
+ expect(stored?.progress.processed).toBe(7);
298
+ expect(stored?.progress.steps).toBe(2);
299
+ expect(stored?.leaseExpiresAt?.getTime()).toBe(renewed.getTime());
300
+ });
301
+ it("ignores a write from a stale ex-owner", async () => {
302
+ const run = makeRun(uid("progress-stale"), { concurrencyKey: void 0 });
303
+ await store.createIfPermitted(run);
304
+ const live = uid("live");
305
+ await store.claim(run.id, {
306
+ token: live,
307
+ expiresAt: future()
308
+ });
309
+ await store.saveProgress(run.id, {
310
+ processed: 5,
311
+ steps: 1
312
+ }, {
313
+ token: live,
314
+ expiresAt: future()
315
+ });
316
+ await store.saveProgress(run.id, {
317
+ processed: 999,
318
+ steps: 999
319
+ }, {
320
+ token: "a-stale-token",
321
+ expiresAt: future()
322
+ });
323
+ expect((await store.get(run.id))?.progress.processed).toBe(5);
324
+ });
325
+ });
326
+ describe("reArmIfPermitted — retry under the creation admission policy", () => {
327
+ it("re-queues a failed run and clears cancel + lease + finalization state", async () => {
328
+ const run = makeRun(uid("rearm"), { concurrencyKey: void 0 });
329
+ await store.createIfPermitted(run);
330
+ const token = uid("owner");
331
+ await store.claim(run.id, {
332
+ token,
333
+ expiresAt: future()
334
+ });
335
+ await store.compareAndTransition(run.id, ["queued"], "running", void 0, token);
336
+ await store.requestCancel(run.id, {
337
+ actor: ACTOR,
338
+ reason: "stop",
339
+ requestedAt: /* @__PURE__ */ new Date()
340
+ });
341
+ await store.compareAndTransition(run.id, ["running"], "failed", void 0, token);
342
+ const requeued = await store.reArmIfPermitted(run.id, {
343
+ queuedAt: /* @__PURE__ */ new Date(),
344
+ cancelRequested: false
345
+ });
346
+ expect(requeued?.status).toBe("queued");
347
+ expect(requeued?.cancelRequested).toBe(false);
348
+ expect(requeued?.leaseToken).toBeUndefined();
349
+ expect(requeued?.finalization).toBeUndefined();
350
+ });
351
+ it("re-queues a cancelled run", async () => {
352
+ const run = makeRun(uid("rearm-cancelled"), { concurrencyKey: void 0 });
353
+ await store.createIfPermitted(run);
354
+ await store.compareAndTransition(run.id, ["queued"], "cancelled");
355
+ expect((await store.reArmIfPermitted(run.id, { queuedAt: /* @__PURE__ */ new Date() }))?.status).toBe("queued");
356
+ });
357
+ it("refuses a run that is not in a terminal status", async () => {
358
+ const run = makeRun(uid("rearm-live"), { concurrencyKey: void 0 });
359
+ await store.createIfPermitted(run);
360
+ expect(await store.reArmIfPermitted(run.id, { queuedAt: /* @__PURE__ */ new Date() })).toBeNull();
361
+ });
362
+ it("refuses when another non-terminal run holds the same concurrencyKey", async () => {
363
+ const key = uid("rearm-key");
364
+ const failed = makeRun(uid("rearm-blocked"), { concurrencyKey: key });
365
+ await store.createIfPermitted(failed);
366
+ await store.compareAndTransition(failed.id, ["queued"], "failed");
367
+ const active = makeRun(uid("rearm-active"), { concurrencyKey: key });
368
+ expect(await store.createIfPermitted(active)).toEqual({ created: true });
369
+ expect(await store.reArmIfPermitted(failed.id, { queuedAt: /* @__PURE__ */ new Date() })).toBeNull();
370
+ });
371
+ });
372
+ });
373
+ }
374
+ //#endregion
375
+ export { runCleanupRunStoreContract };
@@ -1,7 +1,8 @@
1
1
  import { V as ResourceDefinition } from "../index-DPRH0Cme.mjs";
2
2
  import { c as AnyRecord } from "../types-Dmw-6Qeo.mjs";
3
- import { d as ArcModuleInput } from "../index-KUoxjIq1.mjs";
4
- import { n as preloadResourcesAsync, r as CreateAppOptions, t as preloadResources } from "../preloadResources-0oe-JSSS.mjs";
3
+ import { n as ArcModuleInput } from "../types-Cu6vVJmh.mjs";
4
+ import "../index-CZY4EGga.mjs";
5
+ import { n as preloadResourcesAsync, r as CreateAppOptions, t as preloadResources } from "../preloadResources-CM4uUaFv.mjs";
5
6
  import { StorageContractSetup, StorageContractSetupResult, runStorageContract } from "./storageContract.mjs";
6
7
  import { FastifyInstance, FastifyServerOptions } from "fastify";
7
8
  import { Mock } from "vitest";
@@ -1,5 +1,7 @@
1
1
  import { t as CRUD_OPERATIONS } from "../constants-DNre0wRg.mjs";
2
- import { i as createApp, n as preloadResourcesAsync, p as getModuleExports, t as preloadResources } from "../preloadResources-DfyngIN9.mjs";
2
+ import { n as createApp } from "../createApp-Du5G54TM.mjs";
3
+ import { n as getModuleExports } from "../module-_-M7C5ES.mjs";
4
+ import { n as preloadResourcesAsync, t as preloadResources } from "../preloadResources-DSY5HGpS.mjs";
3
5
  import { runStorageContract } from "./storageContract.mjs";
4
6
  import Fastify from "fastify";
5
7
  import { afterAll, describe, expect, it, vi } from "vitest";
@@ -1116,7 +1118,7 @@ function pickDefaultAuth(authMode, callerAuth) {
1116
1118
  };
1117
1119
  }
1118
1120
  async function createTestApp(options = {}) {
1119
- const { createApp } = await import("../preloadResources-DfyngIN9.mjs").then((n) => n.a);
1121
+ const { createApp } = await import("../createApp-Du5G54TM.mjs").then((n) => n.r);
1120
1122
  const { resources = [], db = "in-memory", connectMongoose = false, authMode = "jwt", defaultOrgId, plugins, auth: callerAuth, ...appOptions } = options;
1121
1123
  let dbHandle;
1122
1124
  let dbUri;
@@ -0,0 +1,13 @@
1
+ import { OutboxStore } from "@classytic/primitives/outbox";
2
+ //#region src/testing/outboxStoreContract.d.ts
3
+ interface OutboxStoreContractSetupResult {
4
+ store: OutboxStore;
5
+ /** Called once after the suite. */
6
+ teardown?: () => Promise<void>;
7
+ /** Empty the store between tests. Strongly recommended for isolation. */
8
+ reset?: () => Promise<void>;
9
+ }
10
+ type OutboxStoreContractSetup = () => Promise<OutboxStoreContractSetupResult>;
11
+ declare function runOutboxStoreContract(name: string, setup: OutboxStoreContractSetup): void;
12
+ //#endregion
13
+ export { OutboxStoreContractSetup, OutboxStoreContractSetupResult, runOutboxStoreContract };
@@ -0,0 +1,247 @@
1
+ import { OutboxOwnershipError } from "@classytic/primitives/outbox";
2
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
3
+ //#region src/testing/outboxStoreContract.ts
4
+ let seq = 0;
5
+ /** A minimal well-formed event. Ids are unique per call. */
6
+ function event(type = "test.event") {
7
+ seq += 1;
8
+ return {
9
+ type,
10
+ payload: { n: seq },
11
+ meta: {
12
+ id: `evt-${Date.now()}-${seq}`,
13
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString()
14
+ }
15
+ };
16
+ }
17
+ const idOf = (e) => e.meta.id;
18
+ function runOutboxStoreContract(name, setup) {
19
+ describe(`OutboxStore contract: ${name}`, () => {
20
+ let store;
21
+ let teardown;
22
+ let reset;
23
+ beforeAll(async () => {
24
+ const result = await setup();
25
+ store = result.store;
26
+ teardown = result.teardown;
27
+ reset = result.reset;
28
+ });
29
+ afterAll(async () => {
30
+ if (teardown) await teardown();
31
+ });
32
+ /** Save n events, oldest first. */
33
+ async function seed(n, type) {
34
+ if (reset) await reset();
35
+ const events = [];
36
+ for (let i = 0; i < n; i++) {
37
+ const e = event(type);
38
+ await store.save(e);
39
+ events.push(e);
40
+ }
41
+ return events;
42
+ }
43
+ describe("save — rejects malformed events (invariant 1)", () => {
44
+ it("persists a well-formed event", async () => {
45
+ const [e] = await seed(1);
46
+ expect((await store.getPending(10)).map(idOf)).toContain(idOf(e));
47
+ });
48
+ it("THROWS rather than persisting an event with no `type`", async () => {
49
+ if (reset) await reset();
50
+ await expect(store.save({
51
+ payload: {},
52
+ meta: { id: "no-type" }
53
+ })).rejects.toThrow();
54
+ expect((await store.getPending(10)).length).toBe(0);
55
+ });
56
+ it("THROWS rather than persisting an event with no `meta.id`", async () => {
57
+ if (reset) await reset();
58
+ await expect(store.save({
59
+ type: "x",
60
+ payload: {},
61
+ meta: {}
62
+ })).rejects.toThrow();
63
+ expect((await store.getPending(10)).length).toBe(0);
64
+ });
65
+ });
66
+ describe("getPending", () => {
67
+ it("returns events FIFO (oldest first)", async () => {
68
+ const saved = await seed(3);
69
+ expect((await store.getPending(10)).slice(0, 3).map(idOf)).toEqual(saved.map(idOf));
70
+ });
71
+ it("honours the limit", async () => {
72
+ await seed(3);
73
+ expect((await store.getPending(2)).length).toBeLessThanOrEqual(2);
74
+ });
75
+ it("returns only well-formed events (invariant 6)", async () => {
76
+ await seed(2);
77
+ for (const e of await store.getPending(10)) {
78
+ expect(typeof e.type).toBe("string");
79
+ expect(e.type.length).toBeGreaterThan(0);
80
+ expect(idOf(e)).toBeTruthy();
81
+ }
82
+ });
83
+ });
84
+ describe("claimPending — exclusive lease (invariant 2)", () => {
85
+ it("claims pending events", async ({ skip }) => {
86
+ if (!store.claimPending) return skip();
87
+ await seed(2);
88
+ expect((await store.claimPending({
89
+ consumerId: "c1",
90
+ limit: 10
91
+ })).length).toBe(2);
92
+ });
93
+ it("two concurrent claimers NEVER receive the same event", async ({ skip }) => {
94
+ if (!store.claimPending) return skip();
95
+ await seed(6);
96
+ const [a, b] = await Promise.all([store.claimPending({
97
+ consumerId: "c1",
98
+ limit: 6
99
+ }), store.claimPending({
100
+ consumerId: "c2",
101
+ limit: 6
102
+ })]);
103
+ const idsA = a.map(idOf);
104
+ const idsB = b.map(idOf);
105
+ expect(idsA.filter((id) => idsB.includes(id))).toEqual([]);
106
+ expect(idsA.length + idsB.length).toBeLessThanOrEqual(6);
107
+ });
108
+ it("does not re-claim an event whose lease is still live", async ({ skip }) => {
109
+ if (!store.claimPending) return skip();
110
+ await seed(2);
111
+ await store.claimPending({
112
+ consumerId: "c1",
113
+ limit: 10,
114
+ leaseMs: 6e4
115
+ });
116
+ expect(await store.claimPending({
117
+ consumerId: "c2",
118
+ limit: 10
119
+ })).toEqual([]);
120
+ });
121
+ it("RECLAIMS an event whose lease expired (crashed worker)", async ({ skip }) => {
122
+ if (!store.claimPending) return skip();
123
+ await seed(1);
124
+ await store.claimPending({
125
+ consumerId: "dead",
126
+ limit: 10,
127
+ leaseMs: 0
128
+ });
129
+ expect((await store.claimPending({
130
+ consumerId: "c2",
131
+ limit: 10
132
+ })).length).toBe(1);
133
+ });
134
+ it("claims FIFO", async ({ skip }) => {
135
+ if (!store.claimPending) return skip();
136
+ const saved = await seed(3);
137
+ expect((await store.claimPending({
138
+ consumerId: "c1",
139
+ limit: 2
140
+ })).map(idOf)).toEqual(saved.slice(0, 2).map(idOf));
141
+ });
142
+ });
143
+ describe("acknowledge", () => {
144
+ it("removes the event from pending", async () => {
145
+ const [e] = await seed(1);
146
+ await store.acknowledge(idOf(e));
147
+ expect((await store.getPending(10)).map(idOf)).not.toContain(idOf(e));
148
+ });
149
+ it("is a NO-OP for an unknown id — never an ownership error (invariant 4)", async () => {
150
+ if (reset) await reset();
151
+ await expect(store.acknowledge("does-not-exist")).resolves.not.toThrow();
152
+ });
153
+ it("THROWS OutboxOwnershipError when consumerId is not the lease owner (invariant 3)", async ({ skip }) => {
154
+ if (!store.claimPending) return skip();
155
+ await seed(1);
156
+ const [claimed] = await store.claimPending({
157
+ consumerId: "owner",
158
+ limit: 1,
159
+ leaseMs: 6e4
160
+ });
161
+ if (!claimed) return skip();
162
+ await expect(store.acknowledge(idOf(claimed), { consumerId: "impostor" })).rejects.toBeInstanceOf(OutboxOwnershipError);
163
+ });
164
+ it("succeeds for the rightful lease owner", async ({ skip }) => {
165
+ if (!store.claimPending) return skip();
166
+ await seed(1);
167
+ const [claimed] = await store.claimPending({
168
+ consumerId: "owner",
169
+ limit: 1,
170
+ leaseMs: 6e4
171
+ });
172
+ if (!claimed) return skip();
173
+ await expect(store.acknowledge(idOf(claimed), { consumerId: "owner" })).resolves.not.toThrow();
174
+ });
175
+ });
176
+ describe("fail — deterministic lease + visibility (invariant 5)", () => {
177
+ it("makes the event re-claimable again (lease cleared)", async ({ skip }) => {
178
+ if (!store.fail || !store.claimPending) return skip();
179
+ await seed(1);
180
+ const [claimed] = await store.claimPending({
181
+ consumerId: "c1",
182
+ limit: 1,
183
+ leaseMs: 6e4
184
+ });
185
+ if (!claimed) return skip();
186
+ await store.fail(idOf(claimed), { message: "boom" });
187
+ expect((await store.claimPending({
188
+ consumerId: "c2",
189
+ limit: 10
190
+ })).map(idOf)).toContain(idOf(claimed));
191
+ });
192
+ it("THROWS OutboxOwnershipError on a consumerId mismatch (invariant 3)", async ({ skip }) => {
193
+ if (!store.fail || !store.claimPending) return skip();
194
+ await seed(1);
195
+ const [claimed] = await store.claimPending({
196
+ consumerId: "owner",
197
+ limit: 1,
198
+ leaseMs: 6e4
199
+ });
200
+ if (!claimed) return skip();
201
+ await expect(store.fail(idOf(claimed), { message: "boom" }, { consumerId: "impostor" })).rejects.toBeInstanceOf(OutboxOwnershipError);
202
+ });
203
+ it("honours `retryAt` — the event is NOT immediately re-claimable", async ({ skip }) => {
204
+ if (!store.fail || !store.claimPending) return skip();
205
+ await seed(1);
206
+ const [claimed] = await store.claimPending({
207
+ consumerId: "c1",
208
+ limit: 1,
209
+ leaseMs: 6e4
210
+ });
211
+ if (!claimed) return skip();
212
+ await store.fail(idOf(claimed), { message: "boom" }, { retryAt: new Date(Date.now() + 6e4) });
213
+ expect((await store.claimPending({
214
+ consumerId: "c2",
215
+ limit: 10
216
+ })).map(idOf)).not.toContain(idOf(claimed));
217
+ });
218
+ it("`deadLetter: true` removes it from the pending stream", async ({ skip }) => {
219
+ if (!store.fail) return skip();
220
+ const [e] = await seed(1);
221
+ await store.fail(idOf(e), { message: "fatal" }, { deadLetter: true });
222
+ expect((await store.getPending(10)).map(idOf)).not.toContain(idOf(e));
223
+ });
224
+ });
225
+ describe("getDeadLettered", () => {
226
+ it("returns dead-lettered events with their failure metadata", async ({ skip }) => {
227
+ if (!store.fail || !store.getDeadLettered) return skip();
228
+ const [e] = await seed(1);
229
+ await store.fail(idOf(e), { message: "fatal" }, { deadLetter: true });
230
+ const row = (await store.getDeadLettered(10)).find((d) => idOf(d.event) === idOf(e));
231
+ expect(row).toBeDefined();
232
+ expect(row?.error).toBeDefined();
233
+ expect(typeof row?.attempts).toBe("number");
234
+ });
235
+ });
236
+ describe("purge", () => {
237
+ it("NEVER removes pending events — delivered-only scope", async ({ skip }) => {
238
+ if (!store.purge) return skip();
239
+ await seed(2);
240
+ await store.purge(0);
241
+ expect((await store.getPending(10)).length).toBe(2);
242
+ });
243
+ });
244
+ });
245
+ }
246
+ //#endregion
247
+ export { runOutboxStoreContract };