@iterativeflow/conformance 2.0.0-alpha.1 → 2.0.0-alpha.11

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.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # @iterativeflow/conformance
2
+
3
+ The backend-authoring kit for [iterativeflow](https://github.com/ahmedrowaihi/iterativeflow)
4
+ v2. To add a substrate, implement the four ports (store/queue/timer/wakeup) from
5
+ `@iterativeflow/core/backend` and prove them against these shared suites — the
6
+ same tests the built-in memory, Postgres, and DynamoDB backends pass. A real
7
+ serialization or consistency regression can't hide behind a backend's own tests.
8
+
9
+ ```bash
10
+ npm install -D @iterativeflow/conformance @iterativeflow/core
11
+ ```
12
+
13
+ ```ts
14
+ import { storeConformance, engineConformance } from "@iterativeflow/conformance";
15
+ import { createMyBackend } from "./backend";
16
+
17
+ storeConformance("my-backend", () => createMyBackend());
18
+ engineConformance("my-backend", () => createMyBackend());
19
+ ```
20
+
21
+ Each suite registers `describe`/`it` blocks (vitest), so call them at the top
22
+ level of a test file.
23
+
24
+ ## Suites
25
+
26
+ - **Port suites** — `storeConformance`, `queueConformance`, `timerConformance`,
27
+ `wakeupConformance`: each port's contract in isolation.
28
+ - `outboxConformance` — the atomic multi-port checkpoint write.
29
+ - `signalConformance`, `reconcileConformance`, `cronConformance` — the durable
30
+ behaviors built on the ports.
31
+ - `engineConformance` — the composed engine (retry/dead-letter, signal resume,
32
+ cancel cascade, fan-out, drift) end-to-end on your backend.
33
+
34
+ Every suite takes `(label, makeBackend)` where `makeBackend` returns a fresh
35
+ `Backend` (or a `Promise` of one) per case.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
- import { Backend, Queue, Store, Timer, Wakeup } from "@iterativeflow/core/backend";
1
+ import { Backend } from "@iterativeflow/core";
2
+ import { Backend as Backend$1, Queue, Store, Timer, Wakeup } from "@iterativeflow/core/backend";
2
3
  //#region src/store.d.ts
3
4
  /**
4
5
  * The Store contract as executable invariants. EVERY backend (in-memory, Postgres,
@@ -39,7 +40,7 @@ declare const wakeupConformance: (label: string, makeWakeup: () => Wakeup | Prom
39
40
  * satisfy this; it is the property that makes crash recovery correct rather than just
40
41
  * plausible.
41
42
  */
42
- declare const outboxConformance: (label: string, makeBackend: () => Backend | Promise<Backend>) => void;
43
+ declare const outboxConformance: (label: string, makeBackend: () => Backend$1 | Promise<Backend$1>) => void;
43
44
  //#endregion
44
45
  //#region src/signal.d.ts
45
46
  /**
@@ -47,7 +48,7 @@ declare const outboxConformance: (label: string, makeBackend: () => Backend | Pr
47
48
  * wakes the run (re-enqueue), is idempotent on its key, and is consumed exactly once by a
48
49
  * checkpoint's `consumeSignals` outbox.
49
50
  */
50
- declare const signalConformance: (label: string, makeBackend: () => Backend | Promise<Backend>) => void;
51
+ declare const signalConformance: (label: string, makeBackend: () => Backend$1 | Promise<Backend$1>) => void;
51
52
  //#endregion
52
53
  //#region src/reconcile.d.ts
53
54
  /**
@@ -56,7 +57,7 @@ declare const signalConformance: (label: string, makeBackend: () => Backend | Pr
56
57
  * pending timer, OR an `awaiting_signal` parent whose child already finished (lost wake). A
57
58
  * run that is queued, timing, terminal, or legitimately waiting on an external signal is NOT.
58
59
  */
59
- declare const reconcileConformance: (label: string, makeBackend: () => Backend | Promise<Backend>) => void;
60
+ declare const reconcileConformance: (label: string, makeBackend: () => Backend$1 | Promise<Backend$1>) => void;
60
61
  //#endregion
61
62
  //#region src/cron.d.ts
62
63
  /**
@@ -66,4 +67,19 @@ declare const reconcileConformance: (label: string, makeBackend: () => Backend |
66
67
  */
67
68
  declare const cronConformance: (label: string, makeStore: () => Store | Promise<Store>) => void;
68
69
  //#endregion
69
- export { cronConformance, outboxConformance, queueConformance, reconcileConformance, signalConformance, storeConformance, timerConformance, wakeupConformance };
70
+ //#region src/engine.d.ts
71
+ /**
72
+ * Engine-behavior conformance: the composed durable behaviors (retry/dead-letter, signal
73
+ * resume, cancel cascade, drift) run against every backend, not just memory — so a real
74
+ * serialization/consistency regression can't hide behind the port-conformance suites.
75
+ */
76
+ declare const engineConformance: (label: string, makeBackend: () => Backend | Promise<Backend>) => void;
77
+ //#endregion
78
+ //#region src/claim-filter.d.ts
79
+ /**
80
+ * `ClaimOpts.names` as executable invariants: a sharded worker leases only runs whose flow `name`
81
+ * it registered. Needs the store (to create named runs), so it takes the whole backend.
82
+ */
83
+ declare const claimFilterConformance: (label: string, makeBackend: () => Backend$1 | Promise<Backend$1>) => void;
84
+ //#endregion
85
+ export { claimFilterConformance, cronConformance, engineConformance, outboxConformance, queueConformance, reconcileConformance, signalConformance, storeConformance, timerConformance, wakeupConformance };
package/dist/index.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { builder, cancelRun, defineFlow, reconcile, registry, signalRun, signalType, submit, tickOnce } from "@iterativeflow/core";
3
+ import { isTerminal } from "@iterativeflow/core/backend";
2
4
  //#region src/store.ts
3
5
  /**
4
6
  * The Store contract as executable invariants. EVERY backend (in-memory, Postgres,
@@ -75,6 +77,22 @@ const storeConformance = (label, makeStore) => {
75
77
  expect(results[1].created).toBe(true);
76
78
  expect(results[1].runId).not.toBe(first.runId);
77
79
  });
80
+ it("startManyRuns dedups a key that repeats WITHIN the batch — one run, not a throw", async () => {
81
+ const results = await (await makeStore()).startManyRuns([{
82
+ name: "f",
83
+ version: 1,
84
+ input: {},
85
+ idempotencyKey: "same"
86
+ }, {
87
+ name: "f",
88
+ version: 1,
89
+ input: {},
90
+ idempotencyKey: "same"
91
+ }]);
92
+ expect(results).toHaveLength(2);
93
+ expect(results[0].runId).toBe(results[1].runId);
94
+ expect([results[0].created, results[1].created].filter(Boolean)).toHaveLength(1);
95
+ });
78
96
  it("startRun is idempotent on the idempotency key — no duplicate run", async () => {
79
97
  const s = await makeStore();
80
98
  const a = await s.startRun({
@@ -111,6 +129,73 @@ const storeConformance = (label, makeStore) => {
111
129
  it("loadRun returns undefined for an unknown run", async () => {
112
130
  expect(await (await makeStore()).loadRun("00000000-0000-0000-0000-000000000000")).toBeUndefined();
113
131
  });
132
+ it("loadRunRow returns just the run row (no step/signal maps), undefined if gone", async () => {
133
+ const s = await makeStore();
134
+ const { runId } = await s.startRun({
135
+ name: "f",
136
+ version: 1,
137
+ input: { a: 1 }
138
+ });
139
+ await s.checkpointStep({
140
+ runId,
141
+ cursorKey: "a",
142
+ status: "ok",
143
+ result: 1,
144
+ attempts: 1
145
+ });
146
+ const row = await s.loadRunRow(runId);
147
+ expect(row).toMatchObject({
148
+ id: runId,
149
+ status: "pending"
150
+ });
151
+ expect(row).not.toHaveProperty("steps");
152
+ expect(row).not.toHaveProperty("signals");
153
+ expect(await s.loadRunRow("00000000-0000-0000-0000-000000000000")).toBeUndefined();
154
+ });
155
+ it("loadRunRows returns rows aligned to the input ids, undefined where gone", async () => {
156
+ const s = await makeStore();
157
+ const a = await s.startRun({
158
+ name: "f",
159
+ version: 1,
160
+ input: { n: 1 }
161
+ });
162
+ const b = await s.startRun({
163
+ name: "f",
164
+ version: 1,
165
+ input: { n: 2 }
166
+ });
167
+ const rows = await s.loadRunRows([
168
+ a.runId,
169
+ "00000000-0000-0000-0000-000000000000",
170
+ b.runId
171
+ ]);
172
+ expect(rows).toHaveLength(3);
173
+ expect(rows[0]?.id).toBe(a.runId);
174
+ expect(rows[1]).toBeUndefined();
175
+ expect(rows[2]?.id).toBe(b.runId);
176
+ expect(await s.loadRunRows([])).toEqual([]);
177
+ });
178
+ it("arriveAtJoin decrements the armed join countdown, and sentinels a gone parent", async () => {
179
+ const s = await makeStore();
180
+ const { runId } = await s.startRun({
181
+ name: "p",
182
+ version: 1,
183
+ input: {}
184
+ });
185
+ await s.checkpointStep({
186
+ runId,
187
+ cursorKey: "s0",
188
+ status: "ok",
189
+ result: [],
190
+ attempts: 1
191
+ }, { joinTarget: {
192
+ runId,
193
+ count: 2
194
+ } });
195
+ expect(await s.arriveAtJoin(runId)).toBe(1);
196
+ expect(await s.arriveAtJoin(runId)).toBe(0);
197
+ expect(await s.arriveAtJoin("00000000-0000-0000-0000-000000000000")).toBeUndefined();
198
+ });
114
199
  it("checkpointStep persists a completed step into the memo", async () => {
115
200
  const s = await makeStore();
116
201
  const { runId } = await s.startRun({
@@ -131,6 +216,23 @@ const storeConformance = (label, makeStore) => {
131
216
  attempts: 1
132
217
  });
133
218
  });
219
+ it("checkpointStep round-trips the shape tag (drift-guard evidence)", async () => {
220
+ const s = await makeStore();
221
+ const { runId } = await s.startRun({
222
+ name: "f",
223
+ version: 1,
224
+ input: {}
225
+ });
226
+ await s.checkpointStep({
227
+ runId,
228
+ cursorKey: "a",
229
+ status: "ok",
230
+ result: 1,
231
+ attempts: 1,
232
+ shape: "step:charge"
233
+ });
234
+ expect((await s.loadRun(runId))?.steps.get("a")?.shape).toBe("step:charge");
235
+ });
134
236
  it("checkpointStep is first-writer-wins — a second write does NOT overwrite (exactly-once memo)", async () => {
135
237
  const s = await makeStore();
136
238
  const { runId } = await s.startRun({
@@ -167,6 +269,22 @@ const storeConformance = (label, makeStore) => {
167
269
  expect(await s.markRunning(runId)).toBe(2);
168
270
  expect((await s.loadRun(runId))?.run.attempts).toBe(2);
169
271
  });
272
+ it("a forward-progress suspend zeroes the dispatch counter; retrying keeps it", async () => {
273
+ const s = await makeStore();
274
+ const { runId } = await s.startRun({
275
+ name: "f",
276
+ version: 1,
277
+ input: {}
278
+ });
279
+ await s.markRunning(runId);
280
+ await s.markRunning(runId);
281
+ await s.suspendRun(runId, "retrying");
282
+ expect((await s.loadRunRow(runId))?.attempts).toBe(2);
283
+ await s.markRunning(runId);
284
+ await s.suspendRun(runId, "sleeping");
285
+ expect((await s.loadRunRow(runId))?.attempts).toBe(0);
286
+ expect(await s.markRunning(runId)).toBe(1);
287
+ });
170
288
  it("markRunning never resurrects a terminal run (a late re-dispatch can't undo a cancel)", async () => {
171
289
  const s = await makeStore();
172
290
  const { runId } = await s.startRun({
@@ -207,56 +325,69 @@ const storeConformance = (label, makeStore) => {
207
325
  });
208
326
  expect((await s.loadRun(runId))?.run.status).toBe("canceled");
209
327
  });
210
- it("checkpointStep round-trips a failed_terminal outcome with its error", async () => {
328
+ it("deleteRunsOlderThan prunes terminal runs (with steps + signals) before the cutoff, spares live ones", async () => {
211
329
  const s = await makeStore();
212
- const { runId } = await s.startRun({
330
+ const old = /* @__PURE__ */ new Date("2020-01-01T00:00:00.000Z");
331
+ const doomed = await s.startRun({
213
332
  name: "f",
214
333
  version: 1,
215
- input: {}
334
+ input: {},
335
+ createdAt: old
216
336
  });
217
337
  await s.checkpointStep({
218
- runId,
338
+ runId: doomed.runId,
219
339
  cursorKey: "a",
220
- status: "failed_terminal",
221
- error: {
222
- code: "BOOM",
223
- message: "boom"
224
- },
340
+ status: "ok",
341
+ result: 1,
225
342
  attempts: 1
226
343
  });
227
- expect((await s.loadRun(runId))?.steps.get("a")).toMatchObject({
228
- status: "failed_terminal",
229
- error: {
230
- code: "BOOM",
231
- message: "boom"
232
- }
344
+ await s.postSignal(doomed.runId, "go", { ok: true });
345
+ await s.markTerminal(doomed.runId, {
346
+ status: "done",
347
+ output: 1
348
+ });
349
+ const live = await s.startRun({
350
+ name: "f",
351
+ version: 1,
352
+ input: {},
353
+ createdAt: old
233
354
  });
355
+ expect((await s.loadRunRow(live.runId))?.createdAt?.getTime()).toBe(old.getTime());
356
+ expect(await s.deleteRunsOlderThan(/* @__PURE__ */ new Date("2020-06-01T00:00:00Z"), 100)).toBe(1);
357
+ expect(await s.loadRun(doomed.runId)).toBeUndefined();
358
+ expect((await s.loadRun(live.runId))?.run.status).toBe("pending");
234
359
  });
235
- it("first-writer-wins holds across status — a later ok cannot overwrite a failed_terminal", async () => {
360
+ it("deleteRunsOlderThan honours the cutoff and the per-sweep limit", async () => {
236
361
  const s = await makeStore();
237
- const { runId } = await s.startRun({
362
+ const old = /* @__PURE__ */ new Date("2020-01-01T00:00:00.000Z");
363
+ const a = await s.startRun({
238
364
  name: "f",
239
365
  version: 1,
240
- input: {}
366
+ input: {},
367
+ createdAt: old
241
368
  });
242
- await s.checkpointStep({
243
- runId,
244
- cursorKey: "a",
245
- status: "failed_terminal",
369
+ const b = await s.startRun({
370
+ name: "f",
371
+ version: 1,
372
+ input: {},
373
+ createdAt: old
374
+ });
375
+ await s.markTerminal(a.runId, {
376
+ status: "done",
377
+ output: 1
378
+ });
379
+ await s.markTerminal(b.runId, {
380
+ status: "failed",
246
381
  error: {
247
- code: "BOOM",
248
- message: "boom"
249
- },
250
- attempts: 1
382
+ code: "X",
383
+ message: "x"
384
+ }
251
385
  });
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");
386
+ const after = /* @__PURE__ */ new Date("2020-06-01T00:00:00Z");
387
+ expect(await s.deleteRunsOlderThan(/* @__PURE__ */ new Date("2019-01-01T00:00:00Z"), 100)).toBe(0);
388
+ expect(await s.deleteRunsOlderThan(after, 1)).toBe(1);
389
+ expect(await s.deleteRunsOlderThan(after, 1)).toBe(1);
390
+ expect(await s.deleteRunsOlderThan(after, 1)).toBe(0);
260
391
  });
261
392
  it("checkpointStep on an unknown run is rejected (no orphan step)", async () => {
262
393
  await expect((await makeStore()).checkpointStep({
@@ -477,7 +608,7 @@ const queueConformance = (label, makeQueue) => {
477
608
  const q = await makeQueue();
478
609
  await q.enqueue("r1");
479
610
  const leases = await q.claim({
480
- max: 10,
611
+ limit: 10,
481
612
  leaseMs: 1e3,
482
613
  now: at(0)
483
614
  });
@@ -485,16 +616,41 @@ const queueConformance = (label, makeQueue) => {
485
616
  expect(leases[0].token).toBeTruthy();
486
617
  expect(leases[0].expiresAt.getTime()).toBe(at(1e3).getTime());
487
618
  });
619
+ it("depth reports backlog, in-flight, and the oldest-claimable age", async () => {
620
+ const q = await makeQueue();
621
+ expect(await q.depth(at(0))).toEqual({
622
+ claimable: 0,
623
+ leased: 0,
624
+ oldestClaimableAgeMs: null
625
+ });
626
+ await q.enqueue("r1", { runAt: at(0) });
627
+ await q.enqueue("r2", { runAt: at(5e3) });
628
+ expect(await q.depth(at(1e3))).toEqual({
629
+ claimable: 1,
630
+ leased: 0,
631
+ oldestClaimableAgeMs: 1e3
632
+ });
633
+ expect((await q.claim({
634
+ limit: 10,
635
+ leaseMs: 1e3,
636
+ now: at(1e3)
637
+ })).map((l) => l.runId)).toEqual(["r1"]);
638
+ expect(await q.depth(at(1e3))).toEqual({
639
+ claimable: 0,
640
+ leased: 1,
641
+ oldestClaimableAgeMs: null
642
+ });
643
+ });
488
644
  it("a future runAt is not claimable until due", async () => {
489
645
  const q = await makeQueue();
490
646
  await q.enqueue("r1", { runAt: at(5e3) });
491
647
  expect(await q.claim({
492
- max: 10,
648
+ limit: 10,
493
649
  leaseMs: 1e3,
494
650
  now: at(0)
495
651
  })).toEqual([]);
496
652
  expect((await q.claim({
497
- max: 10,
653
+ limit: 10,
498
654
  leaseMs: 1e3,
499
655
  now: at(5e3)
500
656
  })).map((l) => l.runId)).toEqual(["r1"]);
@@ -503,12 +659,12 @@ const queueConformance = (label, makeQueue) => {
503
659
  const q = await makeQueue();
504
660
  await q.enqueue("r1");
505
661
  expect(await q.claim({
506
- max: 10,
662
+ limit: 10,
507
663
  leaseMs: 1e3,
508
664
  now: at(0)
509
665
  })).toHaveLength(1);
510
666
  expect(await q.claim({
511
- max: 10,
667
+ limit: 10,
512
668
  leaseMs: 1e3,
513
669
  now: at(500)
514
670
  })).toEqual([]);
@@ -517,12 +673,12 @@ const queueConformance = (label, makeQueue) => {
517
673
  const q = await makeQueue();
518
674
  await q.enqueue("r1");
519
675
  const first = await q.claim({
520
- max: 10,
676
+ limit: 10,
521
677
  leaseMs: 1e3,
522
678
  now: at(0)
523
679
  });
524
680
  const reclaim = await q.claim({
525
- max: 10,
681
+ limit: 10,
526
682
  leaseMs: 1e3,
527
683
  now: at(1001)
528
684
  });
@@ -533,7 +689,7 @@ const queueConformance = (label, makeQueue) => {
533
689
  const q = await makeQueue();
534
690
  await q.enqueue("r1");
535
691
  const [lease] = await q.claim({
536
- max: 10,
692
+ limit: 10,
537
693
  leaseMs: 1e3,
538
694
  now: at(0)
539
695
  });
@@ -542,12 +698,12 @@ const queueConformance = (label, makeQueue) => {
542
698
  now: at(500)
543
699
  });
544
700
  expect(await q.claim({
545
- max: 10,
701
+ limit: 10,
546
702
  leaseMs: 1e3,
547
703
  now: at(1001)
548
704
  })).toEqual([]);
549
705
  expect((await q.claim({
550
- max: 10,
706
+ limit: 10,
551
707
  leaseMs: 1e3,
552
708
  now: at(1501)
553
709
  })).map((l) => l.runId)).toEqual(["r1"]);
@@ -556,12 +712,12 @@ const queueConformance = (label, makeQueue) => {
556
712
  const q = await makeQueue();
557
713
  await q.enqueue("r1");
558
714
  const [stale] = await q.claim({
559
- max: 10,
715
+ limit: 10,
560
716
  leaseMs: 1e3,
561
717
  now: at(0)
562
718
  });
563
719
  await q.claim({
564
- max: 10,
720
+ limit: 10,
565
721
  leaseMs: 1e3,
566
722
  now: at(1001)
567
723
  });
@@ -574,13 +730,13 @@ const queueConformance = (label, makeQueue) => {
574
730
  const q = await makeQueue();
575
731
  await q.enqueue("r1");
576
732
  const [lease] = await q.claim({
577
- max: 10,
733
+ limit: 10,
578
734
  leaseMs: 1e3,
579
735
  now: at(0)
580
736
  });
581
737
  await q.ack(lease, { now: at(1) });
582
738
  expect(await q.claim({
583
- max: 10,
739
+ limit: 10,
584
740
  leaseMs: 1e3,
585
741
  now: at(2e3)
586
742
  })).toEqual([]);
@@ -589,18 +745,18 @@ const queueConformance = (label, makeQueue) => {
589
745
  const q = await makeQueue();
590
746
  await q.enqueue("r1");
591
747
  const [stale] = await q.claim({
592
- max: 10,
748
+ limit: 10,
593
749
  leaseMs: 1e3,
594
750
  now: at(0)
595
751
  });
596
752
  await q.claim({
597
- max: 10,
753
+ limit: 10,
598
754
  leaseMs: 1e3,
599
755
  now: at(1001)
600
756
  });
601
757
  await q.ack(stale, { now: at(1002) });
602
758
  expect((await q.claim({
603
- max: 10,
759
+ limit: 10,
604
760
  leaseMs: 1e3,
605
761
  now: at(2002)
606
762
  })).map((l) => l.runId)).toEqual(["r1"]);
@@ -609,14 +765,14 @@ const queueConformance = (label, makeQueue) => {
609
765
  const q = await makeQueue();
610
766
  await q.enqueue("r1");
611
767
  const [lease] = await q.claim({
612
- max: 10,
768
+ limit: 10,
613
769
  leaseMs: 1e3,
614
770
  now: at(0)
615
771
  });
616
772
  await q.enqueue("r1");
617
773
  await q.ack(lease, { now: at(1) });
618
774
  expect((await q.claim({
619
- max: 10,
775
+ limit: 10,
620
776
  leaseMs: 1e3,
621
777
  now: at(2)
622
778
  })).map((l) => l.runId)).toEqual(["r1"]);
@@ -625,13 +781,13 @@ const queueConformance = (label, makeQueue) => {
625
781
  const q = await makeQueue();
626
782
  await q.enqueue("r1");
627
783
  const [lease] = await q.claim({
628
- max: 10,
784
+ limit: 10,
629
785
  leaseMs: 1e3,
630
786
  now: at(0)
631
787
  });
632
788
  await q.ack(lease, { now: at(1001) });
633
789
  expect((await q.claim({
634
- max: 10,
790
+ limit: 10,
635
791
  leaseMs: 1e3,
636
792
  now: at(1002)
637
793
  })).map((l) => l.runId)).toEqual(["r1"]);
@@ -640,7 +796,7 @@ const queueConformance = (label, makeQueue) => {
640
796
  const q = await makeQueue();
641
797
  await q.enqueue("r1");
642
798
  const [lease] = await q.claim({
643
- max: 10,
799
+ limit: 10,
644
800
  leaseMs: 1e3,
645
801
  now: at(0)
646
802
  });
@@ -653,59 +809,22 @@ const queueConformance = (label, makeQueue) => {
653
809
  const q = await makeQueue();
654
810
  await q.enqueue("r1");
655
811
  await q.claim({
656
- max: 10,
812
+ limit: 10,
657
813
  leaseMs: 1e3,
658
814
  now: at(0)
659
815
  });
660
816
  await q.enqueue("r1", { priority: 1 });
661
817
  expect(await q.claim({
662
- max: 10,
818
+ limit: 10,
663
819
  leaseMs: 1e3,
664
820
  now: at(500)
665
821
  })).toEqual([]);
666
822
  });
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 () => {
823
+ it("claim honours the batch limit", async () => {
705
824
  const q = await makeQueue();
706
825
  for (let i = 0; i < 5; i++) await q.enqueue(`r${i}`);
707
826
  expect(await q.claim({
708
- max: 2,
827
+ limit: 2,
709
828
  leaseMs: 1e3,
710
829
  now: at(0)
711
830
  })).toHaveLength(2);
@@ -715,7 +834,7 @@ const queueConformance = (label, makeQueue) => {
715
834
  await q.enqueue("low", { priority: 10 });
716
835
  await q.enqueue("high", { priority: 1 });
717
836
  const [first] = await q.claim({
718
- max: 1,
837
+ limit: 1,
719
838
  leaseMs: 1e3,
720
839
  now: at(0)
721
840
  });
@@ -726,7 +845,7 @@ const queueConformance = (label, makeQueue) => {
726
845
  await q.enqueue("r1");
727
846
  await q.enqueue("r1", { priority: 5 });
728
847
  expect((await q.claim({
729
- max: 10,
848
+ limit: 10,
730
849
  leaseMs: 1e3,
731
850
  now: at(0)
732
851
  })).map((l) => l.runId)).toEqual(["r1"]);
@@ -746,11 +865,11 @@ const timerConformance = (label, makeTimer) => {
746
865
  await tm.schedule("r1", at(1e3));
747
866
  expect(await tm.dueBatch({
748
867
  now: at(999),
749
- max: 10
868
+ limit: 10
750
869
  })).toEqual([]);
751
870
  expect(await tm.dueBatch({
752
871
  now: at(1e3),
753
- max: 10
872
+ limit: 10
754
873
  })).toEqual(["r1"]);
755
874
  });
756
875
  it("dueBatch fires exactly once — a consumed timer is not returned again", async () => {
@@ -758,11 +877,11 @@ const timerConformance = (label, makeTimer) => {
758
877
  await tm.schedule("r1", at(0));
759
878
  expect(await tm.dueBatch({
760
879
  now: at(1),
761
- max: 10
880
+ limit: 10
762
881
  })).toEqual(["r1"]);
763
882
  expect(await tm.dueBatch({
764
883
  now: at(2),
765
- max: 10
884
+ limit: 10
766
885
  })).toEqual([]);
767
886
  });
768
887
  it("reschedule is an upsert — the latest fireAt wins", async () => {
@@ -771,11 +890,11 @@ const timerConformance = (label, makeTimer) => {
771
890
  await tm.schedule("r1", at(5e3));
772
891
  expect(await tm.dueBatch({
773
892
  now: at(2e3),
774
- max: 10
893
+ limit: 10
775
894
  })).toEqual([]);
776
895
  expect(await tm.dueBatch({
777
896
  now: at(5e3),
778
- max: 10
897
+ limit: 10
779
898
  })).toEqual(["r1"]);
780
899
  });
781
900
  it("reschedule EARLIER also wins — upsert, not a LEAST(fire_at) merge", async () => {
@@ -784,7 +903,7 @@ const timerConformance = (label, makeTimer) => {
784
903
  await tm.schedule("r1", at(1e3));
785
904
  expect(await tm.dueBatch({
786
905
  now: at(1e3),
787
- max: 10
906
+ limit: 10
788
907
  })).toEqual(["r1"]);
789
908
  });
790
909
  it("cancel removes a pending timer", async () => {
@@ -793,19 +912,40 @@ const timerConformance = (label, makeTimer) => {
793
912
  await tm.cancel("r1");
794
913
  expect(await tm.dueBatch({
795
914
  now: at(2e3),
796
- max: 10
915
+ limit: 10
797
916
  })).toEqual([]);
798
917
  });
799
- it("dueBatch honours max and returns earliest-first", async () => {
918
+ it("dueBatch honours limit and returns earliest-first", async () => {
800
919
  const tm = await makeTimer();
801
920
  await tm.schedule("late", at(300));
802
921
  await tm.schedule("early", at(100));
803
922
  await tm.schedule("mid", at(200));
804
923
  expect(await tm.dueBatch({
805
924
  now: at(1e3),
806
- max: 2
925
+ limit: 2
807
926
  })).toEqual(["early", "mid"]);
808
927
  });
928
+ it("nextDueAt is null when nothing is pending", async () => {
929
+ expect(await (await makeTimer()).nextDueAt(at(0))).toBeNull();
930
+ });
931
+ it("nextDueAt returns the earliest timer due strictly after now", async () => {
932
+ const tm = await makeTimer();
933
+ await tm.schedule("late", at(3e3));
934
+ await tm.schedule("early", at(1e3));
935
+ await tm.schedule("mid", at(2e3));
936
+ expect(await tm.nextDueAt(at(500))).toEqual(at(1e3));
937
+ expect(await tm.nextDueAt(at(1e3))).toEqual(at(2e3));
938
+ expect(await tm.nextDueAt(at(3e3))).toBeNull();
939
+ });
940
+ it("nextDueAt reflects reschedule and cancel", async () => {
941
+ const tm = await makeTimer();
942
+ await tm.schedule("r1", at(5e3));
943
+ expect(await tm.nextDueAt(at(0))).toEqual(at(5e3));
944
+ await tm.schedule("r1", at(1e3));
945
+ expect(await tm.nextDueAt(at(0))).toEqual(at(1e3));
946
+ await tm.cancel("r1");
947
+ expect(await tm.nextDueAt(at(0))).toBeNull();
948
+ });
809
949
  });
810
950
  };
811
951
  //#endregion
@@ -893,7 +1033,7 @@ const outboxConformance = (label, makeBackend) => {
893
1033
  attempts: 1
894
1034
  }, { enqueue: [{ runId }] });
895
1035
  expect((await queue.claim({
896
- max: 10,
1036
+ limit: 10,
897
1037
  leaseMs: 1e3,
898
1038
  now: at(0)
899
1039
  })).map((l) => l.runId)).toEqual([runId]);
@@ -922,7 +1062,7 @@ const outboxConformance = (label, makeBackend) => {
922
1062
  }] });
923
1063
  expect((await store.loadRun(childId))?.run.input).toEqual({ n: 1 });
924
1064
  expect((await queue.claim({
925
- max: 10,
1065
+ limit: 10,
926
1066
  leaseMs: 1e3,
927
1067
  now: at(0)
928
1068
  })).map((l) => l.runId)).toContain(childId);
@@ -958,7 +1098,7 @@ const outboxConformance = (label, makeBackend) => {
958
1098
  attempts: 2
959
1099
  }, spawn);
960
1100
  expect((await queue.claim({
961
- max: 10,
1101
+ limit: 10,
962
1102
  leaseMs: 1e3,
963
1103
  now: at(0)
964
1104
  })).filter((l) => l.runId === childId)).toHaveLength(1);
@@ -978,11 +1118,11 @@ const outboxConformance = (label, makeBackend) => {
978
1118
  expect((await store.loadRun(runId))?.run.status).toBe("sleeping");
979
1119
  expect(await timer.dueBatch({
980
1120
  now: at(4999),
981
- max: 10
1121
+ limit: 10
982
1122
  })).toEqual([]);
983
1123
  expect(await timer.dueBatch({
984
1124
  now: at(5e3),
985
- max: 10
1125
+ limit: 10
986
1126
  })).toEqual([runId]);
987
1127
  });
988
1128
  it("suspendRun is a no-op on a terminal run — no zombie wake timer", async () => {
@@ -1003,7 +1143,7 @@ const outboxConformance = (label, makeBackend) => {
1003
1143
  expect((await store.loadRun(runId))?.run.status).toBe("done");
1004
1144
  expect(await timer.dueBatch({
1005
1145
  now: at(5e3),
1006
- max: 10
1146
+ limit: 10
1007
1147
  })).toEqual([]);
1008
1148
  });
1009
1149
  it("markTerminal wakes a suspended parent atomically (enqueue in the same write)", async () => {
@@ -1024,7 +1164,7 @@ const outboxConformance = (label, makeBackend) => {
1024
1164
  output: 7
1025
1165
  }, { enqueue: [{ runId: parent.runId }] });
1026
1166
  expect((await queue.claim({
1027
- max: 10,
1167
+ limit: 10,
1028
1168
  leaseMs: 1e3,
1029
1169
  now: at(0)
1030
1170
  })).map((l) => l.runId)).toContain(parent.runId);
@@ -1047,7 +1187,7 @@ const outboxConformance = (label, makeBackend) => {
1047
1187
  }, { cancelTimers: [runId] });
1048
1188
  expect(await timer.dueBatch({
1049
1189
  now: at(5e3),
1050
- max: 10
1190
+ limit: 10
1051
1191
  })).toEqual([]);
1052
1192
  });
1053
1193
  it("a canceled run's markTerminal is sticky AND skips its outbox", async () => {
@@ -1064,7 +1204,7 @@ const outboxConformance = (label, makeBackend) => {
1064
1204
  }, { enqueue: [{ runId }] });
1065
1205
  expect((await store.loadRun(runId))?.run.status).toBe("canceled");
1066
1206
  expect(await queue.claim({
1067
- max: 10,
1207
+ limit: 10,
1068
1208
  leaseMs: 1e3,
1069
1209
  now: at(0)
1070
1210
  })).toEqual([]);
@@ -1096,7 +1236,7 @@ const signalConformance = (label, makeBackend) => {
1096
1236
  payload: { ok: true }
1097
1237
  }]);
1098
1238
  expect((await queue.claim({
1099
- max: 10,
1239
+ limit: 10,
1100
1240
  leaseMs: 1e3,
1101
1241
  now: at(0)
1102
1242
  })).map((l) => l.runId)).toEqual([runId]);
@@ -1135,6 +1275,37 @@ const signalConformance = (label, makeBackend) => {
1135
1275
  expect(after?.signals.map((s) => s.name)).toEqual(["b"]);
1136
1276
  expect(after?.steps.get("wait")?.result).toBe("first");
1137
1277
  });
1278
+ it("requireVersion commits while the job version is unchanged, refuses once a racing signal moves it", async () => {
1279
+ const { store, queue } = await makeBackend();
1280
+ const { runId } = await store.startRun({
1281
+ name: "f",
1282
+ version: 1,
1283
+ input: {}
1284
+ });
1285
+ await queue.enqueue(runId);
1286
+ const [lease] = await queue.claim({
1287
+ limit: 1,
1288
+ leaseMs: 1e3,
1289
+ now: at(0)
1290
+ });
1291
+ expect((await store.checkpointStep({
1292
+ runId,
1293
+ cursorKey: "res",
1294
+ status: "ok",
1295
+ result: { received: false },
1296
+ attempts: 1
1297
+ }, { requireVersion: lease.version })).committed).not.toBe(false);
1298
+ expect((await store.loadRun(runId))?.steps.get("res")?.result).toEqual({ received: false });
1299
+ await store.postSignal(runId, "go", "payload");
1300
+ expect((await store.checkpointStep({
1301
+ runId,
1302
+ cursorKey: "res2",
1303
+ status: "ok",
1304
+ result: { received: false },
1305
+ attempts: 1
1306
+ }, { requireVersion: lease.version })).committed).toBe(false);
1307
+ expect((await store.loadRun(runId))?.steps.get("res2")).toBeUndefined();
1308
+ });
1138
1309
  it("distinct signals to a run preserve delivery order in the inbox", async () => {
1139
1310
  const { store } = await makeBackend();
1140
1311
  const { runId } = await store.startRun({
@@ -1228,6 +1399,61 @@ const reconcileConformance = (label, makeBackend) => {
1228
1399
  });
1229
1400
  expect(await store.orphanedRuns(10)).toContain(parent.runId);
1230
1401
  });
1402
+ it("a fan-out parent with a still-running child is NOT an orphan (join not resolved)", async () => {
1403
+ const { store } = await makeBackend();
1404
+ const parent = await store.startRun({
1405
+ name: "p",
1406
+ version: 1,
1407
+ input: {}
1408
+ });
1409
+ const kids = await store.startManyRuns([{
1410
+ name: "c",
1411
+ version: 1,
1412
+ input: {},
1413
+ parentRunId: parent.runId
1414
+ }, {
1415
+ name: "c",
1416
+ version: 1,
1417
+ input: {},
1418
+ parentRunId: parent.runId
1419
+ }]);
1420
+ await store.markRunning(parent.runId);
1421
+ await store.suspendRun(parent.runId, "awaiting_child");
1422
+ await store.markTerminal(kids[0].runId, {
1423
+ status: "done",
1424
+ output: 1
1425
+ });
1426
+ expect(await store.orphanedRuns(10)).not.toContain(parent.runId);
1427
+ });
1428
+ it("a fan-out parent with a FAILED child IS an orphan even while siblings run (fast-fail)", async () => {
1429
+ const { store } = await makeBackend();
1430
+ const parent = await store.startRun({
1431
+ name: "p",
1432
+ version: 1,
1433
+ input: {}
1434
+ });
1435
+ const kids = await store.startManyRuns([{
1436
+ name: "c",
1437
+ version: 1,
1438
+ input: {},
1439
+ parentRunId: parent.runId
1440
+ }, {
1441
+ name: "c",
1442
+ version: 1,
1443
+ input: {},
1444
+ parentRunId: parent.runId
1445
+ }]);
1446
+ await store.markRunning(parent.runId);
1447
+ await store.suspendRun(parent.runId, "awaiting_child");
1448
+ await store.markTerminal(kids[0].runId, {
1449
+ status: "failed",
1450
+ error: {
1451
+ code: "X",
1452
+ message: "boom"
1453
+ }
1454
+ });
1455
+ expect(await store.orphanedRuns(10)).toContain(parent.runId);
1456
+ });
1231
1457
  it("a terminal run is never an orphan", async () => {
1232
1458
  const { store } = await makeBackend();
1233
1459
  const { runId } = await store.startRun({
@@ -1300,4 +1526,462 @@ const cronConformance = (label, makeStore) => {
1300
1526
  });
1301
1527
  };
1302
1528
  //#endregion
1303
- export { cronConformance, outboxConformance, queueConformance, reconcileConformance, signalConformance, storeConformance, timerConformance, wakeupConformance };
1529
+ //#region src/engine.ts
1530
+ /**
1531
+ * Engine-behavior conformance: the composed durable behaviors (retry/dead-letter, signal
1532
+ * resume, cancel cascade, drift) run against every backend, not just memory — so a real
1533
+ * serialization/consistency regression can't hide behind the port-conformance suites.
1534
+ */
1535
+ const engineConformance = (label, makeBackend) => {
1536
+ describe(`engine conformance (${label})`, () => {
1537
+ const base = {
1538
+ batchMax: 16,
1539
+ leaseMs: 6e5
1540
+ };
1541
+ const drive = async (backend, flows, runId, retry) => {
1542
+ let clock = /* @__PURE__ */ new Date("2030-01-01T00:00:00Z");
1543
+ const now = () => clock;
1544
+ for (let i = 0; i < 200; i++) {
1545
+ await tickOnce(backend, flows, {
1546
+ ...base,
1547
+ now,
1548
+ retry
1549
+ });
1550
+ const run = await backend.store.loadRunRow(runId);
1551
+ if (run && isTerminal(run.status)) return run;
1552
+ clock = new Date(clock.getTime() + 6e4);
1553
+ }
1554
+ throw new Error("run did not settle");
1555
+ };
1556
+ it("a run dispatched far more than maxAttempts times (many sleeps) completes", async () => {
1557
+ const backend = await makeBackend();
1558
+ const flow = defineFlow({
1559
+ name: "poller",
1560
+ version: 1,
1561
+ run: async (ctx) => {
1562
+ for (let i = 0; i < 6; i++) await ctx.sleep(1e3);
1563
+ return "settled";
1564
+ }
1565
+ });
1566
+ const runId = await submit(backend, flow, {});
1567
+ expect(await drive(backend, registry([flow]), runId, {
1568
+ maxAttempts: 3,
1569
+ baseDelayMs: 1,
1570
+ maxDelayMs: 1
1571
+ })).toMatchObject({
1572
+ status: "done",
1573
+ output: "settled"
1574
+ });
1575
+ });
1576
+ it("runs a builder flow with a durable sleep to completion", async () => {
1577
+ const backend = await makeBackend();
1578
+ const flow = builder(`${label}-sleep`, 1).step("doubled", (acc) => acc.input.x * 2).step("nap", async (_acc, ctx) => {
1579
+ await ctx.sleep(5e3);
1580
+ return "rested";
1581
+ }).output((acc) => ({
1582
+ doubled: acc.doubled,
1583
+ nap: acc.nap
1584
+ }));
1585
+ const runId = await submit(backend, flow, { x: 21 });
1586
+ expect(await drive(backend, registry([flow]), runId)).toMatchObject({
1587
+ status: "done",
1588
+ output: {
1589
+ doubled: 42,
1590
+ nap: "rested"
1591
+ }
1592
+ });
1593
+ });
1594
+ it("a genuinely failing step still dead-letters after its retry budget", async () => {
1595
+ const backend = await makeBackend();
1596
+ const flow = defineFlow({
1597
+ name: "always-throws",
1598
+ version: 1,
1599
+ run: async (ctx) => ctx.step("boom", () => {
1600
+ throw new Error("nope");
1601
+ })
1602
+ });
1603
+ const runId = await submit(backend, flow, {});
1604
+ expect((await drive(backend, registry([flow]), runId, {
1605
+ maxAttempts: 2,
1606
+ baseDelayMs: 1,
1607
+ maxDelayMs: 1
1608
+ })).status).toBe("failed");
1609
+ });
1610
+ it("dead-letters a poison-pill (uncatchable-crash) run once attempts exceed the cap", async () => {
1611
+ const backend = await makeBackend();
1612
+ const flow = defineFlow({
1613
+ name: "poison",
1614
+ version: 1,
1615
+ run: async () => 1
1616
+ });
1617
+ const runId = await submit(backend, flow, {});
1618
+ for (let i = 0; i < 5; i++) await backend.store.markRunning(runId);
1619
+ await tickOnce(backend, registry([flow]), {
1620
+ ...base,
1621
+ now: () => /* @__PURE__ */ new Date("2030-01-01T00:00:00Z"),
1622
+ retry: {
1623
+ maxAttempts: 3,
1624
+ baseDelayMs: 1,
1625
+ maxDelayMs: 1
1626
+ }
1627
+ });
1628
+ const run = await backend.store.loadRunRow(runId);
1629
+ expect(run?.status).toBe("failed");
1630
+ expect(run?.error?.code).toBe("RUN_ATTEMPTS_EXHAUSTED");
1631
+ });
1632
+ it("awaits an external signal, parks, and resumes with its payload", async () => {
1633
+ const backend = await makeBackend();
1634
+ const flow = defineFlow({
1635
+ name: "approve",
1636
+ version: 1,
1637
+ signals: { go: signalType() },
1638
+ run: async (ctx) => {
1639
+ return (await ctx.signal("go")).ok ? "shipped" : "held";
1640
+ }
1641
+ });
1642
+ const flows = registry([flow]);
1643
+ const runId = await submit(backend, flow, {});
1644
+ await tickOnce(backend, flows, {
1645
+ ...base,
1646
+ now: () => /* @__PURE__ */ new Date("2030-01-01T00:00:00Z")
1647
+ });
1648
+ expect((await backend.store.loadRunRow(runId))?.status).toBe("awaiting_signal");
1649
+ await signalRun(backend, runId, "go", { ok: true });
1650
+ expect(await drive(backend, flows, runId)).toMatchObject({
1651
+ status: "done",
1652
+ output: "shipped"
1653
+ });
1654
+ });
1655
+ it("cancels a run and cascades to grandchildren", async () => {
1656
+ const backend = await makeBackend();
1657
+ const grandchild = defineFlow({
1658
+ name: "gc",
1659
+ version: 1,
1660
+ run: async (ctx) => {
1661
+ await ctx.sleep(1e7);
1662
+ return "gc";
1663
+ }
1664
+ });
1665
+ const child = defineFlow({
1666
+ name: "c",
1667
+ version: 1,
1668
+ run: async (ctx) => ctx.invoke(grandchild, {})
1669
+ });
1670
+ const parent = defineFlow({
1671
+ name: "p",
1672
+ version: 1,
1673
+ run: async (ctx) => ctx.invoke(child, {})
1674
+ });
1675
+ const flows = registry([
1676
+ parent,
1677
+ child,
1678
+ grandchild
1679
+ ]);
1680
+ const parentId = await submit(backend, parent, {});
1681
+ let clock = /* @__PURE__ */ new Date("2030-01-01T00:00:00Z");
1682
+ for (let i = 0; i < 10; i++) {
1683
+ await tickOnce(backend, flows, {
1684
+ ...base,
1685
+ now: () => clock
1686
+ });
1687
+ clock = new Date(clock.getTime() + 1e3);
1688
+ }
1689
+ await cancelRun(backend, parentId);
1690
+ const kids = await backend.store.childrenOf(parentId);
1691
+ expect(kids.length).toBeGreaterThan(0);
1692
+ const childId = kids[0].id;
1693
+ const grandkids = await backend.store.childrenOf(childId);
1694
+ expect((await backend.store.loadRunRow(parentId))?.status).toBe("canceled");
1695
+ expect((await backend.store.loadRunRow(childId))?.status).toBe("canceled");
1696
+ if (grandkids[0]) expect((await backend.store.loadRunRow(grandkids[0].id))?.status).toBe("canceled");
1697
+ });
1698
+ it("a child of an already-terminated parent cancels itself on dispatch (crash-safe cascade)", async () => {
1699
+ const backend = await makeBackend();
1700
+ const orphan = defineFlow({
1701
+ name: "orphan",
1702
+ version: 1,
1703
+ run: async (ctx) => {
1704
+ await ctx.sleep(1e3);
1705
+ return 1;
1706
+ }
1707
+ });
1708
+ const { runId: parentId } = await backend.store.startRun({
1709
+ name: "p",
1710
+ version: 1,
1711
+ input: {}
1712
+ });
1713
+ await backend.store.markTerminal(parentId, {
1714
+ status: "failed",
1715
+ error: {
1716
+ code: "X",
1717
+ message: "parent died"
1718
+ }
1719
+ });
1720
+ const { runId: childId } = await backend.store.startRun({
1721
+ name: "orphan",
1722
+ version: 1,
1723
+ input: {},
1724
+ parentRunId: parentId,
1725
+ parentCursorKey: "s0"
1726
+ });
1727
+ await backend.queue.enqueue(childId);
1728
+ await tickOnce(backend, registry([orphan]), {
1729
+ ...base,
1730
+ now: () => /* @__PURE__ */ new Date("2030-01-01T00:00:00Z")
1731
+ });
1732
+ expect((await backend.store.loadRunRow(childId))?.status).toBe("canceled");
1733
+ });
1734
+ it("reconcile cancels a sleeping child left behind by a dead parent", async () => {
1735
+ const backend = await makeBackend();
1736
+ const now = () => /* @__PURE__ */ new Date("2030-01-01T00:00:00Z");
1737
+ const orphan = defineFlow({
1738
+ name: "orphan2",
1739
+ version: 1,
1740
+ run: async (ctx) => {
1741
+ await ctx.sleep(1e7);
1742
+ return 1;
1743
+ }
1744
+ });
1745
+ const { runId: parentId } = await backend.store.startRun({
1746
+ name: "p",
1747
+ version: 1,
1748
+ input: {}
1749
+ });
1750
+ const { runId: childId } = await backend.store.startRun({
1751
+ name: "orphan2",
1752
+ version: 1,
1753
+ input: {},
1754
+ parentRunId: parentId,
1755
+ parentCursorKey: "s0"
1756
+ });
1757
+ await backend.queue.enqueue(childId);
1758
+ await tickOnce(backend, registry([orphan]), {
1759
+ ...base,
1760
+ now
1761
+ });
1762
+ expect((await backend.store.loadRunRow(childId))?.status).toBe("sleeping");
1763
+ await backend.store.markTerminal(parentId, {
1764
+ status: "failed",
1765
+ error: {
1766
+ code: "X",
1767
+ message: "died"
1768
+ }
1769
+ });
1770
+ await reconcile(backend, { limit: 16 });
1771
+ await tickOnce(backend, registry([orphan]), {
1772
+ ...base,
1773
+ now
1774
+ });
1775
+ expect((await backend.store.loadRunRow(childId))?.status).toBe("canceled");
1776
+ });
1777
+ it("fans out children in parallel and joins their outputs in order", async () => {
1778
+ const backend = await makeBackend();
1779
+ const dbl = defineFlow({
1780
+ name: "dbl",
1781
+ version: 1,
1782
+ run: async (_ctx, n) => n * 2
1783
+ });
1784
+ const parent = defineFlow({
1785
+ name: "fan",
1786
+ version: 1,
1787
+ run: async (ctx) => ctx.invoke([
1788
+ {
1789
+ flow: dbl,
1790
+ input: 1
1791
+ },
1792
+ {
1793
+ flow: dbl,
1794
+ input: 2
1795
+ },
1796
+ {
1797
+ flow: dbl,
1798
+ input: 3
1799
+ }
1800
+ ])
1801
+ });
1802
+ const runId = await submit(backend, parent, {});
1803
+ expect(await drive(backend, registry([parent, dbl]), runId)).toMatchObject({
1804
+ status: "done",
1805
+ output: [
1806
+ 2,
1807
+ 4,
1808
+ 6
1809
+ ]
1810
+ });
1811
+ });
1812
+ it("rejects a fan-out beyond the cap", async () => {
1813
+ const backend = await makeBackend();
1814
+ const noop = defineFlow({
1815
+ name: "noop",
1816
+ version: 1,
1817
+ run: async () => 1
1818
+ });
1819
+ const parent = defineFlow({
1820
+ name: "toobig",
1821
+ version: 1,
1822
+ run: async (ctx) => ctx.invoke(Array.from({ length: 10001 }, () => ({
1823
+ flow: noop,
1824
+ input: {}
1825
+ })))
1826
+ });
1827
+ const runId = await submit(backend, parent, {});
1828
+ await tickOnce(backend, registry([parent, noop]), {
1829
+ ...base,
1830
+ now: () => /* @__PURE__ */ new Date("2030-01-01T00:00:00Z"),
1831
+ retry: {
1832
+ maxAttempts: 1,
1833
+ baseDelayMs: 1,
1834
+ maxDelayMs: 1
1835
+ }
1836
+ });
1837
+ expect((await backend.store.loadRunRow(runId))?.status).toBe("failed");
1838
+ });
1839
+ it("caps invoke recursion at the depth limit and persists each run's depth", async () => {
1840
+ const backend = await makeBackend();
1841
+ const recurse = defineFlow({
1842
+ name: "recurse",
1843
+ version: 1,
1844
+ policy: { maxDepth: 3 },
1845
+ run: async (ctx, n) => ctx.invoke(recurse, n + 1)
1846
+ });
1847
+ const runId = await submit(backend, recurse, 0);
1848
+ expect((await drive(backend, registry([recurse]), runId, {
1849
+ maxAttempts: 1,
1850
+ baseDelayMs: 1,
1851
+ maxDelayMs: 1
1852
+ })).status).toBe("failed");
1853
+ let cur = runId;
1854
+ let deepest = await backend.store.loadRunRow(cur);
1855
+ for (;;) {
1856
+ const kids = await backend.store.childrenOf(cur);
1857
+ if (kids.length === 0) break;
1858
+ deepest = kids[0];
1859
+ cur = kids[0].id;
1860
+ }
1861
+ expect(deepest?.depth).toBe(3);
1862
+ expect(deepest?.error?.message).toContain("depth");
1863
+ });
1864
+ it("fans out a batch larger than one spawn chunk (crosses chunk boundaries)", async () => {
1865
+ const backend = await makeBackend();
1866
+ const inc = defineFlow({
1867
+ name: "inc",
1868
+ version: 1,
1869
+ run: async (_ctx, n) => n + 1
1870
+ });
1871
+ const parent = defineFlow({
1872
+ name: "wide",
1873
+ version: 1,
1874
+ run: async (ctx) => ctx.invoke(Array.from({ length: 45 }, (_v, n) => ({
1875
+ flow: inc,
1876
+ input: n
1877
+ })))
1878
+ });
1879
+ const runId = await submit(backend, parent, {});
1880
+ const run = await drive(backend, registry([parent, inc]), runId);
1881
+ expect(run.status).toBe("done");
1882
+ expect(run.output).toEqual(Array.from({ length: 45 }, (_v, n) => n + 1));
1883
+ });
1884
+ it("fast-fails a fan-out and cancels the running siblings when one child fails", async () => {
1885
+ const backend = await makeBackend();
1886
+ const boom = defineFlow({
1887
+ name: "boom",
1888
+ version: 1,
1889
+ run: async () => {
1890
+ throw new Error("boom");
1891
+ }
1892
+ });
1893
+ const slow = defineFlow({
1894
+ name: "slow",
1895
+ version: 1,
1896
+ run: async (ctx) => {
1897
+ await ctx.sleep(1e7);
1898
+ return 1;
1899
+ }
1900
+ });
1901
+ const parent = defineFlow({
1902
+ name: "fanfail",
1903
+ version: 1,
1904
+ run: async (ctx) => ctx.invoke([{
1905
+ flow: boom,
1906
+ input: {}
1907
+ }, {
1908
+ flow: slow,
1909
+ input: {}
1910
+ }])
1911
+ });
1912
+ const runId = await submit(backend, parent, {});
1913
+ expect((await drive(backend, registry([
1914
+ parent,
1915
+ boom,
1916
+ slow
1917
+ ]), runId, {
1918
+ maxAttempts: 1,
1919
+ baseDelayMs: 1,
1920
+ maxDelayMs: 1
1921
+ })).status).toBe("failed");
1922
+ const kids = await backend.store.childrenOf(runId);
1923
+ expect(kids).toHaveLength(2);
1924
+ expect(kids.every((k) => isTerminal(k.status))).toBe(true);
1925
+ expect(kids.some((k) => k.status === "canceled")).toBe(true);
1926
+ });
1927
+ });
1928
+ };
1929
+ //#endregion
1930
+ //#region src/claim-filter.ts
1931
+ /**
1932
+ * `ClaimOpts.names` as executable invariants: a sharded worker leases only runs whose flow `name`
1933
+ * it registered. Needs the store (to create named runs), so it takes the whole backend.
1934
+ */
1935
+ const claimFilterConformance = (label, makeBackend) => {
1936
+ describe(`Claim flow-name filter — ${label}`, () => {
1937
+ it("leases only runs whose flow name is in the set", async () => {
1938
+ const { store, queue } = await makeBackend();
1939
+ const a = await store.startRun({
1940
+ name: "flow-a",
1941
+ version: 1,
1942
+ input: {}
1943
+ });
1944
+ const b = await store.startRun({
1945
+ name: "flow-b",
1946
+ version: 1,
1947
+ input: {}
1948
+ });
1949
+ await queue.enqueue(a.runId);
1950
+ await queue.enqueue(b.runId);
1951
+ expect((await queue.claim({
1952
+ limit: 10,
1953
+ leaseMs: 1e3,
1954
+ names: ["flow-a"],
1955
+ now: at(0)
1956
+ })).map((l) => l.runId)).toEqual([a.runId]);
1957
+ expect((await queue.claim({
1958
+ limit: 10,
1959
+ leaseMs: 1e3,
1960
+ names: ["flow-b"],
1961
+ now: at(0)
1962
+ })).map((l) => l.runId)).toEqual([b.runId]);
1963
+ });
1964
+ it("an empty set leases nothing; an omitted set leases everything", async () => {
1965
+ const { store, queue } = await makeBackend();
1966
+ const a = await store.startRun({
1967
+ name: "flow-a",
1968
+ version: 1,
1969
+ input: {}
1970
+ });
1971
+ await queue.enqueue(a.runId);
1972
+ expect(await queue.claim({
1973
+ limit: 10,
1974
+ leaseMs: 1e3,
1975
+ names: [],
1976
+ now: at(0)
1977
+ })).toEqual([]);
1978
+ expect((await queue.claim({
1979
+ limit: 10,
1980
+ leaseMs: 1e3,
1981
+ now: at(0)
1982
+ })).map((l) => l.runId)).toEqual([a.runId]);
1983
+ });
1984
+ });
1985
+ };
1986
+ //#endregion
1987
+ export { claimFilterConformance, cronConformance, engineConformance, outboxConformance, queueConformance, reconcileConformance, signalConformance, storeConformance, timerConformance, wakeupConformance };
package/package.json CHANGED
@@ -1,8 +1,15 @@
1
1
  {
2
2
  "name": "@iterativeflow/conformance",
3
- "version": "2.0.0-alpha.1",
3
+ "version": "2.0.0-alpha.11",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ahmedrowaihi/iterativeflow.git",
8
+ "directory": "v2/packages/conformance"
9
+ },
4
10
  "files": [
5
- "dist"
11
+ "dist",
12
+ "README.md"
6
13
  ],
7
14
  "type": "module",
8
15
  "imports": {
@@ -21,7 +28,7 @@
21
28
  "build": "tsdown"
22
29
  },
23
30
  "dependencies": {
24
- "@iterativeflow/core": "2.0.0-alpha.1"
31
+ "@iterativeflow/core": "2.0.0-alpha.11"
25
32
  },
26
33
  "peerDependencies": {
27
34
  "vitest": ">=3"