@shirudo/ddd-kit 3.0.0-rc.7 → 3.0.0-rc.9

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/dist/testing.d.ts CHANGED
@@ -147,6 +147,14 @@ interface EsRepositoryContractHarness<TAggregate extends Aggregate<Id<string>, T
147
147
  * Enables the snapshot catch-up proof.
148
148
  */
149
149
  captureSnapshot?(aggregate: TAggregate, environment: EsRepositoryContractEnvironment<TAggregate, TEvent>): Promise<void>;
150
+ /**
151
+ * Bound for the overlapping `run` calls, in milliseconds: the second call
152
+ * of the environment preflight, and the committing call of each
153
+ * stale-writer proof. Raise it only for a second connection that needs
154
+ * more time to open, or for a slow commit. Keep twice the bound, plus
155
+ * environment creation and teardown, below the test timeout of the runner.
156
+ */
157
+ overlappingCallsBoundMs?: number;
150
158
  }
151
159
  type EsRepositoryContractTest = ContractTest;
152
160
  /**
@@ -494,7 +502,8 @@ declare function createProjectionCheckpointStoreContractTests<TCtx>(harness: Pro
494
502
  interface ContractRepository<TAggregate extends Aggregate<Id<string>, AnyDomainEvent>> {
495
503
  findById(id: TAggregate["id"]): Promise<TAggregate | undefined>;
496
504
  add(aggregate: TAggregate): void;
497
- update(aggregate: TAggregate): void;
505
+ /** An append-only port declares no update. */
506
+ update?(aggregate: TAggregate): void;
498
507
  /** Physical removal is an optional persistence capability. */
499
508
  remove?(aggregate: TAggregate): void;
500
509
  }
@@ -533,10 +542,26 @@ interface RepositoryContractHarness<TAggregate extends Aggregate<Id<string>, TEv
533
542
  snapshotState?(aggregate: TAggregate): unknown;
534
543
  /** Opt out only for an intentionally upserting add implementation. */
535
544
  insertsAreDuplicateChecked?: boolean;
545
+ /**
546
+ * Opt out only for an append-only port, one that the definition marks
547
+ * with `appendOnly: true`. The suite then skips every update proof. The
548
+ * duplicate-add proof is the concurrency proof that remains, so it is
549
+ * mandatory. Provide `createAggregateWithId` and keep
550
+ * `insertsAreDuplicateChecked`, or the proof fails instead of skipping.
551
+ */
552
+ updatesAreSupported?: boolean;
536
553
  /** Enables physical-remove behavior and stale-remove OCC tests. */
537
554
  removesAreSupported?: boolean;
538
555
  /** The remove flush predicates on the version captured at load. */
539
556
  removesAreVersionChecked?: boolean;
557
+ /**
558
+ * Bound for the overlapping `run` calls, in milliseconds: the second call
559
+ * of the environment preflight, and the committing call of each
560
+ * stale-writer proof. Raise it only for a second connection that needs
561
+ * more time to open, or for a slow commit. Keep twice the bound, plus
562
+ * environment creation and teardown, below the test timeout of the runner.
563
+ */
564
+ overlappingCallsBoundMs?: number;
540
565
  }
541
566
  type RepositoryContractTest = ContractTest;
