@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/chunks/ports.js +125 -1
- package/dist/chunks/ports.js.map +1 -1
- package/dist/chunks/snapshot-store.d.ts +4 -2
- package/dist/index.d.ts +14 -3
- package/dist/index.js +1 -125
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +16 -0
- package/dist/testing.js +143 -52
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
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 {
|
|
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
|
-
|
|
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
|
-
|
|
853
|
-
await mayAppend;
|
|
963
|
+
await hold();
|
|
854
964
|
harness.mutate(stale);
|
|
855
965
|
harness.mutate(stale);
|
|
856
966
|
repository.update(stale);
|
|
857
|
-
});
|
|
858
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
2557
|
-
await bMayFlush;
|
|
2659
|
+
await hold();
|
|
2558
2660
|
harness.mutate(stale);
|
|
2559
2661
|
repository.update(stale);
|
|
2560
|
-
});
|
|
2561
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2726
|
-
await mayRemove;
|
|
2818
|
+
await hold();
|
|
2727
2819
|
repository.remove(stale);
|
|
2728
|
-
});
|
|
2729
|
-
await
|
|
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
|
})
|