@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/chunks/kit-errors.d.ts +7 -1
- package/dist/chunks/kit-errors.js +6 -2
- package/dist/chunks/kit-errors.js.map +1 -1
- package/dist/chunks/ports.js +125 -1
- package/dist/chunks/ports.js.map +1 -1
- package/dist/index.d.ts +91 -14
- package/dist/index.js +33 -150
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +26 -1
- package/dist/testing.js +170 -70
- 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
|
/**
|
|
@@ -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
|
|
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 {
|
|
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");
|
|
@@ -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
|
-
|
|
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
|
-
|
|
2557
|
-
await bMayFlush;
|
|
2668
|
+
await hold();
|
|
2558
2669
|
harness.mutate(stale);
|
|
2559
|
-
|
|
2560
|
-
});
|
|
2561
|
-
await
|
|
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
|
-
|
|
2675
|
+
update(repository, current);
|
|
2566
2676
|
return current;
|
|
2567
|
-
});
|
|
2677
|
+
}), writerB, overlappingCallsBoundMs);
|
|
2568
2678
|
const outboxAfterA = await environment.committedOutboxEvents();
|
|
2569
|
-
|
|
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
|
-
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2726
|
-
await mayRemove;
|
|
2827
|
+
await hold();
|
|
2727
2828
|
repository.remove(stale);
|
|
2728
|
-
});
|
|
2729
|
-
await
|
|
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
|
-
|
|
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
|
|