542
567
  /**
package/dist/testing.js CHANGED
@@ -1,4 +1,4 @@
1
- import { E as deepEqual, t as isDispatchTrackingOutbox, u as isRecordedDomainEvent } from "./chunks/ports.js";
1
+ import { N as deepEqual, _ as isRecordedDomainEvent, i as runBoundedExecution, t as isDispatchTrackingOutbox } from "./chunks/ports.js";
2
2
 
3
3
  //#region src/testing/contract-assertions.ts
4
4
  /**
@@ -44,6 +44,123 @@ function captureRejection(promise) {
44
44
  return promise.then(() => void 0, (error) => error);
45
45
  }
46
46
  /**
47
+ * Default bound for the overlapping `run` calls of the contract suites, in
48
+ * milliseconds. On an environment that gives each `run` call its own
49
+ * connection, the second call completes in milliseconds. The failure path
50
+ * takes up to twice the bound: the bound itself, then the wait for the
51
+ * released calls to settle. Twice the bound plus environment creation and
52
+ * teardown stays below the default test timeout of common runners
53
+ * (5000 ms). So the named failure reaches the report before the runner's
54
+ * own timeout replaces it.
55
+ */
56
+ const OVERLAPPING_CALLS_BOUND_MS = 1e3;
57
+ const overlappingCallsViolation = (boundMs) => `run must permit overlapping calls: a second run call did not complete within ${boundMs} ms while the first call stayed open. Either run serializes its calls, the first call holds a lock that blocks the second one, or the second call needs more time than the bound. Give each call its own transaction and connection, load without row locks, or raise overlappingCallsBoundMs on the harness`;
58
+ function settle(promise) {
59
+ return promise.then((value) => ({
60
+ status: "fulfilled",
61
+ value
62
+ }), (reason) => ({
63
+ status: "rejected",
64
+ reason
65
+ }));
66
+ }
67
+ /** Outcomes of every promise, or `undefined` when one is still open after `boundMs`. */
68
+ function settledWithin(promises, boundMs) {
69
+ return runBoundedExecution("release of the overlapping calls", { timeoutMs: boundMs }, () => Promise.allSettled(promises)).catch(() => void 0);
70
+ }
71
+ /**
72
+ * Starts a `run` call and holds it open. `start` receives `hold`. The work
73
+ * of the call awaits `hold()` at the point where it must stay open, for
74
+ * example after its load. The result resolves once the work holds and the
75
+ * call is still open. A call that rejects before that propagates its
76
+ * rejection. A call that resolves while its work holds fails: `run` did not
77
+ * await its work.
78
+ */
79
+ async function parkRunCall(start) {
80
+ let release;
81
+ const mayContinue = new Promise((resolve) => {
82
+ release = resolve;
83
+ });
84
+ let markHolding;
85
+ const holding = new Promise((resolve) => {
86
+ markHolding = () => resolve("holding");
87
+ });
88
+ const call = start(() => {
89
+ markHolding();
90
+ return mayContinue;
91
+ });
92
+ const settled = settle(call).then((outcome) => outcome.status);
93
+ let state = await Promise.race([holding, settled]);
94
+ if (state === "holding") state = await Promise.race([settled, Promise.resolve("holding")]);
95
+ if (state === "rejected") await call;
96
+ assert(state === "holding", "run must await its work: the call resolved while its work still holds");
97
+ return {
98
+ call,
99
+ release
100
+ };
101
+ }
102
+ /**
103
+ * Starts a `run` call through `startCall` and awaits it. The call must
104
+ * complete while `parked` stays open. On an environment that serializes
105
+ * `run`, it never completes. So this bounds the wait. After `boundMs` it
106
+ * releases the parked call and waits up to `boundMs` for both calls to
107
+ * settle. Then it fails with the requirement. A rejection of the call, or a
108
+ * synchronous throw of `startCall`, releases the parked call the same way
109
+ * and then propagates. On success the parked call stays parked; the proof
110
+ * releases it when it is ready.
111
+ */
112
+ async function awaitOverlappingCall(startCall, parked, boundMs) {
113
+ let call;
114
+ try {
115
+ call = startCall();
116
+ } catch (error) {
117
+ parked.release();
118
+ await settledWithin([parked.call], boundMs);
119
+ throw error;
120
+ }
121
+ const outcome = await runBoundedExecution("overlapping run call", { timeoutMs: boundMs }, () => settle(call)).catch(() => void 0);
122
+ if (outcome?.status === "fulfilled") return outcome.value;
123
+ parked.release();
124
+ await settledWithin([parked.call, call], boundMs);
125
+ assert(outcome !== void 0, overlappingCallsViolation(boundMs));
126
+ throw outcome.reason;
127
+ }
128
+ /**
129
+ * Proves that the environment lets two `run` calls stay open at once.
130
+ *
131
+ * The stale-writer proofs hold one transaction open while a second one
132
+ * commits. An environment that serializes `run` (one connection, a mutex)
133
+ * blocks the second call behind the first. The suite then hangs at the test
134
+ * timeout with no cause. This proof turns that hang into a named failure
135
+ * within `boundMs`. It releases the first call before it returns and waits
136
+ * up to `boundMs` for both calls to complete. A second call that is still
137
+ * blocked after that stays in flight, observed, while the failure reports.
138
+ */
139
+ async function assertRunPermitsOverlappingCalls(run, boundMs) {
140
+ const first = await parkRunCall((hold) => run(hold));
141
+ await awaitOverlappingCall(() => run(async () => {}), first, boundMs);
142
+ first.release();
143
+ const firstOutcome = (await settledWithin([first.call], boundMs))?.[0];
144
+ assert(firstOutcome !== void 0, `the first run call did not complete within ${boundMs} ms after the proof released it`);
145
+ if (firstOutcome.status === "rejected") throw firstOutcome.reason;
146
+ }
147
+ /**
148
+ * The preflight entry both repository suites put first: it names a
149
+ * serializing environment before the stale-writer proofs can hang on it.
150
+ * Each `run` call of the proof reads `freshId()` before it holds. So an
151
+ * adapter that reserves its connection on the first statement holds the
152
+ * connection while the call stays open.
153
+ */
154
+ function overlappingCallsPreflight(inEnvironment, freshId, boundMs) {
155
+ return {
156
+ name: "environment preflight: a second run call completes while the first call stays open",
157
+ run: inEnvironment((env) => assertRunPermitsOverlappingCalls((work) => env.run(async ({ repository }) => {
158
+ await repository.findById(freshId());
159
+ await work();
160
+ }), boundMs))
161
+ };
162
+ }
163
+ /**
47
164
  * Load with a contract diagnostic instead of a bare TypeError downstream.
48
165
  * `suspectHint` names the suite-specific likely cause (broken hydration
49
166
  * vs broken replay read).
@@ -804,6 +921,7 @@ function createEsRepositoryContractTests(harness) {
804
921
  const createAggregateWithId = harness.createAggregateWithId;
805
922
  const snapshotState = harness.snapshotState;
806
923
  const captureSnapshot = harness.captureSnapshot;
924
+ const overlappingCallsBoundMs = harness.overlappingCallsBoundMs ?? 1e3;
807
925
  const load = (repository, id) => loadAggregateOrFail(repository, id, "the stream was not appended or replayed correctly");
808
926
  const streamFor = (id) => harness.streamKeyFor(id);
809
927
  const recordedIds = (events) => recordedPendingEventIds(events, "pending events must be recorded before flush");
@@ -820,6 +938,7 @@ function createEsRepositoryContractTests(harness) {
820
938
  return aggregate;
821
939
  }
822
940
  const tests = [
941
+ overlappingCallsPreflight(inEnvironment, () => harness.createAggregate().id, overlappingCallsBoundMs),
823
942
  {
824
943
  name: "add appends the exact creation batch to stream and outbox",
825
944
  run: inEnvironment(async (environment) => {
@@ -839,32 +958,22 @@ function createEsRepositoryContractTests(harness) {
839
958
  name: "MANDATORY stale append: writer B conflicts after writer A commits and appends no prefix",
840
959
  run: inEnvironment(async (environment) => {
841
960
  const seeded = await seed(environment);
842
- let loaded;
843
- const bLoaded = new Promise((resolve) => {
844
- loaded = resolve;
845
- });
846
- let release;
847
- const mayAppend = new Promise((resolve) => {
848
- release = resolve;
849
- });
850
- const writerB = environment.run(async ({ repository }) => {
961
+ const writerB = await parkRunCall((hold) => environment.run(async ({ repository }) => {
851
962
  const stale = await load(repository, seeded.id);
852
- loaded();
853
- await mayAppend;
963
+ await hold();
854
964
  harness.mutate(stale);
855
965
  harness.mutate(stale);
856
966
  repository.update(stale);
857
- });
858
- await bLoaded;
859
- const winner = await environment.run(async ({ repository }) => {
967
+ }));
968
+ const winner = await awaitOverlappingCall(() => environment.run(async ({ repository }) => {
860
969
  const current = await load(repository, seeded.id);
861
970
  harness.mutate(current);
862
971
  repository.update(current);
863
972
  return current;
864
- });
973
+ }), writerB, overlappingCallsBoundMs);
865
974
  const streamAfterWinner = await environment.committedStreamEvents(streamFor(seeded.id), readAll);
866
- release();
867
- const rejection = await captureRejection(writerB);
975
+ writerB.release();
976
+ const rejection = await captureRejection(writerB.call);
868
977
  assertChainContainsKitError(rejection, ["CONCURRENCY_CONFLICT"], `stale append must reject with ConcurrencyConflictError; got ${describeError(rejection)}`);
869
978
  const finalStream = await environment.committedStreamEvents(streamFor(seeded.id), readAll);
870
979
  assert(finalStream.exists && deepEqual(ids(finalStream.events), ids(streamAfterWinner.events)), "a rejected multi-event append must leave no prefix in the stream");
@@ -2487,9 +2596,19 @@ function createRepositoryContractTests(harness) {
2487
2596
  const mutateVersionOnly = harness.mutateVersionOnly;
2488
2597
  const mutateChildCollection = harness.mutateChildCollection;
2489
2598
  const insertsAreDuplicateChecked = harness.insertsAreDuplicateChecked !== false;
2599
+ const updatesAreSupported = harness.updatesAreSupported !== false;
2490
2600
  const removesAreSupported = harness.removesAreSupported === true;
2491
2601
  const removesAreVersionChecked = removesAreSupported && harness.removesAreVersionChecked === true;
2602
+ const overlappingCallsBoundMs = harness.overlappingCallsBoundMs ?? 1e3;
2492
2603
  const load = (repository, id) => loadAggregateOrFail(repository, id, "the adapter did not commit or reconstitute the aggregate");
2604
+ const update = (repository, aggregate) => {
2605
+ assert(repository.update !== void 0, "the harness keeps updatesAreSupported, but the repository has no update");
2606
+ repository.update(aggregate);
2607
+ };
2608
+ const updateGate = {
2609
+ capability: "updatesAreSupported",
2610
+ satisfiedBy: updatesAreSupported
2611
+ };
2493
2612
  async function seed(environment) {
2494
2613
  const aggregate = harness.createAggregate();
2495
2614
  harness.mutate(aggregate);
@@ -2502,6 +2621,7 @@ function createRepositoryContractTests(harness) {
2502
2621
  const eventIds = (events) => sortedCommittedEventIds(events);
2503
2622
  const pendingEventIds = (events) => recordedPendingEventIds(events, "the harness must record pending events before persistence");
2504
2623
  const tests = [
2624
+ overlappingCallsPreflight(inEnvironment, () => harness.createAggregate().id, overlappingCallsBoundMs),
2505
2625
  {
2506
2626
  name: "add flushes a new aggregate and its exact event batch atomically",
2507
2627
  run: inEnvironment(async (environment) => {
@@ -2539,42 +2659,32 @@ function createRepositoryContractTests(harness) {
2539
2659
  });
2540
2660
  })
2541
2661
  },
2542
- {
2662
+ gatedContractTest(updateGate, {
2543
2663
  name: "MANDATORY stale update: writer B conflicts after writer A commits and persists nothing",
2544
2664
  run: inEnvironment(async (environment) => {
2545
2665
  const seeded = await seed(environment);
2546
- let loadedB;
2547
- const bLoaded = new Promise((resolve) => {
2548
- loadedB = resolve;
2549
- });
2550
- let releaseB;
2551
- const bMayFlush = new Promise((resolve) => {
2552
- releaseB = resolve;
2553
- });
2554
- const writerB = environment.run(async ({ repository }) => {
2666
+ const writerB = await parkRunCall((hold) => environment.run(async ({ repository }) => {
2555
2667
  const stale = await load(repository, seeded.id);
2556
- loadedB();
2557
- await bMayFlush;
2668
+ await hold();
2558
2669
  harness.mutate(stale);
2559
- repository.update(stale);
2560
- });
2561
- await bLoaded;
2562
- const committedA = await environment.run(async ({ repository }) => {
2670
+ update(repository, stale);
2671
+ }));
2672
+ const committedA = await awaitOverlappingCall(() => environment.run(async ({ repository }) => {
2563
2673
  const current = await load(repository, seeded.id);
2564
2674
  harness.mutate(current);
2565
- repository.update(current);
2675
+ update(repository, current);
2566
2676
  return current;
2567
- });
2677
+ }), writerB, overlappingCallsBoundMs);
2568
2678
  const outboxAfterA = await environment.committedOutboxEvents();
2569
- releaseB();
2570
- const rejection = await captureRejection(writerB);
2679
+ writerB.release();
2680
+ const rejection = await captureRejection(writerB.call);
2571
2681
  assertChainContainsKitError(rejection, ["CONCURRENCY_CONFLICT"], `stale update must reject with ConcurrencyConflictError; got ${describeError(rejection)}`);
2572
2682
  const final = await reload(environment, seeded.id);
2573
2683
  assertEqual(final.version, committedA.version, "the stale writer must not replace writer A's version");
2574
2684
  if (snapshotState) assert(deepEqual(snapshotState.call(harness, final), snapshotState.call(harness, committedA)), "the stale writer must not replace writer A's state");
2575
2685
  assert(deepEqual(eventIds(await environment.committedOutboxEvents()), eventIds(outboxAfterA)), "a rejected stale flush must add no outbox records");
2576
2686
  })
2577
- },
2687
+ }),
2578
2688
  {
2579
2689
  name: "rollback acknowledges nothing and commits neither state nor outbox",
2580
2690
  run: inEnvironment(async (environment) => {
@@ -2619,26 +2729,26 @@ function createRepositoryContractTests(harness) {
2619
2729
  });
2620
2730
  })
2621
2731
  },
2622
- {
2732
+ gatedContractTest(updateGate, {
2623
2733
  name: "an unchanged explicit update is safe and emits no event",
2624
2734
  run: inEnvironment(async (environment) => {
2625
2735
  const seeded = await seed(environment);
2626
2736
  const before = await environment.committedOutboxEvents();
2627
2737
  await environment.run(async ({ repository }) => {
2628
2738
  const aggregate = await load(repository, seeded.id);
2629
- repository.update(aggregate);
2739
+ update(repository, aggregate);
2630
2740
  });
2631
2741
  assert(deepEqual(eventIds(await environment.committedOutboxEvents()), eventIds(before)), "an unchanged update must not manufacture an outbox event");
2632
2742
  })
2633
- }
2743
+ })
2634
2744
  ];
2635
2745
  tests.push(gatedContractTest({
2636
2746
  capability: createAggregateWithId ? "insertsAreDuplicateChecked" : "createAggregateWithId",
2637
- satisfiedBy: Boolean(createAggregateWithId) && insertsAreDuplicateChecked
2747
+ satisfiedBy: Boolean(createAggregateWithId) && insertsAreDuplicateChecked || !updatesAreSupported
2638
2748
  }, {
2639
2749
  name: "duplicate add rejects and preserves the existing aggregate",
2640
2750
  run: inEnvironment(async (environment) => {
2641
- assert(createAggregateWithId !== void 0, "capability gate");
2751
+ assert(createAggregateWithId !== void 0 && insertsAreDuplicateChecked, "an append-only harness must provide createAggregateWithId and keep insertsAreDuplicateChecked: the duplicate-add proof is its only concurrency proof");
2642
2752
  const seeded = await seed(environment);
2643
2753
  const duplicate = createAggregateWithId.call(harness, seeded.id);
2644
2754
  harness.mutate(duplicate);
@@ -2652,7 +2762,7 @@ function createRepositoryContractTests(harness) {
2652
2762
  if (snapshotState) assert(deepEqual(snapshotState.call(harness, final), snapshotState.call(harness, seeded)), "the existing row's state must be untouched by the rejected duplicate add");
2653
2763
  })
2654
2764
  }));
2655
- tests.push(gatedContractTest({
2765
+ tests.push(gatedContractTest(updateGate, gatedContractTest({
2656
2766
  capability: "mutateVersionOnly",
2657
2767
  satisfiedBy: Boolean(mutateVersionOnly)
2658
2768
  }, {
@@ -2664,14 +2774,14 @@ function createRepositoryContractTests(harness) {
2664
2774
  await environment.run(async ({ repository }) => {
2665
2775
  const aggregate = await load(repository, seeded.id);
2666
2776
  mutateVersionOnly.call(harness, aggregate);
2667
- repository.update(aggregate);
2777
+ update(repository, aggregate);
2668
2778
  });
2669
2779
  const reloaded = await reload(environment, seeded.id);
2670
2780
  assertEqual(reloaded.version, seeded.version + 1, "a version-only change (empty change set, bumped version) must still be persisted; skipping it desyncs the persisted version and produces false concurrency conflicts later");
2671
2781
  assert(deepEqual(eventIds(await environment.committedOutboxEvents()), eventIds(outboxBefore)), "state-only update must not create an outbox event");
2672
2782
  })
2673
- }));
2674
- tests.push(gatedContractTest({
2783
+ })));
2784
+ tests.push(gatedContractTest(updateGate, gatedContractTest({
2675
2785
  capability: "mutateChildCollection",
2676
2786
  satisfiedBy: Boolean(mutateChildCollection)
2677
2787
  }, {
@@ -2682,12 +2792,12 @@ function createRepositoryContractTests(harness) {
2682
2792
  await environment.run(async ({ repository }) => {
2683
2793
  const aggregate = await load(repository, seeded.id);
2684
2794
  mutateChildCollection.call(harness, aggregate);
2685
- repository.update(aggregate);
2795
+ update(repository, aggregate);
2686
2796
  });
2687
2797
  const reloaded = await reload(environment, seeded.id);
2688
2798
  assertEqual(reloaded.version, seeded.version + 1, "nested collection update must advance the persisted root version");
2689
2799
  })
2690
- }));
2800
+ })));
2691
2801
  tests.push(gatedContractTest({
2692
2802
  capability: "removesAreSupported",
2693
2803
  satisfiedBy: removesAreSupported
@@ -2704,40 +2814,30 @@ function createRepositoryContractTests(harness) {
2704
2814
  assert(await environment.run(({ repository }) => repository.findById(seeded.id)) === void 0, "remove must physically remove the aggregate after commit");
2705
2815
  })
2706
2816
  }));
2707
- tests.push(gatedContractTest({
2817
+ tests.push(gatedContractTest(updateGate, gatedContractTest({
2708
2818
  capability: "removesAreVersionChecked",
2709
2819
  satisfiedBy: removesAreVersionChecked
2710
2820
  }, {
2711
2821
  name: "stale remove conflicts and cannot delete a concurrent update",
2712
2822
  run: inEnvironment(async (environment) => {
2713
2823
  const seeded = await seed(environment);
2714
- let loaded;
2715
- const staleLoaded = new Promise((resolve) => {
2716
- loaded = resolve;
2717
- });
2718
- let release;
2719
- const mayRemove = new Promise((resolve) => {
2720
- release = resolve;
2721
- });
2722
- const staleRemove = environment.run(async ({ repository }) => {
2824
+ const staleRemove = await parkRunCall((hold) => environment.run(async ({ repository }) => {
2723
2825
  assert(repository.remove !== void 0, "remove capability gate");
2724
2826
  const stale = await load(repository, seeded.id);
2725
- loaded();
2726
- await mayRemove;
2827
+ await hold();
2727
2828
  repository.remove(stale);
2728
- });
2729
- await staleLoaded;
2730
- await environment.run(async ({ repository }) => {
2829
+ }));
2830
+ await awaitOverlappingCall(() => environment.run(async ({ repository }) => {
2731
2831
  const current = await load(repository, seeded.id);
2732
2832
  harness.mutate(current);
2733
- repository.update(current);
2734
- });
2735
- release();
2736
- const rejection = await captureRejection(staleRemove);
2833
+ update(repository, current);
2834
+ }), staleRemove, overlappingCallsBoundMs);
2835
+ staleRemove.release();
2836
+ const rejection = await captureRejection(staleRemove.call);
2737
2837
  assertChainContainsKitError(rejection, ["CONCURRENCY_CONFLICT"], `stale remove must reject with ConcurrencyConflictError; got ${describeError(rejection)}`);
2738
2838
  assert(await reload(environment, seeded.id) !== void 0, "stale remove must not delete the concurrent winner");
2739
2839
  })
2740
- }));
2840
+ })));
2741
2841
  return tests;
2742
2842
  }
2743
2843