@iterativeflow/conformance 2.0.0-alpha.1

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.
@@ -0,0 +1,69 @@
1
+ import { Backend, Queue, Store, Timer, Wakeup } from "@iterativeflow/core/backend";
2
+ //#region src/store.d.ts
3
+ /**
4
+ * The Store contract as executable invariants. EVERY backend (in-memory, Postgres,
5
+ * DynamoDB) is run against this exact suite — a backend is not "done" until green here.
6
+ * The guarantee is a test, not a comment.
7
+ */
8
+ declare const storeConformance: (label: string, makeStore: () => Store | Promise<Store>) => void;
9
+ //#endregion
10
+ //#region src/queue.d.ts
11
+ /**
12
+ * The Queue contract as executable invariants. The universal lease-CAS backend and a
13
+ * Postgres `SKIP LOCKED` backend must both satisfy exactly these — leasing is exclusive,
14
+ * leases expire and re-claim (crash recovery), heartbeats extend, and a stale ack is a
15
+ * no-op. Clock is injected so the suite is deterministic on any backend.
16
+ */
17
+ declare const queueConformance: (label: string, makeQueue: () => Queue | Promise<Queue>) => void;
18
+ //#endregion
19
+ //#region src/timer.d.ts
20
+ /**
21
+ * The Timer contract as executable invariants — durable deadlines that fire exactly once,
22
+ * earliest first, with upsert-reschedule and cancel. Clock injected for determinism.
23
+ */
24
+ declare const timerConformance: (label: string, makeTimer: () => Timer | Promise<Timer>) => void;
25
+ //#endregion
26
+ //#region src/wakeup.d.ts
27
+ /**
28
+ * The Wakeup contract as executable invariants: a signal wakes current waiters early, a
29
+ * missing signal still resolves on timeout (the poll tick), and a signal for another run
30
+ * doesn't wake unrelated waiters.
31
+ */
32
+ declare const wakeupConformance: (label: string, makeWakeup: () => Wakeup | Promise<Wakeup>) => void;
33
+ //#endregion
34
+ //#region src/outbox.d.ts
35
+ /**
36
+ * The transactional-outbox seam as executable invariants: a durable Store write and the
37
+ * side-effects it carries commit together, and — critically — the outbox fires exactly
38
+ * ONCE even when the write is replayed. Every backend (memory, Postgres, DynamoDB) must
39
+ * satisfy this; it is the property that makes crash recovery correct rather than just
40
+ * plausible.
41
+ */
42
+ declare const outboxConformance: (label: string, makeBackend: () => Backend | Promise<Backend>) => void;
43
+ //#endregion
44
+ //#region src/signal.d.ts
45
+ /**
46
+ * The durable signal inbox as executable invariants: a delivered signal lands in `loadRun`,
47
+ * wakes the run (re-enqueue), is idempotent on its key, and is consumed exactly once by a
48
+ * checkpoint's `consumeSignals` outbox.
49
+ */
50
+ declare const signalConformance: (label: string, makeBackend: () => Backend | Promise<Backend>) => void;
51
+ //#endregion
52
+ //#region src/reconcile.d.ts
53
+ /**
54
+ * `orphanedRuns` as executable invariants — what the reconciler re-drives. A run is an orphan
55
+ * only when it truly can't make progress on its own: non-terminal but off the queue with no
56
+ * pending timer, OR an `awaiting_signal` parent whose child already finished (lost wake). A
57
+ * run that is queued, timing, terminal, or legitimately waiting on an external signal is NOT.
58
+ */
59
+ declare const reconcileConformance: (label: string, makeBackend: () => Backend | Promise<Backend>) => void;
60
+ //#endregion
61
+ //#region src/cron.d.ts
62
+ /**
63
+ * The cron store surface as executable invariants: upsert keeps timing on re-register,
64
+ * `dueCrons` respects `nextRunAt`, and `advanceCron` is a CAS so exactly one worker fires an
65
+ * occurrence (the double-fire guard on a fleet).
66
+ */
67
+ declare const cronConformance: (label: string, makeStore: () => Store | Promise<Store>) => void;
68
+ //#endregion
69
+ export { cronConformance, outboxConformance, queueConformance, reconcileConformance, signalConformance, storeConformance, timerConformance, wakeupConformance };
package/dist/index.mjs ADDED
@@ -0,0 +1,1303 @@
1
+ import { describe, expect, it } from "vitest";
2
+ //#region src/store.ts
3
+ /**
4
+ * The Store contract as executable invariants. EVERY backend (in-memory, Postgres,
5
+ * DynamoDB) is run against this exact suite — a backend is not "done" until green here.
6
+ * The guarantee is a test, not a comment.
7
+ */
8
+ const storeConformance = (label, makeStore) => {
9
+ describe(`Store conformance — ${label}`, () => {
10
+ it("startRun creates a fresh pending run", async () => {
11
+ const s = await makeStore();
12
+ const r = await s.startRun({
13
+ name: "f",
14
+ version: 1,
15
+ input: { a: 1 }
16
+ });
17
+ expect(r.created).toBe(true);
18
+ expect(r.status).toBe("pending");
19
+ const snap = await s.loadRun(r.runId);
20
+ expect(snap?.run.status).toBe("pending");
21
+ expect(snap?.run.input).toEqual({ a: 1 });
22
+ expect(snap?.run.attempts).toBe(0);
23
+ });
24
+ it("startManyRuns creates the whole batch, results aligned with the specs", async () => {
25
+ const s = await makeStore();
26
+ const specs = [
27
+ {
28
+ name: "f",
29
+ version: 1,
30
+ input: { i: 0 }
31
+ },
32
+ {
33
+ name: "g",
34
+ version: 2,
35
+ input: { i: 1 }
36
+ },
37
+ {
38
+ name: "h",
39
+ version: 1,
40
+ input: { i: 2 }
41
+ }
42
+ ];
43
+ const results = await s.startManyRuns(specs);
44
+ expect(results).toHaveLength(3);
45
+ expect(results.every((r) => r.created)).toBe(true);
46
+ for (let i = 0; i < specs.length; i++) {
47
+ const snap = await s.loadRun(results[i].runId);
48
+ expect(snap?.run.input).toEqual({ i });
49
+ expect(snap?.run.name).toBe(specs[i].name);
50
+ }
51
+ });
52
+ it("startManyRuns honours per-spec idempotency — a batch can mix created and existing", async () => {
53
+ const s = await makeStore();
54
+ const first = await s.startRun({
55
+ name: "f",
56
+ version: 1,
57
+ input: {},
58
+ idempotencyKey: "dup"
59
+ });
60
+ const results = await s.startManyRuns([{
61
+ name: "f",
62
+ version: 1,
63
+ input: {},
64
+ idempotencyKey: "dup"
65
+ }, {
66
+ name: "f",
67
+ version: 1,
68
+ input: {},
69
+ idempotencyKey: "new"
70
+ }]);
71
+ expect(results[0]).toMatchObject({
72
+ runId: first.runId,
73
+ created: false
74
+ });
75
+ expect(results[1].created).toBe(true);
76
+ expect(results[1].runId).not.toBe(first.runId);
77
+ });
78
+ it("startRun is idempotent on the idempotency key — no duplicate run", async () => {
79
+ const s = await makeStore();
80
+ const a = await s.startRun({
81
+ name: "f",
82
+ version: 1,
83
+ input: {},
84
+ idempotencyKey: "k"
85
+ });
86
+ const b = await s.startRun({
87
+ name: "f",
88
+ version: 1,
89
+ input: {},
90
+ idempotencyKey: "k"
91
+ });
92
+ expect(a.created).toBe(true);
93
+ expect(b.created).toBe(false);
94
+ expect(b.runId).toBe(a.runId);
95
+ });
96
+ it("idempotency key is scoped by (name, version)", async () => {
97
+ const s = await makeStore();
98
+ const a = await s.startRun({
99
+ name: "f",
100
+ version: 1,
101
+ input: {},
102
+ idempotencyKey: "k"
103
+ });
104
+ expect((await s.startRun({
105
+ name: "f",
106
+ version: 2,
107
+ input: {},
108
+ idempotencyKey: "k"
109
+ })).runId).not.toBe(a.runId);
110
+ });
111
+ it("loadRun returns undefined for an unknown run", async () => {
112
+ expect(await (await makeStore()).loadRun("00000000-0000-0000-0000-000000000000")).toBeUndefined();
113
+ });
114
+ it("checkpointStep persists a completed step into the memo", async () => {
115
+ const s = await makeStore();
116
+ const { runId } = await s.startRun({
117
+ name: "f",
118
+ version: 1,
119
+ input: {}
120
+ });
121
+ await s.checkpointStep({
122
+ runId,
123
+ cursorKey: "a",
124
+ status: "ok",
125
+ result: 42,
126
+ attempts: 1
127
+ });
128
+ expect((await s.loadRun(runId))?.steps.get("a")).toMatchObject({
129
+ status: "ok",
130
+ result: 42,
131
+ attempts: 1
132
+ });
133
+ });
134
+ it("checkpointStep is first-writer-wins — a second write does NOT overwrite (exactly-once memo)", async () => {
135
+ const s = await makeStore();
136
+ const { runId } = await s.startRun({
137
+ name: "f",
138
+ version: 1,
139
+ input: {}
140
+ });
141
+ const first = await s.checkpointStep({
142
+ runId,
143
+ cursorKey: "a",
144
+ status: "ok",
145
+ result: "first",
146
+ attempts: 1
147
+ });
148
+ const second = await s.checkpointStep({
149
+ runId,
150
+ cursorKey: "a",
151
+ status: "ok",
152
+ result: "SECOND",
153
+ attempts: 2
154
+ });
155
+ expect(first.result).toBe("first");
156
+ expect(second.result).toBe("first");
157
+ expect((await s.loadRun(runId))?.steps.get("a")?.result).toBe("first");
158
+ });
159
+ it("markRunning increments attempts (bounded retries survive crashes)", async () => {
160
+ const s = await makeStore();
161
+ const { runId } = await s.startRun({
162
+ name: "f",
163
+ version: 1,
164
+ input: {}
165
+ });
166
+ expect(await s.markRunning(runId)).toBe(1);
167
+ expect(await s.markRunning(runId)).toBe(2);
168
+ expect((await s.loadRun(runId))?.run.attempts).toBe(2);
169
+ });
170
+ it("markRunning never resurrects a terminal run (a late re-dispatch can't undo a cancel)", async () => {
171
+ const s = await makeStore();
172
+ const { runId } = await s.startRun({
173
+ name: "f",
174
+ version: 1,
175
+ input: {}
176
+ });
177
+ await s.markTerminal(runId, { status: "canceled" });
178
+ await s.markRunning(runId);
179
+ expect((await s.loadRun(runId))?.run.status).toBe("canceled");
180
+ });
181
+ it("markTerminal sets status + output", async () => {
182
+ const s = await makeStore();
183
+ const { runId } = await s.startRun({
184
+ name: "f",
185
+ version: 1,
186
+ input: {}
187
+ });
188
+ await s.markTerminal(runId, {
189
+ status: "done",
190
+ output: "out"
191
+ });
192
+ const snap = await s.loadRun(runId);
193
+ expect(snap?.run.status).toBe("done");
194
+ expect(snap?.run.output).toBe("out");
195
+ });
196
+ it("markTerminal never overrides a canceled run", async () => {
197
+ const s = await makeStore();
198
+ const { runId } = await s.startRun({
199
+ name: "g",
200
+ version: 1,
201
+ input: {}
202
+ });
203
+ await s.markTerminal(runId, { status: "canceled" });
204
+ await s.markTerminal(runId, {
205
+ status: "done",
206
+ output: "late"
207
+ });
208
+ expect((await s.loadRun(runId))?.run.status).toBe("canceled");
209
+ });
210
+ it("checkpointStep round-trips a failed_terminal outcome with its error", async () => {
211
+ const s = await makeStore();
212
+ const { runId } = await s.startRun({
213
+ name: "f",
214
+ version: 1,
215
+ input: {}
216
+ });
217
+ await s.checkpointStep({
218
+ runId,
219
+ cursorKey: "a",
220
+ status: "failed_terminal",
221
+ error: {
222
+ code: "BOOM",
223
+ message: "boom"
224
+ },
225
+ attempts: 1
226
+ });
227
+ expect((await s.loadRun(runId))?.steps.get("a")).toMatchObject({
228
+ status: "failed_terminal",
229
+ error: {
230
+ code: "BOOM",
231
+ message: "boom"
232
+ }
233
+ });
234
+ });
235
+ it("first-writer-wins holds across status — a later ok cannot overwrite a failed_terminal", async () => {
236
+ const s = await makeStore();
237
+ const { runId } = await s.startRun({
238
+ name: "f",
239
+ version: 1,
240
+ input: {}
241
+ });
242
+ await s.checkpointStep({
243
+ runId,
244
+ cursorKey: "a",
245
+ status: "failed_terminal",
246
+ error: {
247
+ code: "BOOM",
248
+ message: "boom"
249
+ },
250
+ attempts: 1
251
+ });
252
+ expect((await s.checkpointStep({
253
+ runId,
254
+ cursorKey: "a",
255
+ status: "ok",
256
+ result: 1,
257
+ attempts: 2
258
+ })).status).toBe("failed_terminal");
259
+ expect((await s.loadRun(runId))?.steps.get("a")?.status).toBe("failed_terminal");
260
+ });
261
+ it("checkpointStep on an unknown run is rejected (no orphan step)", async () => {
262
+ await expect((await makeStore()).checkpointStep({
263
+ runId: "nope",
264
+ cursorKey: "a",
265
+ status: "ok",
266
+ result: 1,
267
+ attempts: 1
268
+ })).rejects.toThrow();
269
+ });
270
+ it("startRun on an idempotency hit returns the run's CURRENT status", async () => {
271
+ const s = await makeStore();
272
+ const a = await s.startRun({
273
+ name: "f",
274
+ version: 1,
275
+ input: {},
276
+ idempotencyKey: "k"
277
+ });
278
+ await s.markTerminal(a.runId, {
279
+ status: "done",
280
+ output: 1
281
+ });
282
+ const b = await s.startRun({
283
+ name: "f",
284
+ version: 1,
285
+ input: {},
286
+ idempotencyKey: "k"
287
+ });
288
+ expect(b.created).toBe(false);
289
+ expect(b.status).toBe("done");
290
+ });
291
+ it("tags round-trip", async () => {
292
+ const s = await makeStore();
293
+ const { runId } = await s.startRun({
294
+ name: "f",
295
+ version: 1,
296
+ input: {},
297
+ tags: ["x", "y"]
298
+ });
299
+ expect((await s.loadRun(runId))?.run.tags).toEqual(["x", "y"]);
300
+ });
301
+ it("mutating a snapshot's run/step VALUES can't corrupt the store", async () => {
302
+ const s = await makeStore();
303
+ const { runId } = await s.startRun({
304
+ name: "f",
305
+ version: 1,
306
+ input: { n: 1 }
307
+ });
308
+ await s.checkpointStep({
309
+ runId,
310
+ cursorKey: "a",
311
+ status: "ok",
312
+ result: { v: 1 },
313
+ attempts: 1
314
+ });
315
+ const snap = await s.loadRun(runId);
316
+ snap.run.input.n = 999;
317
+ snap.steps.get("a").result.v = 999;
318
+ snap.steps.clear();
319
+ const again = await s.loadRun(runId);
320
+ expect(again.run.input.n).toBe(1);
321
+ expect(again.steps.get("a").result.v).toBe(1);
322
+ });
323
+ it("caller mutating the input after startRun can't reach the stored run", async () => {
324
+ const s = await makeStore();
325
+ const input = { n: 1 };
326
+ const { runId } = await s.startRun({
327
+ name: "f",
328
+ version: 1,
329
+ input
330
+ });
331
+ input.n = 999;
332
+ expect((await s.loadRun(runId))?.run.input).toEqual({ n: 1 });
333
+ });
334
+ it("listRuns returns newest-first and filters by status, name, and tag", async () => {
335
+ const s = await makeStore();
336
+ const a = await s.startRun({
337
+ name: "alpha",
338
+ version: 1,
339
+ input: {},
340
+ tags: ["t1"]
341
+ });
342
+ const b = await s.startRun({
343
+ name: "beta",
344
+ version: 1,
345
+ input: {},
346
+ tags: ["t2"]
347
+ });
348
+ await s.startRun({
349
+ name: "alpha",
350
+ version: 1,
351
+ input: {},
352
+ tags: ["t1"]
353
+ });
354
+ await s.markTerminal(b.runId, {
355
+ status: "done",
356
+ output: 1
357
+ });
358
+ const all = await s.listRuns({}, { limit: 10 });
359
+ expect(all.runs).toHaveLength(3);
360
+ expect(all.runs[0].id).not.toBe(a.runId);
361
+ expect((await s.listRuns({ name: "alpha" }, { limit: 10 })).runs.map((r) => r.name)).toEqual(["alpha", "alpha"]);
362
+ expect((await s.listRuns({ status: "done" }, { limit: 10 })).runs.map((r) => r.id)).toEqual([b.runId]);
363
+ expect((await s.listRuns({ tag: "t2" }, { limit: 10 })).runs.map((r) => r.id)).toEqual([b.runId]);
364
+ });
365
+ it("listRuns paginates with a cursor", async () => {
366
+ const s = await makeStore();
367
+ for (let i = 0; i < 5; i++) await s.startRun({
368
+ name: "f",
369
+ version: 1,
370
+ input: { i }
371
+ });
372
+ const first = await s.listRuns({}, { limit: 2 });
373
+ expect(first.runs).toHaveLength(2);
374
+ expect(first.cursor).toBeDefined();
375
+ const second = await s.listRuns({}, {
376
+ limit: 2,
377
+ cursor: first.cursor
378
+ });
379
+ expect(second.runs).toHaveLength(2);
380
+ expect(new Set([...first.runs, ...second.runs].map((r) => r.id)).size).toBe(4);
381
+ });
382
+ it("childrenOf returns the runs spawned under a parent", async () => {
383
+ const s = await makeStore();
384
+ const parent = await s.startRun({
385
+ name: "p",
386
+ version: 1,
387
+ input: {}
388
+ });
389
+ const c1 = await s.startRun({
390
+ name: "c",
391
+ version: 1,
392
+ input: {},
393
+ parentRunId: parent.runId
394
+ });
395
+ const c2 = await s.startRun({
396
+ name: "c",
397
+ version: 1,
398
+ input: {},
399
+ parentRunId: parent.runId
400
+ });
401
+ await s.startRun({
402
+ name: "unrelated",
403
+ version: 1,
404
+ input: {}
405
+ });
406
+ const kids = await s.childrenOf(parent.runId);
407
+ expect(new Set(kids.map((r) => r.id))).toEqual(/* @__PURE__ */ new Set([c1.runId, c2.runId]));
408
+ });
409
+ it("retryRun re-drives a failed run and keeps its ok step memos", async () => {
410
+ const s = await makeStore();
411
+ const { runId } = await s.startRun({
412
+ name: "f",
413
+ version: 1,
414
+ input: {}
415
+ });
416
+ await s.checkpointStep({
417
+ runId,
418
+ cursorKey: "a",
419
+ status: "ok",
420
+ result: 7,
421
+ attempts: 1
422
+ });
423
+ await s.markTerminal(runId, {
424
+ status: "failed",
425
+ error: {
426
+ code: "X",
427
+ message: "boom"
428
+ }
429
+ });
430
+ expect((await s.retryRun(runId)).retried).toBe(true);
431
+ const snap = await s.loadRun(runId);
432
+ expect(snap?.run.status).toBe("pending");
433
+ expect(snap?.run.error).toBeUndefined();
434
+ expect(snap?.steps.get("a")?.result).toBe(7);
435
+ expect((await s.retryRun(runId)).retried).toBe(false);
436
+ });
437
+ it("runStats counts runs per status", async () => {
438
+ const s = await makeStore();
439
+ const a = await s.startRun({
440
+ name: "f",
441
+ version: 1,
442
+ input: {}
443
+ });
444
+ await s.startRun({
445
+ name: "f",
446
+ version: 1,
447
+ input: {}
448
+ });
449
+ await s.markTerminal(a.runId, {
450
+ status: "done",
451
+ output: 1
452
+ });
453
+ const stats = await s.runStats();
454
+ expect(stats.pending).toBe(1);
455
+ expect(stats.done).toBe(1);
456
+ expect(stats.failed).toBe(0);
457
+ });
458
+ });
459
+ };
460
+ //#endregion
461
+ //#region src/clock.ts
462
+ /** @internal */
463
+ const T0 = /* @__PURE__ */ new Date("2030-01-01T00:00:00Z");
464
+ /** @internal */
465
+ const at = (ms) => new Date(T0.getTime() + ms);
466
+ //#endregion
467
+ //#region src/queue.ts
468
+ /**
469
+ * The Queue contract as executable invariants. The universal lease-CAS backend and a
470
+ * Postgres `SKIP LOCKED` backend must both satisfy exactly these — leasing is exclusive,
471
+ * leases expire and re-claim (crash recovery), heartbeats extend, and a stale ack is a
472
+ * no-op. Clock is injected so the suite is deterministic on any backend.
473
+ */
474
+ const queueConformance = (label, makeQueue) => {
475
+ describe(`Queue conformance — ${label}`, () => {
476
+ it("enqueue then claim returns the run with a lease", async () => {
477
+ const q = await makeQueue();
478
+ await q.enqueue("r1");
479
+ const leases = await q.claim({
480
+ max: 10,
481
+ leaseMs: 1e3,
482
+ now: at(0)
483
+ });
484
+ expect(leases.map((l) => l.runId)).toEqual(["r1"]);
485
+ expect(leases[0].token).toBeTruthy();
486
+ expect(leases[0].expiresAt.getTime()).toBe(at(1e3).getTime());
487
+ });
488
+ it("a future runAt is not claimable until due", async () => {
489
+ const q = await makeQueue();
490
+ await q.enqueue("r1", { runAt: at(5e3) });
491
+ expect(await q.claim({
492
+ max: 10,
493
+ leaseMs: 1e3,
494
+ now: at(0)
495
+ })).toEqual([]);
496
+ expect((await q.claim({
497
+ max: 10,
498
+ leaseMs: 1e3,
499
+ now: at(5e3)
500
+ })).map((l) => l.runId)).toEqual(["r1"]);
501
+ });
502
+ it("a claimed run is leased exclusively — a second claim before expiry gets nothing", async () => {
503
+ const q = await makeQueue();
504
+ await q.enqueue("r1");
505
+ expect(await q.claim({
506
+ max: 10,
507
+ leaseMs: 1e3,
508
+ now: at(0)
509
+ })).toHaveLength(1);
510
+ expect(await q.claim({
511
+ max: 10,
512
+ leaseMs: 1e3,
513
+ now: at(500)
514
+ })).toEqual([]);
515
+ });
516
+ it("an expired lease is re-claimable (crash recovery), with a fresh token", async () => {
517
+ const q = await makeQueue();
518
+ await q.enqueue("r1");
519
+ const first = await q.claim({
520
+ max: 10,
521
+ leaseMs: 1e3,
522
+ now: at(0)
523
+ });
524
+ const reclaim = await q.claim({
525
+ max: 10,
526
+ leaseMs: 1e3,
527
+ now: at(1001)
528
+ });
529
+ expect(reclaim.map((l) => l.runId)).toEqual(["r1"]);
530
+ expect(reclaim[0].token).not.toBe(first[0].token);
531
+ });
532
+ it("heartbeat extends the lease so it isn't re-claimed at the original expiry", async () => {
533
+ const q = await makeQueue();
534
+ await q.enqueue("r1");
535
+ const [lease] = await q.claim({
536
+ max: 10,
537
+ leaseMs: 1e3,
538
+ now: at(0)
539
+ });
540
+ await q.heartbeat(lease, {
541
+ leaseMs: 1e3,
542
+ now: at(500)
543
+ });
544
+ expect(await q.claim({
545
+ max: 10,
546
+ leaseMs: 1e3,
547
+ now: at(1001)
548
+ })).toEqual([]);
549
+ expect((await q.claim({
550
+ max: 10,
551
+ leaseMs: 1e3,
552
+ now: at(1501)
553
+ })).map((l) => l.runId)).toEqual(["r1"]);
554
+ });
555
+ it("heartbeat on a lost (re-claimed) lease throws", async () => {
556
+ const q = await makeQueue();
557
+ await q.enqueue("r1");
558
+ const [stale] = await q.claim({
559
+ max: 10,
560
+ leaseMs: 1e3,
561
+ now: at(0)
562
+ });
563
+ await q.claim({
564
+ max: 10,
565
+ leaseMs: 1e3,
566
+ now: at(1001)
567
+ });
568
+ await expect(q.heartbeat(stale, {
569
+ leaseMs: 1e3,
570
+ now: at(1002)
571
+ })).rejects.toThrow();
572
+ });
573
+ it("ack removes the run so it isn't claimed again", async () => {
574
+ const q = await makeQueue();
575
+ await q.enqueue("r1");
576
+ const [lease] = await q.claim({
577
+ max: 10,
578
+ leaseMs: 1e3,
579
+ now: at(0)
580
+ });
581
+ await q.ack(lease, { now: at(1) });
582
+ expect(await q.claim({
583
+ max: 10,
584
+ leaseMs: 1e3,
585
+ now: at(2e3)
586
+ })).toEqual([]);
587
+ });
588
+ it("a stale ack is a no-op — it does not delete the run the new owner holds", async () => {
589
+ const q = await makeQueue();
590
+ await q.enqueue("r1");
591
+ const [stale] = await q.claim({
592
+ max: 10,
593
+ leaseMs: 1e3,
594
+ now: at(0)
595
+ });
596
+ await q.claim({
597
+ max: 10,
598
+ leaseMs: 1e3,
599
+ now: at(1001)
600
+ });
601
+ await q.ack(stale, { now: at(1002) });
602
+ expect((await q.claim({
603
+ max: 10,
604
+ leaseMs: 1e3,
605
+ now: at(2002)
606
+ })).map((l) => l.runId)).toEqual(["r1"]);
607
+ });
608
+ it("a wake that re-enqueues during a lease survives the ack — the job is released, not deleted", async () => {
609
+ const q = await makeQueue();
610
+ await q.enqueue("r1");
611
+ const [lease] = await q.claim({
612
+ max: 10,
613
+ leaseMs: 1e3,
614
+ now: at(0)
615
+ });
616
+ await q.enqueue("r1");
617
+ await q.ack(lease, { now: at(1) });
618
+ expect((await q.claim({
619
+ max: 10,
620
+ leaseMs: 1e3,
621
+ now: at(2)
622
+ })).map((l) => l.runId)).toEqual(["r1"]);
623
+ });
624
+ it("an expired ack is a no-op — an expired owner can't delete a re-claimable run", async () => {
625
+ const q = await makeQueue();
626
+ await q.enqueue("r1");
627
+ const [lease] = await q.claim({
628
+ max: 10,
629
+ leaseMs: 1e3,
630
+ now: at(0)
631
+ });
632
+ await q.ack(lease, { now: at(1001) });
633
+ expect((await q.claim({
634
+ max: 10,
635
+ leaseMs: 1e3,
636
+ now: at(1002)
637
+ })).map((l) => l.runId)).toEqual(["r1"]);
638
+ });
639
+ it("heartbeat after expiry throws (lease lost even without a re-claim)", async () => {
640
+ const q = await makeQueue();
641
+ await q.enqueue("r1");
642
+ const [lease] = await q.claim({
643
+ max: 10,
644
+ leaseMs: 1e3,
645
+ now: at(0)
646
+ });
647
+ await expect(q.heartbeat(lease, {
648
+ leaseMs: 1e3,
649
+ now: at(1001)
650
+ })).rejects.toThrow();
651
+ });
652
+ it("re-enqueue does not yank an active lease from the current worker", async () => {
653
+ const q = await makeQueue();
654
+ await q.enqueue("r1");
655
+ await q.claim({
656
+ max: 10,
657
+ leaseMs: 1e3,
658
+ now: at(0)
659
+ });
660
+ await q.enqueue("r1", { priority: 1 });
661
+ expect(await q.claim({
662
+ max: 10,
663
+ leaseMs: 1e3,
664
+ now: at(500)
665
+ })).toEqual([]);
666
+ });
667
+ it("release makes the run immediately re-claimable without waiting out the lease", async () => {
668
+ const q = await makeQueue();
669
+ await q.enqueue("r1");
670
+ const [lease] = await q.claim({
671
+ max: 10,
672
+ leaseMs: 1e3,
673
+ now: at(0)
674
+ });
675
+ await q.release(lease, { now: at(100) });
676
+ const again = await q.claim({
677
+ max: 10,
678
+ leaseMs: 1e3,
679
+ now: at(200)
680
+ });
681
+ expect(again.map((l) => l.runId)).toEqual(["r1"]);
682
+ expect(again[0].token).not.toBe(lease.token);
683
+ });
684
+ it("a stale release is a no-op — it can't unseat the current owner", async () => {
685
+ const q = await makeQueue();
686
+ await q.enqueue("r1");
687
+ const [stale] = await q.claim({
688
+ max: 10,
689
+ leaseMs: 1e3,
690
+ now: at(0)
691
+ });
692
+ await q.claim({
693
+ max: 10,
694
+ leaseMs: 1e3,
695
+ now: at(1001)
696
+ });
697
+ await q.release(stale, { now: at(1002) });
698
+ expect(await q.claim({
699
+ max: 10,
700
+ leaseMs: 1e3,
701
+ now: at(1003)
702
+ })).toEqual([]);
703
+ });
704
+ it("claim honours the batch max", async () => {
705
+ const q = await makeQueue();
706
+ for (let i = 0; i < 5; i++) await q.enqueue(`r${i}`);
707
+ expect(await q.claim({
708
+ max: 2,
709
+ leaseMs: 1e3,
710
+ now: at(0)
711
+ })).toHaveLength(2);
712
+ });
713
+ it("lower priority is claimed first", async () => {
714
+ const q = await makeQueue();
715
+ await q.enqueue("low", { priority: 10 });
716
+ await q.enqueue("high", { priority: 1 });
717
+ const [first] = await q.claim({
718
+ max: 1,
719
+ leaseMs: 1e3,
720
+ now: at(0)
721
+ });
722
+ expect(first.runId).toBe("high");
723
+ });
724
+ it("re-enqueue is an upsert keyed by runId — claimed once", async () => {
725
+ const q = await makeQueue();
726
+ await q.enqueue("r1");
727
+ await q.enqueue("r1", { priority: 5 });
728
+ expect((await q.claim({
729
+ max: 10,
730
+ leaseMs: 1e3,
731
+ now: at(0)
732
+ })).map((l) => l.runId)).toEqual(["r1"]);
733
+ });
734
+ });
735
+ };
736
+ //#endregion
737
+ //#region src/timer.ts
738
+ /**
739
+ * The Timer contract as executable invariants — durable deadlines that fire exactly once,
740
+ * earliest first, with upsert-reschedule and cancel. Clock injected for determinism.
741
+ */
742
+ const timerConformance = (label, makeTimer) => {
743
+ describe(`Timer conformance — ${label}`, () => {
744
+ it("a scheduled timer is due once its fireAt has passed", async () => {
745
+ const tm = await makeTimer();
746
+ await tm.schedule("r1", at(1e3));
747
+ expect(await tm.dueBatch({
748
+ now: at(999),
749
+ max: 10
750
+ })).toEqual([]);
751
+ expect(await tm.dueBatch({
752
+ now: at(1e3),
753
+ max: 10
754
+ })).toEqual(["r1"]);
755
+ });
756
+ it("dueBatch fires exactly once — a consumed timer is not returned again", async () => {
757
+ const tm = await makeTimer();
758
+ await tm.schedule("r1", at(0));
759
+ expect(await tm.dueBatch({
760
+ now: at(1),
761
+ max: 10
762
+ })).toEqual(["r1"]);
763
+ expect(await tm.dueBatch({
764
+ now: at(2),
765
+ max: 10
766
+ })).toEqual([]);
767
+ });
768
+ it("reschedule is an upsert — the latest fireAt wins", async () => {
769
+ const tm = await makeTimer();
770
+ await tm.schedule("r1", at(1e3));
771
+ await tm.schedule("r1", at(5e3));
772
+ expect(await tm.dueBatch({
773
+ now: at(2e3),
774
+ max: 10
775
+ })).toEqual([]);
776
+ expect(await tm.dueBatch({
777
+ now: at(5e3),
778
+ max: 10
779
+ })).toEqual(["r1"]);
780
+ });
781
+ it("reschedule EARLIER also wins — upsert, not a LEAST(fire_at) merge", async () => {
782
+ const tm = await makeTimer();
783
+ await tm.schedule("r1", at(5e3));
784
+ await tm.schedule("r1", at(1e3));
785
+ expect(await tm.dueBatch({
786
+ now: at(1e3),
787
+ max: 10
788
+ })).toEqual(["r1"]);
789
+ });
790
+ it("cancel removes a pending timer", async () => {
791
+ const tm = await makeTimer();
792
+ await tm.schedule("r1", at(1e3));
793
+ await tm.cancel("r1");
794
+ expect(await tm.dueBatch({
795
+ now: at(2e3),
796
+ max: 10
797
+ })).toEqual([]);
798
+ });
799
+ it("dueBatch honours max and returns earliest-first", async () => {
800
+ const tm = await makeTimer();
801
+ await tm.schedule("late", at(300));
802
+ await tm.schedule("early", at(100));
803
+ await tm.schedule("mid", at(200));
804
+ expect(await tm.dueBatch({
805
+ now: at(1e3),
806
+ max: 2
807
+ })).toEqual(["early", "mid"]);
808
+ });
809
+ });
810
+ };
811
+ //#endregion
812
+ //#region src/wakeup.ts
813
+ /**
814
+ * The Wakeup contract as executable invariants: a signal wakes current waiters early, a
815
+ * missing signal still resolves on timeout (the poll tick), and a signal for another run
816
+ * doesn't wake unrelated waiters.
817
+ */
818
+ const wakeupConformance = (label, makeWakeup) => {
819
+ describe(`Wakeup conformance — ${label}`, () => {
820
+ it("signal wakes a pending waiter early", async () => {
821
+ const w = await makeWakeup();
822
+ let resolved = false;
823
+ const p = w.wait("r1", 5e3).then(() => {
824
+ resolved = true;
825
+ });
826
+ await w.signal("r1");
827
+ await p;
828
+ expect(resolved).toBe(true);
829
+ });
830
+ it("wait resolves on timeout when no signal arrives (the poll tick)", async () => {
831
+ const w = await makeWakeup();
832
+ const start = Date.now();
833
+ await w.wait("r1", 25);
834
+ expect(Date.now() - start).toBeGreaterThanOrEqual(20);
835
+ });
836
+ it("a signal for another run does not wake the waiter early", async () => {
837
+ const w = await makeWakeup();
838
+ let resolved = false;
839
+ const p = w.wait("r1", 60).then(() => {
840
+ resolved = true;
841
+ });
842
+ await w.signal("r2");
843
+ await new Promise((r) => setTimeout(r, 15));
844
+ expect(resolved).toBe(false);
845
+ await p;
846
+ expect(resolved).toBe(true);
847
+ });
848
+ it("signal is a no-op when there are no waiters", async () => {
849
+ await expect((await makeWakeup()).signal("nobody")).resolves.toBeUndefined();
850
+ });
851
+ it("is edge-triggered — a signal before any waiter does not latch", async () => {
852
+ const w = await makeWakeup();
853
+ await w.signal("r1");
854
+ const start = Date.now();
855
+ await w.wait("r1", 25);
856
+ expect(Date.now() - start).toBeGreaterThanOrEqual(20);
857
+ });
858
+ it("multiple waiters on the same run all wake on one signal", async () => {
859
+ const w = await makeWakeup();
860
+ let woke = 0;
861
+ const ps = [w.wait("r1", 5e3), w.wait("r1", 5e3)].map((p) => p.then(() => {
862
+ woke += 1;
863
+ }));
864
+ await w.signal("r1");
865
+ await Promise.all(ps);
866
+ expect(woke).toBe(2);
867
+ });
868
+ });
869
+ };
870
+ //#endregion
871
+ //#region src/outbox.ts
872
+ /**
873
+ * The transactional-outbox seam as executable invariants: a durable Store write and the
874
+ * side-effects it carries commit together, and — critically — the outbox fires exactly
875
+ * ONCE even when the write is replayed. Every backend (memory, Postgres, DynamoDB) must
876
+ * satisfy this; it is the property that makes crash recovery correct rather than just
877
+ * plausible.
878
+ */
879
+ const outboxConformance = (label, makeBackend) => {
880
+ describe(`Outbox conformance — ${label}`, () => {
881
+ it("checkpointStep commits its enqueue atomically — the run is claimable after", async () => {
882
+ const { store, queue } = await makeBackend();
883
+ const { runId } = await store.startRun({
884
+ name: "f",
885
+ version: 1,
886
+ input: {}
887
+ });
888
+ await store.checkpointStep({
889
+ runId,
890
+ cursorKey: "a",
891
+ status: "ok",
892
+ result: 1,
893
+ attempts: 1
894
+ }, { enqueue: [{ runId }] });
895
+ expect((await queue.claim({
896
+ max: 10,
897
+ leaseMs: 1e3,
898
+ now: at(0)
899
+ })).map((l) => l.runId)).toEqual([runId]);
900
+ });
901
+ it("checkpointStep spawns a child run + enqueues it atomically", async () => {
902
+ const { store, queue } = await makeBackend();
903
+ const { runId } = await store.startRun({
904
+ name: "parent",
905
+ version: 1,
906
+ input: {}
907
+ });
908
+ const childId = "11111111-1111-1111-1111-111111111111";
909
+ await store.checkpointStep({
910
+ runId,
911
+ cursorKey: "spawn",
912
+ status: "ok",
913
+ result: childId,
914
+ attempts: 1
915
+ }, { spawn: [{
916
+ runId: childId,
917
+ spec: {
918
+ name: "child",
919
+ version: 1,
920
+ input: { n: 1 }
921
+ }
922
+ }] });
923
+ expect((await store.loadRun(childId))?.run.input).toEqual({ n: 1 });
924
+ expect((await queue.claim({
925
+ max: 10,
926
+ leaseMs: 1e3,
927
+ now: at(0)
928
+ })).map((l) => l.runId)).toContain(childId);
929
+ });
930
+ it("a replayed checkpoint does NOT re-fire the outbox — the child spawns exactly once", async () => {
931
+ const { store, queue } = await makeBackend();
932
+ const { runId } = await store.startRun({
933
+ name: "parent",
934
+ version: 1,
935
+ input: {}
936
+ });
937
+ const childId = "22222222-2222-2222-2222-222222222222";
938
+ const spawn = { spawn: [{
939
+ runId: childId,
940
+ spec: {
941
+ name: "child",
942
+ version: 1,
943
+ input: {}
944
+ }
945
+ }] };
946
+ await store.checkpointStep({
947
+ runId,
948
+ cursorKey: "spawn",
949
+ status: "ok",
950
+ result: childId,
951
+ attempts: 1
952
+ }, spawn);
953
+ await store.checkpointStep({
954
+ runId,
955
+ cursorKey: "spawn",
956
+ status: "ok",
957
+ result: childId,
958
+ attempts: 2
959
+ }, spawn);
960
+ expect((await queue.claim({
961
+ max: 10,
962
+ leaseMs: 1e3,
963
+ now: at(0)
964
+ })).filter((l) => l.runId === childId)).toHaveLength(1);
965
+ });
966
+ it("suspendRun(sleeping) sets status + wake timer atomically", async () => {
967
+ const { store, timer } = await makeBackend();
968
+ const { runId } = await store.startRun({
969
+ name: "f",
970
+ version: 1,
971
+ input: {}
972
+ });
973
+ await store.markRunning(runId);
974
+ await store.suspendRun(runId, "sleeping", { timers: [{
975
+ runId,
976
+ fireAt: at(5e3)
977
+ }] });
978
+ expect((await store.loadRun(runId))?.run.status).toBe("sleeping");
979
+ expect(await timer.dueBatch({
980
+ now: at(4999),
981
+ max: 10
982
+ })).toEqual([]);
983
+ expect(await timer.dueBatch({
984
+ now: at(5e3),
985
+ max: 10
986
+ })).toEqual([runId]);
987
+ });
988
+ it("suspendRun is a no-op on a terminal run — no zombie wake timer", async () => {
989
+ const { store, timer } = await makeBackend();
990
+ const { runId } = await store.startRun({
991
+ name: "f",
992
+ version: 1,
993
+ input: {}
994
+ });
995
+ await store.markTerminal(runId, {
996
+ status: "done",
997
+ output: 1
998
+ });
999
+ await store.suspendRun(runId, "sleeping", { timers: [{
1000
+ runId,
1001
+ fireAt: at(5e3)
1002
+ }] });
1003
+ expect((await store.loadRun(runId))?.run.status).toBe("done");
1004
+ expect(await timer.dueBatch({
1005
+ now: at(5e3),
1006
+ max: 10
1007
+ })).toEqual([]);
1008
+ });
1009
+ it("markTerminal wakes a suspended parent atomically (enqueue in the same write)", async () => {
1010
+ const { store, queue } = await makeBackend();
1011
+ const parent = await store.startRun({
1012
+ name: "parent",
1013
+ version: 1,
1014
+ input: {}
1015
+ });
1016
+ const child = await store.startRun({
1017
+ name: "child",
1018
+ version: 1,
1019
+ input: {},
1020
+ parentRunId: parent.runId
1021
+ });
1022
+ await store.markTerminal(child.runId, {
1023
+ status: "done",
1024
+ output: 7
1025
+ }, { enqueue: [{ runId: parent.runId }] });
1026
+ expect((await queue.claim({
1027
+ max: 10,
1028
+ leaseMs: 1e3,
1029
+ now: at(0)
1030
+ })).map((l) => l.runId)).toContain(parent.runId);
1031
+ });
1032
+ it("markTerminal cancels a pending timer atomically (no stale wake after completion)", async () => {
1033
+ const { store, timer } = await makeBackend();
1034
+ const { runId } = await store.startRun({
1035
+ name: "f",
1036
+ version: 1,
1037
+ input: {}
1038
+ });
1039
+ await store.markRunning(runId);
1040
+ await store.suspendRun(runId, "awaiting_signal", { timers: [{
1041
+ runId,
1042
+ fireAt: at(5e3)
1043
+ }] });
1044
+ await store.markTerminal(runId, {
1045
+ status: "done",
1046
+ output: 1
1047
+ }, { cancelTimers: [runId] });
1048
+ expect(await timer.dueBatch({
1049
+ now: at(5e3),
1050
+ max: 10
1051
+ })).toEqual([]);
1052
+ });
1053
+ it("a canceled run's markTerminal is sticky AND skips its outbox", async () => {
1054
+ const { store, queue } = await makeBackend();
1055
+ const { runId } = await store.startRun({
1056
+ name: "f",
1057
+ version: 1,
1058
+ input: {}
1059
+ });
1060
+ await store.markTerminal(runId, { status: "canceled" });
1061
+ await store.markTerminal(runId, {
1062
+ status: "done",
1063
+ output: 1
1064
+ }, { enqueue: [{ runId }] });
1065
+ expect((await store.loadRun(runId))?.run.status).toBe("canceled");
1066
+ expect(await queue.claim({
1067
+ max: 10,
1068
+ leaseMs: 1e3,
1069
+ now: at(0)
1070
+ })).toEqual([]);
1071
+ });
1072
+ });
1073
+ };
1074
+ //#endregion
1075
+ //#region src/signal.ts
1076
+ /**
1077
+ * The durable signal inbox as executable invariants: a delivered signal lands in `loadRun`,
1078
+ * wakes the run (re-enqueue), is idempotent on its key, and is consumed exactly once by a
1079
+ * checkpoint's `consumeSignals` outbox.
1080
+ */
1081
+ const signalConformance = (label, makeBackend) => {
1082
+ describe(`Signal conformance — ${label}`, () => {
1083
+ it("postSignal delivers into the inbox and wakes the run (re-enqueue)", async () => {
1084
+ const { store, queue } = await makeBackend();
1085
+ const { runId } = await store.startRun({
1086
+ name: "f",
1087
+ version: 1,
1088
+ input: {}
1089
+ });
1090
+ expect((await store.postSignal(runId, "approve", { ok: true })).delivered).toBe(true);
1091
+ expect((await store.loadRun(runId))?.signals.map((s) => ({
1092
+ name: s.name,
1093
+ payload: s.payload
1094
+ }))).toEqual([{
1095
+ name: "approve",
1096
+ payload: { ok: true }
1097
+ }]);
1098
+ expect((await queue.claim({
1099
+ max: 10,
1100
+ leaseMs: 1e3,
1101
+ now: at(0)
1102
+ })).map((l) => l.runId)).toEqual([runId]);
1103
+ });
1104
+ it("postSignal is idempotent on its key — a retried delivery lands once", async () => {
1105
+ const { store } = await makeBackend();
1106
+ const { runId } = await store.startRun({
1107
+ name: "f",
1108
+ version: 1,
1109
+ input: {}
1110
+ });
1111
+ const a = await store.postSignal(runId, "x", 1, { idempotencyKey: "k" });
1112
+ const b = await store.postSignal(runId, "x", 1, { idempotencyKey: "k" });
1113
+ expect(a.delivered).toBe(true);
1114
+ expect(b.delivered).toBe(false);
1115
+ expect((await store.loadRun(runId))?.signals).toHaveLength(1);
1116
+ });
1117
+ it("consumeSignals drains exactly the referenced signal, atomically with the checkpoint", async () => {
1118
+ const { store } = await makeBackend();
1119
+ const { runId } = await store.startRun({
1120
+ name: "f",
1121
+ version: 1,
1122
+ input: {}
1123
+ });
1124
+ await store.postSignal(runId, "a", "first");
1125
+ await store.postSignal(runId, "b", "second");
1126
+ const target = (await store.loadRun(runId))?.signals.find((s) => s.name === "a");
1127
+ await store.checkpointStep({
1128
+ runId,
1129
+ cursorKey: "wait",
1130
+ status: "ok",
1131
+ result: "first",
1132
+ attempts: 1
1133
+ }, { consumeSignals: [target.id] });
1134
+ const after = await store.loadRun(runId);
1135
+ expect(after?.signals.map((s) => s.name)).toEqual(["b"]);
1136
+ expect(after?.steps.get("wait")?.result).toBe("first");
1137
+ });
1138
+ it("distinct signals to a run preserve delivery order in the inbox", async () => {
1139
+ const { store } = await makeBackend();
1140
+ const { runId } = await store.startRun({
1141
+ name: "f",
1142
+ version: 1,
1143
+ input: {}
1144
+ });
1145
+ await store.postSignal(runId, "s", 1);
1146
+ await store.postSignal(runId, "s", 2);
1147
+ await store.postSignal(runId, "s", 3);
1148
+ expect((await store.loadRun(runId))?.signals.map((s) => s.payload)).toEqual([
1149
+ 1,
1150
+ 2,
1151
+ 3
1152
+ ]);
1153
+ });
1154
+ });
1155
+ };
1156
+ //#endregion
1157
+ //#region src/reconcile.ts
1158
+ /**
1159
+ * `orphanedRuns` as executable invariants — what the reconciler re-drives. A run is an orphan
1160
+ * only when it truly can't make progress on its own: non-terminal but off the queue with no
1161
+ * pending timer, OR an `awaiting_signal` parent whose child already finished (lost wake). A
1162
+ * run that is queued, timing, terminal, or legitimately waiting on an external signal is NOT.
1163
+ */
1164
+ const reconcileConformance = (label, makeBackend) => {
1165
+ describe(`Reconcile conformance — ${label}`, () => {
1166
+ it("a non-terminal run with no job and no timer is an orphan", async () => {
1167
+ const { store } = await makeBackend();
1168
+ const { runId } = await store.startRun({
1169
+ name: "f",
1170
+ version: 1,
1171
+ input: {}
1172
+ });
1173
+ expect(await store.orphanedRuns(10)).toContain(runId);
1174
+ });
1175
+ it("a queued run is not an orphan", async () => {
1176
+ const { store, queue } = await makeBackend();
1177
+ const { runId } = await store.startRun({
1178
+ name: "f",
1179
+ version: 1,
1180
+ input: {}
1181
+ });
1182
+ await queue.enqueue(runId);
1183
+ expect(await store.orphanedRuns(10)).not.toContain(runId);
1184
+ });
1185
+ it("a sleeping run with a pending timer is not an orphan", async () => {
1186
+ const { store } = await makeBackend();
1187
+ const { runId } = await store.startRun({
1188
+ name: "f",
1189
+ version: 1,
1190
+ input: {}
1191
+ });
1192
+ await store.markRunning(runId);
1193
+ await store.suspendRun(runId, "sleeping", { timers: [{
1194
+ runId,
1195
+ fireAt: at(1e4)
1196
+ }] });
1197
+ expect(await store.orphanedRuns(10)).not.toContain(runId);
1198
+ });
1199
+ it("an awaiting_signal run is not an orphan (legitimately waiting on an external signal)", async () => {
1200
+ const { store } = await makeBackend();
1201
+ const { runId } = await store.startRun({
1202
+ name: "f",
1203
+ version: 1,
1204
+ input: {}
1205
+ });
1206
+ await store.markRunning(runId);
1207
+ await store.suspendRun(runId, "awaiting_signal");
1208
+ expect(await store.orphanedRuns(10)).not.toContain(runId);
1209
+ });
1210
+ it("an awaiting_child parent whose child finished IS an orphan (lost parent-wake)", async () => {
1211
+ const { store } = await makeBackend();
1212
+ const parent = await store.startRun({
1213
+ name: "p",
1214
+ version: 1,
1215
+ input: {}
1216
+ });
1217
+ const child = await store.startRun({
1218
+ name: "c",
1219
+ version: 1,
1220
+ input: {},
1221
+ parentRunId: parent.runId
1222
+ });
1223
+ await store.markRunning(parent.runId);
1224
+ await store.suspendRun(parent.runId, "awaiting_child");
1225
+ await store.markTerminal(child.runId, {
1226
+ status: "done",
1227
+ output: 1
1228
+ });
1229
+ expect(await store.orphanedRuns(10)).toContain(parent.runId);
1230
+ });
1231
+ it("a terminal run is never an orphan", async () => {
1232
+ const { store } = await makeBackend();
1233
+ const { runId } = await store.startRun({
1234
+ name: "f",
1235
+ version: 1,
1236
+ input: {}
1237
+ });
1238
+ await store.markTerminal(runId, {
1239
+ status: "done",
1240
+ output: 1
1241
+ });
1242
+ expect(await store.orphanedRuns(10)).not.toContain(runId);
1243
+ });
1244
+ });
1245
+ };
1246
+ //#endregion
1247
+ //#region src/cron.ts
1248
+ /**
1249
+ * The cron store surface as executable invariants: upsert keeps timing on re-register,
1250
+ * `dueCrons` respects `nextRunAt`, and `advanceCron` is a CAS so exactly one worker fires an
1251
+ * occurrence (the double-fire guard on a fleet).
1252
+ */
1253
+ const cronConformance = (label, makeStore) => {
1254
+ describe(`Cron conformance — ${label}`, () => {
1255
+ const spec = {
1256
+ name: "nightly",
1257
+ schedule: "0 0 * * *",
1258
+ flowName: "report",
1259
+ flowVersion: 1,
1260
+ input: { kind: "daily" }
1261
+ };
1262
+ it("upsertCron registers a cron that dueCrons returns once due", async () => {
1263
+ const s = await makeStore();
1264
+ await s.upsertCron({
1265
+ ...spec,
1266
+ nextRunAt: at(1e3)
1267
+ });
1268
+ expect(await s.dueCrons(at(999), 10)).toEqual([]);
1269
+ const due = await s.dueCrons(at(1e3), 10);
1270
+ expect(due.map((c) => c.name)).toEqual(["nightly"]);
1271
+ expect(due[0].input).toEqual({ kind: "daily" });
1272
+ });
1273
+ it("re-registering keeps the existing nextRunAt (no schedule-timing reset)", async () => {
1274
+ const s = await makeStore();
1275
+ await s.upsertCron({
1276
+ ...spec,
1277
+ nextRunAt: at(5e3)
1278
+ });
1279
+ await s.upsertCron({
1280
+ ...spec,
1281
+ input: { kind: "changed" },
1282
+ nextRunAt: at(999999)
1283
+ });
1284
+ const due = await s.dueCrons(at(5e3), 10);
1285
+ expect(due.map((c) => c.name)).toEqual(["nightly"]);
1286
+ expect(due[0].input).toEqual({ kind: "changed" });
1287
+ });
1288
+ it("advanceCron is a CAS — only the worker matching the expected time wins", async () => {
1289
+ const s = await makeStore();
1290
+ await s.upsertCron({
1291
+ ...spec,
1292
+ nextRunAt: at(1e3)
1293
+ });
1294
+ const first = await s.advanceCron("nightly", at(1e3), at(2e3), at(1e3));
1295
+ const second = await s.advanceCron("nightly", at(1e3), at(2e3), at(1e3));
1296
+ expect(first).toBe(true);
1297
+ expect(second).toBe(false);
1298
+ expect(await s.dueCrons(at(1500), 10)).toEqual([]);
1299
+ });
1300
+ });
1301
+ };
1302
+ //#endregion
1303
+ export { cronConformance, outboxConformance, queueConformance, reconcileConformance, signalConformance, storeConformance, timerConformance, wakeupConformance };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@iterativeflow/conformance",
3
+ "version": "2.0.0-alpha.1",
4
+ "files": [
5
+ "dist"
6
+ ],
7
+ "type": "module",
8
+ "imports": {
9
+ "#*": "./src/*.ts"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.mts",
14
+ "default": "./dist/index.mjs"
15
+ }
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "scripts": {
21
+ "build": "tsdown"
22
+ },
23
+ "dependencies": {
24
+ "@iterativeflow/core": "2.0.0-alpha.1"
25
+ },
26
+ "peerDependencies": {
27
+ "vitest": ">=3"
28
+ }
29
+ }