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

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,12 @@ 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
+ export { 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
233
348
  });
349
+ const live = await s.startRun({
350
+ name: "f",
351
+ version: 1,
352
+ input: {},
353
+ createdAt: old
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,17 +912,17 @@ 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
  });
809
928
  });
@@ -893,7 +1012,7 @@ const outboxConformance = (label, makeBackend) => {
893
1012
  attempts: 1
894
1013
  }, { enqueue: [{ runId }] });
895
1014
  expect((await queue.claim({
896
- max: 10,
1015
+ limit: 10,
897
1016
  leaseMs: 1e3,
898
1017
  now: at(0)
899
1018
  })).map((l) => l.runId)).toEqual([runId]);
@@ -922,7 +1041,7 @@ const outboxConformance = (label, makeBackend) => {
922
1041
  }] });
923
1042
  expect((await store.loadRun(childId))?.run.input).toEqual({ n: 1 });
924
1043
  expect((await queue.claim({
925
- max: 10,
1044
+ limit: 10,
926
1045
  leaseMs: 1e3,
927
1046
  now: at(0)
928
1047
  })).map((l) => l.runId)).toContain(childId);
@@ -958,7 +1077,7 @@ const outboxConformance = (label, makeBackend) => {
958
1077
  attempts: 2
959
1078
  }, spawn);
960
1079
  expect((await queue.claim({
961
- max: 10,
1080
+ limit: 10,
962
1081
  leaseMs: 1e3,
963
1082
  now: at(0)
964
1083
  })).filter((l) => l.runId === childId)).toHaveLength(1);
@@ -978,11 +1097,11 @@ const outboxConformance = (label, makeBackend) => {
978
1097
  expect((await store.loadRun(runId))?.run.status).toBe("sleeping");
979
1098
  expect(await timer.dueBatch({
980
1099
  now: at(4999),
981
- max: 10
1100
+ limit: 10
982
1101
  })).toEqual([]);
983
1102
  expect(await timer.dueBatch({
984
1103
  now: at(5e3),
985
- max: 10
1104
+ limit: 10
986
1105
  })).toEqual([runId]);
987
1106
  });
988
1107
  it("suspendRun is a no-op on a terminal run — no zombie wake timer", async () => {
@@ -1003,7 +1122,7 @@ const outboxConformance = (label, makeBackend) => {
1003
1122
  expect((await store.loadRun(runId))?.run.status).toBe("done");
1004
1123
  expect(await timer.dueBatch({
1005
1124
  now: at(5e3),
1006
- max: 10
1125
+ limit: 10
1007
1126
  })).toEqual([]);
1008
1127
  });
1009
1128
  it("markTerminal wakes a suspended parent atomically (enqueue in the same write)", async () => {
@@ -1024,7 +1143,7 @@ const outboxConformance = (label, makeBackend) => {
1024
1143
  output: 7
1025
1144
  }, { enqueue: [{ runId: parent.runId }] });
1026
1145
  expect((await queue.claim({
1027
- max: 10,
1146
+ limit: 10,
1028
1147
  leaseMs: 1e3,
1029
1148
  now: at(0)
1030
1149
  })).map((l) => l.runId)).toContain(parent.runId);
@@ -1047,7 +1166,7 @@ const outboxConformance = (label, makeBackend) => {
1047
1166
  }, { cancelTimers: [runId] });
1048
1167
  expect(await timer.dueBatch({
1049
1168
  now: at(5e3),
1050
- max: 10
1169
+ limit: 10
1051
1170
  })).toEqual([]);
1052
1171
  });
1053
1172
  it("a canceled run's markTerminal is sticky AND skips its outbox", async () => {
@@ -1064,7 +1183,7 @@ const outboxConformance = (label, makeBackend) => {
1064
1183
  }, { enqueue: [{ runId }] });
1065
1184
  expect((await store.loadRun(runId))?.run.status).toBe("canceled");
1066
1185
  expect(await queue.claim({
1067
- max: 10,
1186
+ limit: 10,
1068
1187
  leaseMs: 1e3,
1069
1188
  now: at(0)
1070
1189
  })).toEqual([]);
@@ -1096,7 +1215,7 @@ const signalConformance = (label, makeBackend) => {
1096
1215
  payload: { ok: true }
1097
1216
  }]);
