@shirudo/ddd-kit 3.0.0-rc.6 → 3.0.0-rc.8

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
  /**
@@ -537,6 +545,14 @@ interface RepositoryContractHarness<TAggregate extends Aggregate<Id<string>, TEv
537
545
  removesAreSupported?: boolean;
538
546
  /** The remove flush predicates on the version captured at load. */
539
547
  removesAreVersionChecked?: boolean;
548
+ /**
549
+ * Bound for the overlapping `run` calls, in milliseconds: the second call
550
+ * of the environment preflight, and the committing call of each
551
+ * stale-writer proof. Raise it only for a second connection that needs
552
+ * more time to open, or for a slow commit. Keep twice the bound, plus
553
+ * environment creation and teardown, below the test timeout of the runner.
554
+ */
555
+ overlappingCallsBoundMs?: number;
540
556
  }
541
557
  type RepositoryContractTest = ContractTest;
542
558
  /**
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");
@@ -2489,6 +2598,7 @@ function createRepositoryContractTests(harness) {
2489
2598
  const insertsAreDuplicateChecked = harness.insertsAreDuplicateChecked !== false;
2490
2599
  const removesAreSupported = harness.removesAreSupported === true;
2491
2600
  const removesAreVersionChecked = removesAreSupported && harness.removesAreVersionChecked === true;
2601
+ const overlappingCallsBoundMs = harness.overlappingCallsBoundMs ?? 1e3;
2492
2602
  const load = (repository, id) => loadAggregateOrFail(repository, id, "the adapter did not commit or reconstitute the aggregate");
2493
2603
  async function seed(environment) {
2494
2604
  const aggregate = harness.createAggregate();
@@ -2502,6 +2612,7 @@ function createRepositoryContractTests(harness) {
2502
2612
  const eventIds = (events) => sortedCommittedEventIds(events);
2503
2613
  const pendingEventIds = (events) => recordedPendingEventIds(events, "the harness must record pending events before persistence");
2504
2614
  const tests = [
2615
+ overlappingCallsPreflight(inEnvironment, () => harness.createAggregate().id, overlappingCallsBoundMs),
2505
2616
  {
2506
2617
  name: "add flushes a new aggregate and its exact event batch atomically",
2507
2618
  run: inEnvironment(async (environment) => {
@@ -2543,31 +2654,21 @@ function createRepositoryContractTests(harness) {
2543
2654
  name: "MANDATORY stale update: writer B conflicts after writer A commits and persists nothing",
2544
2655
  run: inEnvironment(async (environment) => {
2545
2656
  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 }) => {
2657
+ const writerB = await parkRunCall((hold) => environment.run(async ({ repository }) => {
2555
2658
  const stale = await load(repository, seeded.id);
2556
- loadedB();
2557
- await bMayFlush;
2659
+ await hold();
2558
2660
  harness.mutate(stale);
2559
2661
  repository.update(stale);
2560
- });
2561
- await bLoaded;
2562
- const committedA = await environment.run(async ({ repository }) => {
2662
+ }));
2663
+ const committedA = await awaitOverlappingCall(() => environment.run(async ({ repository }) => {
2563
2664
  const current = await load(repository, seeded.id);
2564
2665
  harness.mutate(current);
2565
2666
  repository.update(current);
2566
2667
  return current;
2567
- });
2668
+ }), writerB, overlappingCallsBoundMs);
2568
2669
  const outboxAfterA = await environment.committedOutboxEvents();
2569
- releaseB();
2570
- const rejection = await captureRejection(writerB);
2670
+ writerB.release();
2671
+ const rejection = await captureRejection(writerB.call);
2571
2672
  assertChainContainsKitError(rejection, ["CONCURRENCY_CONFLICT"], `stale update must reject with ConcurrencyConflictError; got ${describeError(rejection)}`);
2572
2673
  const final = await reload(environment, seeded.id);
2573
2674
  assertEqual(final.version, committedA.version, "the stale writer must not replace writer A's version");
@@ -2711,29 +2812,19 @@ function createRepositoryContractTests(harness) {
2711
2812
  name: "stale remove conflicts and cannot delete a concurrent update",
2712
2813
  run: inEnvironment(async (environment) => {
2713
2814
  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 }) => {
2815
+ const staleRemove = await parkRunCall((hold) => environment.run(async ({ repository }) => {
2723
2816
  assert(repository.remove !== void 0, "remove capability gate");
2724
2817
  const stale = await load(repository, seeded.id);
2725
- loaded();
2726
- await mayRemove;
2818
+ await hold();
2727
2819
  repository.remove(stale);
2728
- });
2729
- await staleLoaded;
2730
- await environment.run(async ({ repository }) => {
2820
+ }));
2821
+ await awaitOverlappingCall(() => environment.run(async ({ repository }) => {
2731
2822
  const current = await load(repository, seeded.id);
2732
2823
  harness.mutate(current);
2733
2824
  repository.update(current);
2734
- });
2735
- release();
2736
- const rejection = await captureRejection(staleRemove);
2825
+ }), staleRemove, overlappingCallsBoundMs);
2826
+ staleRemove.release();
2827
+ const rejection = await captureRejection(staleRemove.call);
2737
2828
  assertChainContainsKitError(rejection, ["CONCURRENCY_CONFLICT"], `stale remove must reject with ConcurrencyConflictError; got ${describeError(rejection)}`);
2738
2829
  assert(await reload(environment, seeded.id) !== void 0, "stale remove must not delete the concurrent winner");
2739
2830
  })