1098
1217
  expect((await queue.claim({
1099
- max: 10,
1218
+ limit: 10,
1100
1219
  leaseMs: 1e3,
1101
1220
  now: at(0)
1102
1221
  })).map((l) => l.runId)).toEqual([runId]);
@@ -1228,6 +1347,61 @@ const reconcileConformance = (label, makeBackend) => {
1228
1347
  });
1229
1348
  expect(await store.orphanedRuns(10)).toContain(parent.runId);
1230
1349
  });
1350
+ it("a fan-out parent with a still-running child is NOT an orphan (join not resolved)", async () => {
1351
+ const { store } = await makeBackend();
1352
+ const parent = await store.startRun({
1353
+ name: "p",
1354
+ version: 1,
1355
+ input: {}
1356
+ });
1357
+ const kids = await store.startManyRuns([{
1358
+ name: "c",
1359
+ version: 1,
1360
+ input: {},
1361
+ parentRunId: parent.runId
1362
+ }, {
1363
+ name: "c",
1364
+ version: 1,
1365
+ input: {},
1366
+ parentRunId: parent.runId
1367
+ }]);
1368
+ await store.markRunning(parent.runId);
1369
+ await store.suspendRun(parent.runId, "awaiting_child");
1370
+ await store.markTerminal(kids[0].runId, {
1371
+ status: "done",
1372
+ output: 1
1373
+ });
1374
+ expect(await store.orphanedRuns(10)).not.toContain(parent.runId);
1375
+ });
1376
+ it("a fan-out parent with a FAILED child IS an orphan even while siblings run (fast-fail)", async () => {
1377
+ const { store } = await makeBackend();
1378
+ const parent = await store.startRun({
1379
+ name: "p",
1380
+ version: 1,
1381
+ input: {}
1382
+ });
1383
+ const kids = await store.startManyRuns([{
1384
+ name: "c",
1385
+ version: 1,
1386
+ input: {},
1387
+ parentRunId: parent.runId
1388
+ }, {
1389
+ name: "c",
1390
+ version: 1,
1391
+ input: {},
1392
+ parentRunId: parent.runId
1393
+ }]);
1394
+ await store.markRunning(parent.runId);
1395
+ await store.suspendRun(parent.runId, "awaiting_child");
1396
+ await store.markTerminal(kids[0].runId, {
1397
+ status: "failed",
1398
+ error: {
1399
+ code: "X",
1400
+ message: "boom"
1401
+ }
1402
+ });
1403
+ expect(await store.orphanedRuns(10)).toContain(parent.runId);
1404
+ });
1231
1405
  it("a terminal run is never an orphan", async () => {
1232
1406
  const { store } = await makeBackend();
1233
1407
  const { runId } = await store.startRun({
@@ -1300,4 +1474,405 @@ const cronConformance = (label, makeStore) => {
1300
1474
  });
1301
1475
  };
1302
1476
  //#endregion
1303
- export { cronConformance, outboxConformance, queueConformance, reconcileConformance, signalConformance, storeConformance, timerConformance, wakeupConformance };
1477
+ //#region src/engine.ts
1478
+ /**
1479
+ * Engine-behavior conformance: the composed durable behaviors (retry/dead-letter, signal
1480
+ * resume, cancel cascade, drift) run against every backend, not just memory — so a real
1481
+ * serialization/consistency regression can't hide behind the port-conformance suites.
1482
+ */
1483
+ const engineConformance = (label, makeBackend) => {
1484
+ describe(`engine conformance (${label})`, () => {
1485
+ const base = {
1486
+ batchMax: 16,
1487
+ leaseMs: 6e5
1488
+ };
1489
+ const drive = async (backend, flows, runId, retry) => {
1490
+ let clock = /* @__PURE__ */ new Date("2030-01-01T00:00:00Z");
1491
+ const now = () => clock;
1492
+ for (let i = 0; i < 200; i++) {
1493
+ await tickOnce(backend, flows, {
1494
+ ...base,
1495
+ now,
1496
+ retry
1497
+ });
1498
+ const run = await backend.store.loadRunRow(runId);
1499
+ if (run && isTerminal(run.status)) return run;
1500
+ clock = new Date(clock.getTime() + 6e4);
1501
+ }
1502
+ throw new Error("run did not settle");
1503
+ };
1504
+ it("a run dispatched far more than maxAttempts times (many sleeps) completes", async () => {
1505
+ const backend = await makeBackend();
1506
+ const flow = defineFlow({
1507
+ name: "poller",
1508
+ version: 1,
1509
+ run: async (ctx) => {
1510
+ for (let i = 0; i < 6; i++) await ctx.sleep(1e3);
1511
+ return "settled";
1512
+ }
1513
+ });
1514
+ const runId = await submit(backend, flow, {});
1515
+ expect(await drive(backend, registry([flow]), runId, {
1516
+ maxAttempts: 3,
1517
+ baseDelayMs: 1,
1518
+ maxDelayMs: 1
1519
+ })).toMatchObject({
1520
+ status: "done",
1521
+ output: "settled"
1522
+ });
1523
+ });
1524
+ it("runs a builder flow with a durable sleep to completion", async () => {
1525
+ const backend = await makeBackend();
1526
+ const flow = builder(`${label}-sleep`, 1).step("doubled", (acc) => acc.input.x * 2).step("nap", async (_acc, ctx) => {
1527
+ await ctx.sleep(5e3);
1528
+ return "rested";
1529
+ }).output((acc) => ({
1530
+ doubled: acc.doubled,
1531
+ nap: acc.nap
1532
+ }));
1533
+ const runId = await submit(backend, flow, { x: 21 });
1534
+ expect(await drive(backend, registry([flow]), runId)).toMatchObject({
1535
+ status: "done",
1536
+ output: {
1537
+ doubled: 42,
1538
+ nap: "rested"
1539
+ }
1540
+ });
1541
+ });
1542
+ it("a genuinely failing step still dead-letters after its retry budget", async () => {
1543
+ const backend = await makeBackend();
1544
+ const flow = defineFlow({
1545
+ name: "always-throws",
1546
+ version: 1,
1547
+ run: async (ctx) => ctx.step("boom", () => {
1548
+ throw new Error("nope");
1549
+ })
1550
+ });
1551
+ const runId = await submit(backend, flow, {});
1552
+ expect((await drive(backend, registry([flow]), runId, {
1553
+ maxAttempts: 2,
1554
+ baseDelayMs: 1,
1555
+ maxDelayMs: 1
1556
+ })).status).toBe("failed");
1557
+ });
1558
+ it("dead-letters a poison-pill (uncatchable-crash) run once attempts exceed the cap", async () => {
1559
+ const backend = await makeBackend();
1560
+ const flow = defineFlow({
1561
+ name: "poison",
1562
+ version: 1,
1563
+ run: async () => 1
1564
+ });
1565
+ const runId = await submit(backend, flow, {});
1566
+ for (let i = 0; i < 5; i++) await backend.store.markRunning(runId);
1567
+ await tickOnce(backend, registry([flow]), {
1568
+ ...base,
1569
+ now: () => /* @__PURE__ */ new Date("2030-01-01T00:00:00Z"),
1570
+ retry: {
1571
+ maxAttempts: 3,
1572
+ baseDelayMs: 1,
1573
+ maxDelayMs: 1
1574
+ }
1575
+ });
1576
+ const run = await backend.store.loadRunRow(runId);
1577
+ expect(run?.status).toBe("failed");
1578
+ expect(run?.error?.code).toBe("RUN_ATTEMPTS_EXHAUSTED");
1579
+ });
1580
+ it("awaits an external signal, parks, and resumes with its payload", async () => {
1581
+ const backend = await makeBackend();
1582
+ const flow = defineFlow({
1583
+ name: "approve",
1584
+ version: 1,
1585
+ signals: { go: signalType() },
1586
+ run: async (ctx) => {
1587
+ return (await ctx.signal("go")).ok ? "shipped" : "held";
1588
+ }
1589
+ });
1590
+ const flows = registry([flow]);
1591
+ const runId = await submit(backend, flow, {});
1592
+ await tickOnce(backend, flows, {
1593
+ ...base,
1594
+ now: () => /* @__PURE__ */ new Date("2030-01-01T00:00:00Z")
1595
+ });
1596
+ expect((await backend.store.loadRunRow(runId))?.status).toBe("awaiting_signal");
1597
+ await signalRun(backend, runId, "go", { ok: true });
1598
+ expect(await drive(backend, flows, runId)).toMatchObject({
1599
+ status: "done",
1600
+ output: "shipped"
1601
+ });
1602
+ });
1603
+ it("cancels a run and cascades to grandchildren", async () => {
1604
+ const backend = await makeBackend();
1605
+ const grandchild = defineFlow({
1606
+ name: "gc",
1607
+ version: 1,
1608
+ run: async (ctx) => {
1609
+ await ctx.sleep(1e7);
1610
+ return "gc";
1611
+ }
1612
+ });
1613
+ const child = defineFlow({
1614
+ name: "c",
1615
+ version: 1,
1616
+ run: async (ctx) => ctx.invoke(grandchild, {})
1617
+ });
1618
+ const parent = defineFlow({
1619
+ name: "p",
1620
+ version: 1,
1621
+ run: async (ctx) => ctx.invoke(child, {})
1622
+ });
1623
+ const flows = registry([
1624
+ parent,
1625
+ child,
1626
+ grandchild
1627
+ ]);
1628
+ const parentId = await submit(backend, parent, {});
1629
+ let clock = /* @__PURE__ */ new Date("2030-01-01T00:00:00Z");
1630
+ for (let i = 0; i < 10; i++) {
1631
+ await tickOnce(backend, flows, {
1632
+ ...base,
1633
+ now: () => clock
1634
+ });
1635
+ clock = new Date(clock.getTime() + 1e3);
1636
+ }
1637
+ await cancelRun(backend, parentId);
1638
+ const kids = await backend.store.childrenOf(parentId);
1639
+ expect(kids.length).toBeGreaterThan(0);
1640
+ const childId = kids[0].id;
1641
+ const grandkids = await backend.store.childrenOf(childId);
1642
+ expect((await backend.store.loadRunRow(parentId))?.status).toBe("canceled");
1643
+ expect((await backend.store.loadRunRow(childId))?.status).toBe("canceled");
1644
+ if (grandkids[0]) expect((await backend.store.loadRunRow(grandkids[0].id))?.status).toBe("canceled");
1645
+ });
1646
+ it("a child of an already-terminated parent cancels itself on dispatch (crash-safe cascade)", async () => {
1647
+ const backend = await makeBackend();
1648
+ const orphan = defineFlow({
1649
+ name: "orphan",
1650
+ version: 1,
1651
+ run: async (ctx) => {
1652
+ await ctx.sleep(1e3);
1653
+ return 1;
1654
+ }
1655
+ });
1656
+ const { runId: parentId } = await backend.store.startRun({
1657
+ name: "p",
1658
+ version: 1,
1659
+ input: {}
1660
+ });
1661
+ await backend.store.markTerminal(parentId, {
1662
+ status: "failed",
1663
+ error: {
1664
+ code: "X",
1665
+ message: "parent died"
1666
+ }
1667
+ });
1668
+ const { runId: childId } = await backend.store.startRun({
1669
+ name: "orphan",
1670
+ version: 1,
1671
+ input: {},
1672
+ parentRunId: parentId,
1673
+ parentCursorKey: "s0"
1674
+ });
1675
+ await backend.queue.enqueue(childId);
1676
+ await tickOnce(backend, registry([orphan]), {
1677
+ ...base,
1678
+ now: () => /* @__PURE__ */ new Date("2030-01-01T00:00:00Z")
1679
+ });
1680
+ expect((await backend.store.loadRunRow(childId))?.status).toBe("canceled");
1681
+ });
1682
+ it("reconcile cancels a sleeping child left behind by a dead parent", async () => {
1683
+ const backend = await makeBackend();
1684
+ const now = () => /* @__PURE__ */ new Date("2030-01-01T00:00:00Z");
1685
+ const orphan = defineFlow({
1686
+ name: "orphan2",
1687
+ version: 1,
1688
+ run: async (ctx) => {
1689
+ await ctx.sleep(1e7);
1690
+ return 1;
1691
+ }
1692
+ });
1693
+ const { runId: parentId } = await backend.store.startRun({
1694
+ name: "p",
1695
+ version: 1,
1696
+ input: {}
1697
+ });
1698
+ const { runId: childId } = await backend.store.startRun({
1699
+ name: "orphan2",
1700
+ version: 1,
1701
+ input: {},
1702
+ parentRunId: parentId,
1703
+ parentCursorKey: "s0"
1704
+ });
1705
+ await backend.queue.enqueue(childId);
1706
+ await tickOnce(backend, registry([orphan]), {
1707
+ ...base,
1708
+ now
1709
+ });
1710
+ expect((await backend.store.loadRunRow(childId))?.status).toBe("sleeping");
1711
+ await backend.store.markTerminal(parentId, {
1712
+ status: "failed",
1713
+ error: {
1714
+ code: "X",
1715
+ message: "died"
1716
+ }
1717
+ });
1718
+ await reconcile(backend, { limit: 16 });
1719
+ await tickOnce(backend, registry([orphan]), {
1720
+ ...base,
1721
+ now
1722
+ });
1723
+ expect((await backend.store.loadRunRow(childId))?.status).toBe("canceled");
1724
+ });
1725
+ it("fans out children in parallel and joins their outputs in order", async () => {
1726
+ const backend = await makeBackend();
1727
+ const dbl = defineFlow({
1728
+ name: "dbl",
1729
+ version: 1,
1730
+ run: async (_ctx, n) => n * 2
1731
+ });
1732
+ const parent = defineFlow({
1733
+ name: "fan",
1734
+ version: 1,
1735
+ run: async (ctx) => ctx.invoke([
1736
+ {
1737
+ flow: dbl,
1738
+ input: 1
1739
+ },
1740
+ {
1741
+ flow: dbl,
1742
+ input: 2
1743
+ },
1744
+ {
1745
+ flow: dbl,
1746
+ input: 3
1747
+ }
1748
+ ])
1749
+ });
1750
+ const runId = await submit(backend, parent, {});
1751
+ expect(await drive(backend, registry([parent, dbl]), runId)).toMatchObject({
1752
+ status: "done",
1753
+ output: [
1754
+ 2,
1755
+ 4,
1756
+ 6
1757
+ ]
1758
+ });
1759
+ });
1760
+ it("rejects a fan-out beyond the cap", async () => {
1761
+ const backend = await makeBackend();
1762
+ const noop = defineFlow({
1763
+ name: "noop",
1764
+ version: 1,
1765
+ run: async () => 1
1766
+ });
1767
+ const parent = defineFlow({
1768
+ name: "toobig",
1769
+ version: 1,
1770
+ run: async (ctx) => ctx.invoke(Array.from({ length: 10001 }, () => ({
1771
+ flow: noop,
1772
+ input: {}
1773
+ })))
1774
+ });
1775
+ const runId = await submit(backend, parent, {});
1776
+ await tickOnce(backend, registry([parent, noop]), {
1777
+ ...base,
1778
+ now: () => /* @__PURE__ */ new Date("2030-01-01T00:00:00Z"),
1779
+ retry: {
1780
+ maxAttempts: 1,
1781
+ baseDelayMs: 1,
1782
+ maxDelayMs: 1
1783
+ }
1784
+ });
1785
+ expect((await backend.store.loadRunRow(runId))?.status).toBe("failed");
1786
+ });
1787
+ it("caps invoke recursion at the depth limit and persists each run's depth", async () => {
1788
+ const backend = await makeBackend();
1789
+ const recurse = defineFlow({
1790
+ name: "recurse",
1791
+ version: 1,
1792
+ policy: { maxDepth: 3 },
1793
+ run: async (ctx, n) => ctx.invoke(recurse, n + 1)
1794
+ });
1795
+ const runId = await submit(backend, recurse, 0);
1796
+ expect((await drive(backend, registry([recurse]), runId, {
1797
+ maxAttempts: 1,
1798
+ baseDelayMs: 1,
1799
+ maxDelayMs: 1
1800
+ })).status).toBe("failed");
1801
+ let cur = runId;
1802
+ let deepest = await backend.store.loadRunRow(cur);
1803
+ for (;;) {
1804
+ const kids = await backend.store.childrenOf(cur);
1805
+ if (kids.length === 0) break;
1806
+ deepest = kids[0];
1807
+ cur = kids[0].id;
1808
+ }
1809
+ expect(deepest?.depth).toBe(3);
1810
+ expect(deepest?.error?.message).toContain("depth");
1811
+ });
1812
+ it("fans out a batch larger than one spawn chunk (crosses chunk boundaries)", async () => {
1813
+ const backend = await makeBackend();
1814
+ const inc = defineFlow({
1815
+ name: "inc",
1816
+ version: 1,
1817
+ run: async (_ctx, n) => n + 1
1818
+ });
1819
+ const parent = defineFlow({
1820
+ name: "wide",
1821
+ version: 1,
1822
+ run: async (ctx) => ctx.invoke(Array.from({ length: 45 }, (_v, n) => ({
1823
+ flow: inc,
1824
+ input: n
1825
+ })))
1826
+ });
1827
+ const runId = await submit(backend, parent, {});
1828
+ const run = await drive(backend, registry([parent, inc]), runId);
1829
+ expect(run.status).toBe("done");
1830
+ expect(run.output).toEqual(Array.from({ length: 45 }, (_v, n) => n + 1));
1831
+ });
1832
+ it("fast-fails a fan-out and cancels the running siblings when one child fails", async () => {
1833
+ const backend = await makeBackend();
1834
+ const boom = defineFlow({
1835
+ name: "boom",
1836
+ version: 1,
1837
+ run: async () => {
1838
+ throw new Error("boom");
1839
+ }
1840
+ });
1841
+ const slow = defineFlow({
1842
+ name: "slow",
1843
+ version: 1,
1844
+ run: async (ctx) => {
1845
+ await ctx.sleep(1e7);
1846
+ return 1;
1847
+ }
1848
+ });
1849
+ const parent = defineFlow({
1850
+ name: "fanfail",
1851
+ version: 1,
1852
+ run: async (ctx) => ctx.invoke([{
1853
+ flow: boom,
1854
+ input: {}
1855
+ }, {
1856
+ flow: slow,
1857
+ input: {}
1858
+ }])
1859
+ });
1860
+ const runId = await submit(backend, parent, {});
1861
+ expect((await drive(backend, registry([
1862
+ parent,
1863
+ boom,
1864
+ slow
1865
+ ]), runId, {
1866
+ maxAttempts: 1,
1867
+ baseDelayMs: 1,
1868
+ maxDelayMs: 1
1869
+ })).status).toBe("failed");
1870
+ const kids = await backend.store.childrenOf(runId);
1871
+ expect(kids).toHaveLength(2);
1872
+ expect(kids.every((k) => isTerminal(k.status))).toBe(true);
1873
+ expect(kids.some((k) => k.status === "canceled")).toBe(true);
1874
+ });
1875
+ });
1876
+ };
1877
+ //#endregion
1878
+ export { 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.2",
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.2"
25
32
  },
26
33
  "peerDependencies": {
27
34
  "vitest": ">=3"