@shirudo/ddd-kit 3.0.0-rc.10 → 3.0.0-rc.11
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 +287 -108
- package/dist/chunks/kit-errors.js +250 -111
- package/dist/chunks/kit-errors.js.map +1 -1
- package/dist/chunks/{ports.js → read-stream-pages.js} +531 -92
- package/dist/chunks/read-stream-pages.js.map +1 -0
- package/dist/chunks/snapshot-store.d.ts +542 -115
- package/dist/index.d.ts +598 -434
- package/dist/index.js +1508 -637
- package/dist/index.js.map +1 -1
- package/dist/money.d.ts +1 -1
- package/dist/testing.d.ts +106 -15
- package/dist/testing.js +546 -52
- package/dist/testing.js.map +1 -1
- package/package.json +3 -2
- package/dist/chunks/ports.js.map +0 -1
package/dist/testing.js
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { I as deepFreeze, U as deepEqual, a as isDispatchTrackingOutbox, c as runBoundedExecution, f as assertPositiveSafeInteger, n as readStreamPages, w as isRecordedDomainEvent } from "./chunks/read-stream-pages.js";
|
|
2
2
|
|
|
3
3
|
//#region src/testing/contract-assertions.ts
|
|
4
4
|
/**
|
|
5
|
-
* Assertion, error-matching, and suite-runner helpers shared by the
|
|
6
|
-
* repository contract suites (state-stored and event-sourced). Internal
|
|
7
|
-
* to the testing entry: not re-exported from `@shirudo/ddd-kit/testing`.
|
|
8
|
-
*/
|
|
9
|
-
/**
|
|
10
5
|
* Runs one contract-test body against a fresh environment and tears it
|
|
11
6
|
* down in a finally-like discipline with one subtle, load-bearing rule:
|
|
12
7
|
* a teardown failure (dropping a schema on an aborted pool) must never
|
|
@@ -281,6 +276,25 @@ function assertChainContainsKitError(rejection, codes, message) {
|
|
|
281
276
|
throw new Error(`Contract violated: ${message}`);
|
|
282
277
|
}
|
|
283
278
|
/**
|
|
279
|
+
* Asserts that the first `CONCURRENCY_CONFLICT` in the cause chain names the
|
|
280
|
+
* write that failed. The unit of work sets the intent on every conflict that
|
|
281
|
+
* leaves a flush, so an adapter that wraps the conflict keeps it.
|
|
282
|
+
*/
|
|
283
|
+
function assertChainConflictNamesIntent(rejection, intent, message) {
|
|
284
|
+
let observed = "no CONCURRENCY_CONFLICT";
|
|
285
|
+
walkCauseChain(rejection, (node) => {
|
|
286
|
+
if (!errorMatchesName(node, "CONCURRENCY_CONFLICT")) return false;
|
|
287
|
+
try {
|
|
288
|
+
observed = node.intent;
|
|
289
|
+
} catch {
|
|
290
|
+
observed = "an intent that cannot be read";
|
|
291
|
+
}
|
|
292
|
+
return true;
|
|
293
|
+
});
|
|
294
|
+
if (observed === intent) return;
|
|
295
|
+
throw new Error(`Contract violated: ${message}; got ${typeof observed === "string" ? observed : String(observed)}`);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
284
298
|
* Walks the `cause` chain (cycle-safe, hostile-getter-safe) looking for
|
|
285
299
|
* `retryable === true`: the same loose, property-based contract the
|
|
286
300
|
* kit's retry classifier (`someChainRetryable`) applies. Suites assert
|
|
@@ -353,13 +367,14 @@ function causeChainNames(error) {
|
|
|
353
367
|
//#region src/testing/command-outbox-contract.ts
|
|
354
368
|
function createCommandOutboxContractTests(harness) {
|
|
355
369
|
const inEnv = bindContractEnvironment(harness.createEnvironment);
|
|
356
|
-
const
|
|
370
|
+
const processSource = {
|
|
371
|
+
aggregateType: "CheckoutProcess",
|
|
372
|
+
aggregateId: "order-1"
|
|
373
|
+
};
|
|
374
|
+
const commit = (seed, commandSeeds = [seed], source = processSource) => ({
|
|
357
375
|
origin: {
|
|
358
376
|
eventId: `process-event-${seed}`,
|
|
359
|
-
source
|
|
360
|
-
aggregateType: "CheckoutProcess",
|
|
361
|
-
aggregateId: "order-1"
|
|
362
|
-
},
|
|
377
|
+
source,
|
|
363
378
|
position: {
|
|
364
379
|
aggregateVersion: seed,
|
|
365
380
|
commitSequence: 0,
|
|
@@ -551,6 +566,55 @@ function createCommandOutboxContractTests(harness) {
|
|
|
551
566
|
assertEqual(stored[0]?.messages.length, 0, "the retained empty receipt must contain no invented command");
|
|
552
567
|
assert(deepEqual(stored.map(({ origin }) => origin.position.aggregateVersion), [1, 2]), "the source cursor must advance through the empty receipt");
|
|
553
568
|
})
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
name: "rejects a new commit of an ended source",
|
|
572
|
+
run: inEnv(async (env) => {
|
|
573
|
+
const otherProcess = {
|
|
574
|
+
aggregateType: "CheckoutProcess",
|
|
575
|
+
aggregateId: "order-2"
|
|
576
|
+
};
|
|
577
|
+
const otherType = {
|
|
578
|
+
aggregateType: "ShippingProcess",
|
|
579
|
+
aggregateId: "order-1"
|
|
580
|
+
};
|
|
581
|
+
await env.addCommitted([commit(1)]);
|
|
582
|
+
await env.addCommitted([commit(4, [4], otherType)]);
|
|
583
|
+
await env.endEventSourcesCommitted([processSource]);
|
|
584
|
+
const secondEnd = await captureRejection(env.endEventSourcesCommitted([processSource]));
|
|
585
|
+
assert(secondEnd === void 0, `ending a source twice must not be an error. Got: ${describeError(secondEnd)}`);
|
|
586
|
+
const rejection = await captureRejection(env.addCommitted([commit(2)]));
|
|
587
|
+
assert(rejection !== void 0, "a new commit of an ended source must reject");
|
|
588
|
+
for (const [seed, source] of [[3, otherProcess], [5, otherType]]) {
|
|
589
|
+
const siblingRejection = await captureRejection(env.addCommitted([commit(seed, [seed], source)]));
|
|
590
|
+
assert(siblingRejection === void 0, `ending one source must not end another with the same id or the same type. Got: ${describeError(siblingRejection)}`);
|
|
591
|
+
}
|
|
592
|
+
assert(deepEqual((await env.readAll()).map(({ origin }) => origin.eventId).sort(), [
|
|
593
|
+
"process-event-1",
|
|
594
|
+
"process-event-3",
|
|
595
|
+
"process-event-4",
|
|
596
|
+
"process-event-5"
|
|
597
|
+
]), "the rejected commit must leave no receipt");
|
|
598
|
+
})
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
name: "deduplicates an exact retry of a commit of an ended source",
|
|
602
|
+
run: inEnv(async (env) => {
|
|
603
|
+
const original = commit(1);
|
|
604
|
+
await env.addCommitted([original]);
|
|
605
|
+
await env.endEventSourcesCommitted([processSource]);
|
|
606
|
+
const rejection = await captureRejection(env.addCommitted([original]));
|
|
607
|
+
assert(rejection === void 0, `an exact retry must stay idempotent after its source ended. Got: ${describeError(rejection)}`);
|
|
608
|
+
assertEqual((await env.readAll()).length, 1, "the retry must not append another receipt");
|
|
609
|
+
})
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
name: "ending a source without a cursor leaves it open for its first commit",
|
|
613
|
+
run: inEnv(async (env) => {
|
|
614
|
+
await env.endEventSourcesCommitted([processSource]);
|
|
615
|
+
await env.addCommitted([commit(1)]);
|
|
616
|
+
assertEqual((await env.readAll()).length, 1, "a source without a cursor has nothing to protect, so its first commit is accepted");
|
|
617
|
+
})
|
|
554
618
|
}
|
|
555
619
|
];
|
|
556
620
|
tests.push(gatedContractTest({
|
|
@@ -563,6 +627,18 @@ function createCommandOutboxContractTests(harness) {
|
|
|
563
627
|
await env.addRolledBack([commit(1)]);
|
|
564
628
|
assertEqual((await env.readAll()).length, 0, "a rolled-back transaction must persist no command receipt");
|
|
565
629
|
})
|
|
630
|
+
}), gatedContractTest({
|
|
631
|
+
capability: "providesRolledBackEnds",
|
|
632
|
+
satisfiedBy: harness.providesRolledBackEnds === true
|
|
633
|
+
}, {
|
|
634
|
+
name: "a rolled-back end leaves the source open",
|
|
635
|
+
run: inEnv(async (env) => {
|
|
636
|
+
if (!env.endEventSourcesRolledBack) throw new Error("Contract violated: harness declared providesRolledBackEnds but the environment lacks endEventSourcesRolledBack");
|
|
637
|
+
await env.addCommitted([commit(1)]);
|
|
638
|
+
await env.endEventSourcesRolledBack([processSource]);
|
|
639
|
+
await env.addCommitted([commit(2)]);
|
|
640
|
+
assertEqual((await env.readAll()).length, 2, "an end in a rolled-back transaction must not end the source");
|
|
641
|
+
})
|
|
566
642
|
}));
|
|
567
643
|
return tests;
|
|
568
644
|
}
|
|
@@ -882,6 +958,7 @@ function createDeadlineStoreContractTests(harness) {
|
|
|
882
958
|
dueAt: at(T0),
|
|
883
959
|
payload: { kind: "ghost" }
|
|
884
960
|
})).catch(() => {});
|
|
961
|
+
await env.run(async () => {});
|
|
885
962
|
assertEqual((await env.store.due(at(T2), 10)).length, 0, "a deadline from a rolled-back transaction is a ghost input and must not exist");
|
|
886
963
|
})
|
|
887
964
|
}),
|
|
@@ -899,6 +976,7 @@ function createDeadlineStoreContractTests(harness) {
|
|
|
899
976
|
payload: { kind: "x" }
|
|
900
977
|
}));
|
|
901
978
|
await env.runRolledBack(() => env.store.cancel("s", "k")).catch(() => {});
|
|
979
|
+
await env.run(async () => {});
|
|
902
980
|
assertEqual((await env.store.due(at(T1), 10)).length, 1, "a cancel from a rolled-back transaction must not have removed the deadline");
|
|
903
981
|
})
|
|
904
982
|
})
|
|
@@ -975,6 +1053,7 @@ function createEsRepositoryContractTests(harness) {
|
|
|
975
1053
|
writerB.release();
|
|
976
1054
|
const rejection = await captureRejection(writerB.call);
|
|
977
1055
|
assertChainContainsKitError(rejection, ["CONCURRENCY_CONFLICT"], `stale append must reject with ConcurrencyConflictError; got ${describeError(rejection)}`);
|
|
1056
|
+
assertChainConflictNamesIntent(rejection, "update", "the stale append conflict must name the update in its intent");
|
|
978
1057
|
const finalStream = await environment.committedStreamEvents(streamFor(seeded.id), readAll);
|
|
979
1058
|
assert(finalStream.exists && deepEqual(ids(finalStream.events), ids(streamAfterWinner.events)), "a rejected multi-event append must leave no prefix in the stream");
|
|
980
1059
|
const reloaded = await environment.run(({ repository }) => load(repository, seeded.id));
|
|
@@ -1545,11 +1624,11 @@ function createEventStoreContractTests(harness) {
|
|
|
1545
1624
|
const hasSameEventIds = (actual, expected) => actual.length === expected.length && actual.every((event, index) => event.eventId === expected[index]?.eventId);
|
|
1546
1625
|
return [
|
|
1547
1626
|
{
|
|
1548
|
-
name: "
|
|
1627
|
+
name: "absent stream: read reports explicit absence at version zero",
|
|
1549
1628
|
run: inEnv(async ({ store }) => {
|
|
1550
1629
|
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1551
|
-
const
|
|
1552
|
-
assert(!
|
|
1630
|
+
const absent = await store.readStream({ ...firstKey }, fixtureRead);
|
|
1631
|
+
assert(!absent.exists && absent.lastVersion === 0 && absent.events.length === 0, "an absent qualified stream must return the explicit absent state");
|
|
1553
1632
|
})
|
|
1554
1633
|
},
|
|
1555
1634
|
{
|
|
@@ -1576,7 +1655,7 @@ function createEventStoreContractTests(harness) {
|
|
|
1576
1655
|
await store.append(secondKey, [secondEvent], { expectedVersion: 0 });
|
|
1577
1656
|
const firstStream = await store.readStream({ ...firstKey }, fixtureRead);
|
|
1578
1657
|
const secondStream = await store.readStream({ ...secondKey }, fixtureRead);
|
|
1579
|
-
assert(firstStream.exists && firstStream.events.length === 1 && firstStream.events[0]?.eventId === firstEvent.eventId, "the first aggregate type must retain only its own event; key objects
|
|
1658
|
+
assert(firstStream.exists && firstStream.events.length === 1 && firstStream.events[0]?.eventId === firstEvent.eventId, "the first aggregate type must retain only its own event; key objects compare by value, not by object reference");
|
|
1580
1659
|
assert(secondStream.exists && secondStream.events.length === 1 && secondStream.events[0]?.eventId === secondEvent.eventId, "the second aggregate type must retain only its own event when the raw id collides");
|
|
1581
1660
|
})
|
|
1582
1661
|
},
|
|
@@ -1638,6 +1717,111 @@ function createEventStoreContractTests(harness) {
|
|
|
1638
1717
|
assert(atEnd.exists && atEnd.events.length === 0, "continuing at the pinned head must return an existing empty page");
|
|
1639
1718
|
})
|
|
1640
1719
|
},
|
|
1720
|
+
{
|
|
1721
|
+
name: "readStreamPages: the pages walk the pinned prefix and stop before a later append",
|
|
1722
|
+
run: inEnv(async ({ store }) => {
|
|
1723
|
+
const [firstKey, secondKey] = harness.createCollidingStreamKeys();
|
|
1724
|
+
const events = [
|
|
1725
|
+
1,
|
|
1726
|
+
2,
|
|
1727
|
+
3,
|
|
1728
|
+
4,
|
|
1729
|
+
5
|
|
1730
|
+
].map((sequence) => harness.createEvent(firstKey, sequence));
|
|
1731
|
+
await store.append(firstKey, events, { expectedVersion: 0 });
|
|
1732
|
+
const absent = await readStreamPages(store, { ...secondKey }, { limit: 2 });
|
|
1733
|
+
assert(!absent.exists, "readStreamPages must report an absent stream as absent");
|
|
1734
|
+
const read = await readStreamPages(store, { ...firstKey }, { limit: 2 });
|
|
1735
|
+
assert(read.reachable, "a full read of an existing stream must be reachable");
|
|
1736
|
+
assert(read.targetVersion === events.length, "the first page must pin the actual stream head");
|
|
1737
|
+
const collected = [];
|
|
1738
|
+
for await (const page of read.pages) {
|
|
1739
|
+
assert(page.length > 0 && page.length <= 2, "every page must hold at least one event and at most the limit");
|
|
1740
|
+
collected.push(...page);
|
|
1741
|
+
if (collected.length === page.length) await store.append(firstKey, [harness.createEvent(firstKey, 6)], { expectedVersion: events.length });
|
|
1742
|
+
}
|
|
1743
|
+
assert(hasSameEventIds(collected, events), "the pages must reproduce the pinned prefix in append order and leave the later append out");
|
|
1744
|
+
})
|
|
1745
|
+
},
|
|
1746
|
+
{
|
|
1747
|
+
name: "readStreamPages: toVersion below the head is the target version and the pages end there",
|
|
1748
|
+
run: inEnv(async ({ store }) => {
|
|
1749
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1750
|
+
const events = [
|
|
1751
|
+
1,
|
|
1752
|
+
2,
|
|
1753
|
+
3,
|
|
1754
|
+
4,
|
|
1755
|
+
5
|
|
1756
|
+
].map((sequence) => harness.createEvent(firstKey, sequence));
|
|
1757
|
+
await store.append(firstKey, events, { expectedVersion: 0 });
|
|
1758
|
+
const pointInTime = await readStreamPages(store, { ...firstKey }, {
|
|
1759
|
+
toVersion: 3,
|
|
1760
|
+
limit: 2
|
|
1761
|
+
});
|
|
1762
|
+
assert(pointInTime.reachable, "a target below the head must be reachable");
|
|
1763
|
+
assert(pointInTime.targetVersion === 3, "the read must pin toVersion as the target version");
|
|
1764
|
+
const collected = [];
|
|
1765
|
+
for await (const page of pointInTime.pages) collected.push(...page);
|
|
1766
|
+
assert(hasSameEventIds(collected, events.slice(0, 3)), "the pages must stop at toVersion");
|
|
1767
|
+
})
|
|
1768
|
+
},
|
|
1769
|
+
{
|
|
1770
|
+
name: "readStreamPages: toVersion beyond the head is unreachable and names the head",
|
|
1771
|
+
run: inEnv(async ({ store }) => {
|
|
1772
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1773
|
+
const events = [
|
|
1774
|
+
1,
|
|
1775
|
+
2,
|
|
1776
|
+
3
|
|
1777
|
+
].map((sequence) => harness.createEvent(firstKey, sequence));
|
|
1778
|
+
await store.append(firstKey, events, { expectedVersion: 0 });
|
|
1779
|
+
const beyondHead = await readStreamPages(store, { ...firstKey }, {
|
|
1780
|
+
toVersion: 9,
|
|
1781
|
+
limit: 2
|
|
1782
|
+
});
|
|
1783
|
+
assert(beyondHead.exists && !beyondHead.reachable && beyondHead.lastVersion === events.length, "a target beyond the head must be unreachable and name the actual head");
|
|
1784
|
+
})
|
|
1785
|
+
},
|
|
1786
|
+
{
|
|
1787
|
+
name: "readStreamPages: a cursor inside the stream reads only the events after it",
|
|
1788
|
+
run: inEnv(async ({ store }) => {
|
|
1789
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1790
|
+
const events = [
|
|
1791
|
+
1,
|
|
1792
|
+
2,
|
|
1793
|
+
3,
|
|
1794
|
+
4,
|
|
1795
|
+
5
|
|
1796
|
+
].map((sequence) => harness.createEvent(firstKey, sequence));
|
|
1797
|
+
await store.append(firstKey, events, { expectedVersion: 0 });
|
|
1798
|
+
const afterTwo = await readStreamPages(store, { ...firstKey }, {
|
|
1799
|
+
fromVersion: 2,
|
|
1800
|
+
limit: 2
|
|
1801
|
+
});
|
|
1802
|
+
assert(afterTwo.reachable && afterTwo.fromVersion === 2 && afterTwo.targetVersion === events.length, "a cursor inside the stream must be reachable and pin the head as the target version");
|
|
1803
|
+
const collected = [];
|
|
1804
|
+
for await (const page of afterTwo.pages) collected.push(...page);
|
|
1805
|
+
assert(hasSameEventIds(collected, events.slice(2)), "the pages must hold only the events after the cursor");
|
|
1806
|
+
})
|
|
1807
|
+
},
|
|
1808
|
+
{
|
|
1809
|
+
name: "readStreamPages: a cursor beyond the head is unreachable and names the head",
|
|
1810
|
+
run: inEnv(async ({ store }) => {
|
|
1811
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1812
|
+
const events = [
|
|
1813
|
+
1,
|
|
1814
|
+
2,
|
|
1815
|
+
3
|
|
1816
|
+
].map((sequence) => harness.createEvent(firstKey, sequence));
|
|
1817
|
+
await store.append(firstKey, events, { expectedVersion: 0 });
|
|
1818
|
+
const beyondHead = await readStreamPages(store, { ...firstKey }, {
|
|
1819
|
+
fromVersion: 9,
|
|
1820
|
+
limit: 2
|
|
1821
|
+
});
|
|
1822
|
+
assert(beyondHead.exists && !beyondHead.reachable && beyondHead.fromVersion === 9 && beyondHead.lastVersion === events.length, "a cursor beyond the head must be unreachable and name the actual head");
|
|
1823
|
+
})
|
|
1824
|
+
},
|
|
1641
1825
|
{
|
|
1642
1826
|
name: "read options: invalid limits and stream positions fail loudly",
|
|
1643
1827
|
run: inEnv(async ({ store }) => {
|
|
@@ -1746,6 +1930,53 @@ function createEventStoreContractTests(harness) {
|
|
|
1746
1930
|
for (const result of [atHead, beyondHead]) assert(result.exists && result.lastVersion === 2 && result.events.length === 0, "an empty read window must retain stream existence and report the actual head, even when fromVersion is beyond it");
|
|
1747
1931
|
})
|
|
1748
1932
|
},
|
|
1933
|
+
{
|
|
1934
|
+
name: "page bounds: an existing stream reports a head of at least 1 and no page holds more events than its window",
|
|
1935
|
+
run: inEnv(async ({ store }) => {
|
|
1936
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1937
|
+
const events = [
|
|
1938
|
+
1,
|
|
1939
|
+
2,
|
|
1940
|
+
3,
|
|
1941
|
+
4,
|
|
1942
|
+
5
|
|
1943
|
+
].map((sequence) => harness.createEvent(firstKey, sequence));
|
|
1944
|
+
await store.append(firstKey, events, { expectedVersion: 0 });
|
|
1945
|
+
for (const window of [
|
|
1946
|
+
{ limit: 100 },
|
|
1947
|
+
{ limit: 2 },
|
|
1948
|
+
{
|
|
1949
|
+
fromVersion: 2,
|
|
1950
|
+
limit: 100
|
|
1951
|
+
},
|
|
1952
|
+
{
|
|
1953
|
+
fromVersion: 3,
|
|
1954
|
+
toVersion: 4,
|
|
1955
|
+
limit: 100
|
|
1956
|
+
},
|
|
1957
|
+
{
|
|
1958
|
+
fromVersion: 4,
|
|
1959
|
+
toVersion: 99,
|
|
1960
|
+
limit: 100
|
|
1961
|
+
},
|
|
1962
|
+
{
|
|
1963
|
+
fromVersion: 99,
|
|
1964
|
+
limit: 100
|
|
1965
|
+
},
|
|
1966
|
+
{
|
|
1967
|
+
fromVersion: 3,
|
|
1968
|
+
toVersion: 1,
|
|
1969
|
+
limit: 100
|
|
1970
|
+
}
|
|
1971
|
+
]) {
|
|
1972
|
+
const page = await store.readStream({ ...firstKey }, window);
|
|
1973
|
+
assert(page.exists && page.lastVersion >= 1, "an existing stream must report a head of at least 1; report a stream without events as absent");
|
|
1974
|
+
const windowEnd = Math.min(window.toVersion ?? page.lastVersion, page.lastVersion);
|
|
1975
|
+
const room = Math.max(0, windowEnd - (window.fromVersion ?? 0));
|
|
1976
|
+
assert(page.events.length <= room && page.events.length <= window.limit, `a page must hold at most the ${room} events of its window (${window.fromVersion ?? 0}, ${windowEnd}] and at most the limit ${window.limit}; got ${page.events.length}`);
|
|
1977
|
+
}
|
|
1978
|
+
})
|
|
1979
|
+
},
|
|
1749
1980
|
{
|
|
1750
1981
|
name: "qualified fromVersion: slicing one type cannot observe a colliding raw id",
|
|
1751
1982
|
run: inEnv(async ({ store }) => {
|
|
@@ -1795,7 +2026,7 @@ function createEventStoreContractTests(harness) {
|
|
|
1795
2026
|
})
|
|
1796
2027
|
},
|
|
1797
2028
|
{
|
|
1798
|
-
name: "OCC: an expectedVersion ahead of an
|
|
2029
|
+
name: "OCC: an expectedVersion ahead of an absent stream conflicts without creating it",
|
|
1799
2030
|
run: inEnv(async ({ store }) => {
|
|
1800
2031
|
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1801
2032
|
const rejection = await captureRejection(store.append(firstKey, [harness.createEvent(firstKey, 60)], { expectedVersion: 3 }));
|
|
@@ -2177,6 +2408,57 @@ function createOutboxContractTests(harness) {
|
|
|
2177
2408
|
assert(records.every((record, index) => record.source.aggregateType === sources[index]?.aggregateType && record.source.aggregateId === sources[index]?.aggregateId && record.position.previousEventfulAggregateVersion === null), "colliding raw ids or aggregate types must each retain an independent genesis head");
|
|
2178
2409
|
})
|
|
2179
2410
|
},
|
|
2411
|
+
{
|
|
2412
|
+
name: "rejects a new event of an ended event source",
|
|
2413
|
+
run: inEnv(async (env) => {
|
|
2414
|
+
const removed = {
|
|
2415
|
+
aggregateType: "Order",
|
|
2416
|
+
aggregateId: "1"
|
|
2417
|
+
};
|
|
2418
|
+
const sibling = {
|
|
2419
|
+
aggregateType: "Payment",
|
|
2420
|
+
aggregateId: "1"
|
|
2421
|
+
};
|
|
2422
|
+
const sameType = {
|
|
2423
|
+
aggregateType: "Order",
|
|
2424
|
+
aggregateId: "2"
|
|
2425
|
+
};
|
|
2426
|
+
const first = harness.createEvent(1);
|
|
2427
|
+
const siblingFirst = harness.createEvent(2);
|
|
2428
|
+
const siblingNext = harness.createEvent(4);
|
|
2429
|
+
const sameTypeFirst = harness.createEvent(5);
|
|
2430
|
+
const sameTypeNext = harness.createEvent(6);
|
|
2431
|
+
await env.addCommitted(commit([first], 1, removed));
|
|
2432
|
+
await env.addCommitted(commit([siblingFirst], 1, sibling));
|
|
2433
|
+
await env.addCommitted(commit([sameTypeFirst], 1, sameType));
|
|
2434
|
+
await env.endEventSourcesCommitted([removed]);
|
|
2435
|
+
const secondEnd = await captureRejection(env.endEventSourcesCommitted([removed]));
|
|
2436
|
+
assert(secondEnd === void 0, `ending an event source twice must not be an error. Got: ${describeError(secondEnd)}`);
|
|
2437
|
+
const rejection = await captureRejection(env.addCommitted(commit([harness.createEvent(3)], 5, removed)));
|
|
2438
|
+
assert(rejection !== void 0, "a new event of an ended event source must reject, also above its head");
|
|
2439
|
+
for (const [event, source] of [[siblingNext, sibling], [sameTypeNext, sameType]]) {
|
|
2440
|
+
const siblingRejection = await captureRejection(env.addCommitted(commit([event], 2, source)));
|
|
2441
|
+
assert(siblingRejection === void 0, `ending one source must not end another with the same id or the same type. Got: ${describeError(siblingRejection)}`);
|
|
2442
|
+
}
|
|
2443
|
+
const records = await takeAndAck(env, 5);
|
|
2444
|
+
assert(deepEqual(records.map(({ event }) => event.eventId), [
|
|
2445
|
+
first.eventId,
|
|
2446
|
+
siblingFirst.eventId,
|
|
2447
|
+
sameTypeFirst.eventId,
|
|
2448
|
+
siblingNext.eventId,
|
|
2449
|
+
sameTypeNext.eventId
|
|
2450
|
+
]), "the rejected event must leave no record, and ending one source must not end another with the same id or the same type");
|
|
2451
|
+
})
|
|
2452
|
+
},
|
|
2453
|
+
{
|
|
2454
|
+
name: "ending an event source without events leaves it open for its first event",
|
|
2455
|
+
run: inEnv(async (env) => {
|
|
2456
|
+
await env.endEventSourcesCommitted([defaultSource]);
|
|
2457
|
+
await env.addCommitted(commit([harness.createEvent(1)], 1));
|
|
2458
|
+
const [record] = await takeAndAck(env, 1);
|
|
2459
|
+
assertEqual(record?.position.previousEventfulAggregateVersion, null, "a source without a head has no events to protect, so its first event starts a genesis head");
|
|
2460
|
+
})
|
|
2461
|
+
},
|
|
2180
2462
|
{
|
|
2181
2463
|
name: "getPending returns records in commit order, across separate committed adds",
|
|
2182
2464
|
run: inEnv(async (env) => {
|
|
@@ -2268,6 +2550,20 @@ function createOutboxContractTests(harness) {
|
|
|
2268
2550
|
const pending = await env.outbox.getPending(10);
|
|
2269
2551
|
assertEqual(pending.length, 1, "the same eventId must yield one record (unique-key dedupe)");
|
|
2270
2552
|
})
|
|
2553
|
+
}),
|
|
2554
|
+
gatedContractTest({
|
|
2555
|
+
capability: "dedupesOnEventId",
|
|
2556
|
+
satisfiedBy: harness.dedupesOnEventId === true
|
|
2557
|
+
}, {
|
|
2558
|
+
name: "an exact retry of a stored event of an ended source is deduped, not rejected",
|
|
2559
|
+
run: inEnv(async (env) => {
|
|
2560
|
+
const original = commit([harness.createEvent(1)]);
|
|
2561
|
+
await env.addCommitted(original);
|
|
2562
|
+
await env.endEventSourcesCommitted([defaultSource]);
|
|
2563
|
+
const rejection = await captureRejection(env.addCommitted(original));
|
|
2564
|
+
assert(rejection === void 0, `an exact retry of a stored event must stay idempotent after its source ended. Got: ${describeError(rejection)}`);
|
|
2565
|
+
assertEqual((await env.outbox.getPending(10)).length, 1, "the retry must not add a second record");
|
|
2566
|
+
})
|
|
2271
2567
|
})
|
|
2272
2568
|
];
|
|
2273
2569
|
tests.push(gatedContractTest({
|
|
@@ -2281,7 +2577,24 @@ function createOutboxContractTests(harness) {
|
|
|
2281
2577
|
assertEqual((await env.outbox.getPending(10)).length, 0, "events added in a rolled-back transaction must not appear");
|
|
2282
2578
|
await env.addCommitted(commit([harness.createEvent(2)], 2));
|
|
2283
2579
|
const [afterRollback] = await takeAndAck(env, 1);
|
|
2580
|
+
assertEqual(afterRollback?.event.eventId, harness.createEvent(2).eventId, "the only pending record after a rolled-back add must be the committed event");
|
|
2284
2581
|
assertEqual(afterRollback?.position.previousEventfulAggregateVersion, null, "a rolled-back add must not advance the event-source head");
|
|
2582
|
+
assertEqual((await env.outbox.getPending(10)).length, 0, "a rolled-back add must leave no record behind");
|
|
2583
|
+
})
|
|
2584
|
+
}));
|
|
2585
|
+
tests.push(gatedContractTest({
|
|
2586
|
+
capability: "providesRolledBackEnds",
|
|
2587
|
+
satisfiedBy: harness.providesRolledBackEnds === true
|
|
2588
|
+
}, {
|
|
2589
|
+
name: "a rolled-back end leaves the event source open (transactional participation)",
|
|
2590
|
+
run: inEnv(async (env) => {
|
|
2591
|
+
if (!env.endEventSourcesRolledBack) throw new Error("Contract violated: harness declared providesRolledBackEnds but the environment lacks endEventSourcesRolledBack");
|
|
2592
|
+
await env.addCommitted(commit([harness.createEvent(1)], 1));
|
|
2593
|
+
await env.endEventSourcesRolledBack([defaultSource]);
|
|
2594
|
+
const rejection = await captureRejection(env.addCommitted(commit([harness.createEvent(2)], 2)));
|
|
2595
|
+
assert(rejection === void 0, `an end in a rolled-back transaction must not end the event source, but the next event of the source was rejected: ${describeError(rejection)}`);
|
|
2596
|
+
const [, next] = await takeAndAck(env, 2);
|
|
2597
|
+
assertEqual(next?.position.previousEventfulAggregateVersion, 1, "an end in a rolled-back transaction must not end the event source");
|
|
2285
2598
|
})
|
|
2286
2599
|
}));
|
|
2287
2600
|
const trackingEnabled = harness.failuresToDeadLetter !== void 0;
|
|
@@ -2415,32 +2728,32 @@ function createProjectionCheckpointStoreContractTests(harness) {
|
|
|
2415
2728
|
const runConcurrently = env.runConcurrently;
|
|
2416
2729
|
if (!runConcurrently) throw new Error("Contract violated: harness declared providesConcurrentRuns but the environment lacks runConcurrently");
|
|
2417
2730
|
const contenders = 8;
|
|
2418
|
-
const
|
|
2731
|
+
const identity = order("o-locked");
|
|
2419
2732
|
const advanceOnce = async (expectedVersion, nextVersion) => {
|
|
2420
|
-
return (await runConcurrently(Array.from({ length: contenders }, () => (ctx) => env.store.withCheckpointLocks(ctx, "order-list", [
|
|
2421
|
-
const stored = await env.store.load(ctx, "order-list",
|
|
2733
|
+
return (await runConcurrently(Array.from({ length: contenders }, () => (ctx) => env.store.withCheckpointLocks(ctx, "order-list", [identity], async () => {
|
|
2734
|
+
const stored = await env.store.load(ctx, "order-list", identity);
|
|
2422
2735
|
if (expectedVersion === void 0 ? stored !== void 0 : stored?.position.aggregateVersion !== expectedVersion) return false;
|
|
2423
2736
|
await Promise.resolve();
|
|
2424
|
-
await env.store.save(ctx, "order-list",
|
|
2737
|
+
await env.store.save(ctx, "order-list", identity, checkpoint(pos(nextVersion, 0), `evt-v${nextVersion}`));
|
|
2425
2738
|
return true;
|
|
2426
2739
|
})))).filter((advanced) => advanced).length;
|
|
2427
2740
|
};
|
|
2428
2741
|
assertEqual(await advanceOnce(void 0, 1), 1, "exactly one competing callback may advance a missing checkpoint key; genesis has no row that SELECT FOR UPDATE could lock");
|
|
2429
2742
|
assertEqual(await advanceOnce(1, 2), 1, "exactly one competing callback may advance an existing checkpoint key from the observed watermark");
|
|
2430
|
-
const stored = await env.run((ctx) => env.store.load(ctx, "order-list",
|
|
2743
|
+
const stored = await env.run((ctx) => env.store.load(ctx, "order-list", identity));
|
|
2431
2744
|
assert(stored?.position.aggregateVersion === 2, "serialized genesis and existing-row advances must leave the final watermark visible");
|
|
2432
2745
|
})
|
|
2433
2746
|
}),
|
|
2434
2747
|
{
|
|
2435
2748
|
name: "checkpoint locks release after a rejected critical section",
|
|
2436
2749
|
run: inEnv(async (env) => {
|
|
2437
|
-
const
|
|
2438
|
-
const rejection = await captureRejection(env.run((ctx) => env.store.withCheckpointLocks(ctx, "order-list", [
|
|
2750
|
+
const identity = order("o-rejected-lock");
|
|
2751
|
+
const rejection = await captureRejection(env.run((ctx) => env.store.withCheckpointLocks(ctx, "order-list", [identity], async () => {
|
|
2439
2752
|
throw new Error("projection failed");
|
|
2440
2753
|
})));
|
|
2441
2754
|
assert(rejection !== void 0, "the store must propagate a rejected critical section");
|
|
2442
2755
|
let retried = false;
|
|
2443
|
-
await env.run((ctx) => env.store.withCheckpointLocks(ctx, "order-list", [
|
|
2756
|
+
await env.run((ctx) => env.store.withCheckpointLocks(ctx, "order-list", [identity], async () => {
|
|
2444
2757
|
retried = true;
|
|
2445
2758
|
}));
|
|
2446
2759
|
assert(retried, "a rejected callback must release its key so redelivery can enter");
|
|
@@ -2516,7 +2829,7 @@ function createProjectionCheckpointStoreContractTests(harness) {
|
|
|
2516
2829
|
})
|
|
2517
2830
|
},
|
|
2518
2831
|
{
|
|
2519
|
-
name: "
|
|
2832
|
+
name: "aggregate identity encoding is collision-free even with separator-like characters in either half",
|
|
2520
2833
|
run: inEnv(async (env) => {
|
|
2521
2834
|
const inType = {
|
|
2522
2835
|
aggregateType: "A\0B",
|
|
@@ -2531,7 +2844,7 @@ function createProjectionCheckpointStoreContractTests(harness) {
|
|
|
2531
2844
|
await env.store.save(ctx, "order-list", inId, checkpoint(pos(1, 0)));
|
|
2532
2845
|
});
|
|
2533
2846
|
const [first, second] = await env.run((ctx) => Promise.all([env.store.load(ctx, "order-list", inType), env.store.load(ctx, "order-list", inId)]));
|
|
2534
|
-
assert(first?.position.aggregateVersion === 10 && second?.position.aggregateVersion === 1, "two
|
|
2847
|
+
assert(first?.position.aggregateVersion === 10 && second?.position.aggregateVersion === 1, "two aggregate identities that differ only in where a hostile separator sits must not share a watermark");
|
|
2535
2848
|
})
|
|
2536
2849
|
},
|
|
2537
2850
|
{
|
|
@@ -2574,6 +2887,185 @@ function createProjectionCheckpointStoreContractTests(harness) {
|
|
|
2574
2887
|
];
|
|
2575
2888
|
}
|
|
2576
2889
|
|
|
2890
|
+
//#endregion
|
|
2891
|
+
//#region src/testing/replayable-stream-pages.ts
|
|
2892
|
+
/**
|
|
2893
|
+
* Builds a replayable stream read from an in-memory tail, for a test of the
|
|
2894
|
+
* code that consumes a read: a repository, or the replay over a fixed window.
|
|
2895
|
+
* It stands in for `readStreamPages` or an adapter that pages on its own,
|
|
2896
|
+
* and it tests neither. The tail is copied once and frozen deeply, and it
|
|
2897
|
+
* comes back in the same pages on every iteration. So a consumer that
|
|
2898
|
+
* changes an event in place fails at the write in its own test. An empty
|
|
2899
|
+
* tail yields no page, as `readStreamPages` does.
|
|
2900
|
+
*/
|
|
2901
|
+
function createReplayableStreamPages(stream, options) {
|
|
2902
|
+
const limit = options.limit ?? Math.max(options.tail.length, 1);
|
|
2903
|
+
assertPositiveSafeInteger("createReplayableStreamPages", "limit", limit);
|
|
2904
|
+
const pages = sliceIntoPages(deepFreeze(structuredClone([...options.tail])), limit);
|
|
2905
|
+
return {
|
|
2906
|
+
stream,
|
|
2907
|
+
fromVersion: options.fromVersion,
|
|
2908
|
+
targetVersion: options.targetVersion,
|
|
2909
|
+
pages: { [Symbol.asyncIterator]: () => yieldEachPage(pages) }
|
|
2910
|
+
};
|
|
2911
|
+
}
|
|
2912
|
+
function sliceIntoPages(tail, limit) {
|
|
2913
|
+
const pages = [];
|
|
2914
|
+
for (let start = 0; start < tail.length; start += limit) pages.push(Object.freeze(tail.slice(start, start + limit)));
|
|
2915
|
+
return pages;
|
|
2916
|
+
}
|
|
2917
|
+
async function* yieldEachPage(pages) {
|
|
2918
|
+
yield* pages;
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
//#endregion
|
|
2922
|
+
//#region src/testing/replayable-stream-pages-contract.ts
|
|
2923
|
+
/**
|
|
2924
|
+
* Reusable proof of code that builds `ReplayableStreamPages` values:
|
|
2925
|
+
* `readStreamPages`, or an adapter that pages on its own.
|
|
2926
|
+
* The suite proves that the pages hold the events after the cursor
|
|
2927
|
+
* through the target version in append order, that no page is empty or
|
|
2928
|
+
* larger than the limit, that every iteration yields the same prefix, and
|
|
2929
|
+
* that an append during the iteration stays out. It also proves that an
|
|
2930
|
+
* absent stream and a window outside the stream give no replayable read.
|
|
2931
|
+
*/
|
|
2932
|
+
function createReplayableStreamPagesContractTests(harness) {
|
|
2933
|
+
const inEnv = bindContractEnvironment(() => harness.createEnvironment());
|
|
2934
|
+
const seed = async (env, count) => {
|
|
2935
|
+
const stream = harness.createStream();
|
|
2936
|
+
const events = Array.from({ length: count }, (_, index) => harness.createEvent(stream, index + 1));
|
|
2937
|
+
await env.append(stream, events);
|
|
2938
|
+
return {
|
|
2939
|
+
stream,
|
|
2940
|
+
events
|
|
2941
|
+
};
|
|
2942
|
+
};
|
|
2943
|
+
const readOrFail = async (env, stream, window) => {
|
|
2944
|
+
const read = await env.read({ ...stream }, window);
|
|
2945
|
+
assert(read !== void 0, `a window (${window.fromVersion}, ${window.toVersion ?? "head"}] inside the stream must give a replayable read`);
|
|
2946
|
+
return read;
|
|
2947
|
+
};
|
|
2948
|
+
const collectPages = async (read, limit) => {
|
|
2949
|
+
const collected = [];
|
|
2950
|
+
for await (const page of read.pages) {
|
|
2951
|
+
assert(page.length > 0 && page.length <= limit, `every page must hold at least one event and at most the limit ${limit}; got ${page.length}`);
|
|
2952
|
+
collected.push(...page);
|
|
2953
|
+
}
|
|
2954
|
+
return collected;
|
|
2955
|
+
};
|
|
2956
|
+
const sameEventIds = (actual, expected) => actual.length === expected.length && actual.every((event, index) => event.eventId === expected[index]?.eventId);
|
|
2957
|
+
return [
|
|
2958
|
+
{
|
|
2959
|
+
name: "full read: the pages hold every event through the head in append order",
|
|
2960
|
+
run: inEnv(async (env) => {
|
|
2961
|
+
const { stream, events } = await seed(env, 5);
|
|
2962
|
+
const read = await readOrFail(env, stream, {
|
|
2963
|
+
fromVersion: 0,
|
|
2964
|
+
limit: 2
|
|
2965
|
+
});
|
|
2966
|
+
assert(read.stream.aggregateType === stream.aggregateType && read.stream.aggregateId === stream.aggregateId, "the read must name the stream it reads");
|
|
2967
|
+
assert(read.fromVersion === 0 && read.targetVersion === events.length, `a full read must run from 0 to the head ${events.length}; got (${read.fromVersion}, ${read.targetVersion}]`);
|
|
2968
|
+
assert(sameEventIds(await collectPages(read, 2), events), "the pages must hold every event of the stream in append order");
|
|
2969
|
+
})
|
|
2970
|
+
},
|
|
2971
|
+
{
|
|
2972
|
+
name: "catch-up read: the pages start after fromVersion",
|
|
2973
|
+
run: inEnv(async (env) => {
|
|
2974
|
+
const { stream, events } = await seed(env, 5);
|
|
2975
|
+
const read = await readOrFail(env, stream, {
|
|
2976
|
+
fromVersion: 2,
|
|
2977
|
+
limit: 2
|
|
2978
|
+
});
|
|
2979
|
+
assert(read.fromVersion === 2 && read.targetVersion === events.length, `a catch-up read must run from 2 to the head ${events.length}; got (${read.fromVersion}, ${read.targetVersion}]`);
|
|
2980
|
+
assert(sameEventIds(await collectPages(read, 2), events.slice(2)), "the pages must hold only the events after fromVersion");
|
|
2981
|
+
})
|
|
2982
|
+
},
|
|
2983
|
+
{
|
|
2984
|
+
name: "point-in-time read: toVersion is the target version and the pages end there",
|
|
2985
|
+
run: inEnv(async (env) => {
|
|
2986
|
+
const { stream, events } = await seed(env, 5);
|
|
2987
|
+
const read = await readOrFail(env, stream, {
|
|
2988
|
+
fromVersion: 0,
|
|
2989
|
+
toVersion: 3,
|
|
2990
|
+
limit: 2
|
|
2991
|
+
});
|
|
2992
|
+
assert(read.targetVersion === 3, `a read to version 3 must pin 3 as the target version; got ${read.targetVersion}`);
|
|
2993
|
+
assert(sameEventIds(await collectPages(read, 2), events.slice(0, 3)), "the pages must end at the target version");
|
|
2994
|
+
})
|
|
2995
|
+
},
|
|
2996
|
+
{
|
|
2997
|
+
name: "empty window: a read at the head yields no page",
|
|
2998
|
+
run: inEnv(async (env) => {
|
|
2999
|
+
const { stream, events } = await seed(env, 3);
|
|
3000
|
+
const read = await readOrFail(env, stream, {
|
|
3001
|
+
fromVersion: events.length,
|
|
3002
|
+
limit: 2
|
|
3003
|
+
});
|
|
3004
|
+
assert(read.targetVersion === events.length, `a read at the head must pin the head ${events.length}; got ${read.targetVersion}`);
|
|
3005
|
+
assert((await collectPages(read, 2)).length === 0, "a read at the head must yield no page, not an empty one");
|
|
3006
|
+
})
|
|
3007
|
+
},
|
|
3008
|
+
{
|
|
3009
|
+
name: "re-iteration: every iteration yields the same prefix",
|
|
3010
|
+
run: inEnv(async (env) => {
|
|
3011
|
+
const { stream, events } = await seed(env, 5);
|
|
3012
|
+
const read = await readOrFail(env, stream, {
|
|
3013
|
+
fromVersion: 0,
|
|
3014
|
+
limit: 2
|
|
3015
|
+
});
|
|
3016
|
+
const first = await collectPages(read, 2);
|
|
3017
|
+
const second = await collectPages(read, 2);
|
|
3018
|
+
assert(sameEventIds(first, events) && sameEventIds(second, events), "a second iteration must start again from the first page and yield the same events");
|
|
3019
|
+
})
|
|
3020
|
+
},
|
|
3021
|
+
{
|
|
3022
|
+
name: "target as upper bound: an append during the iteration stays out of the pages",
|
|
3023
|
+
run: inEnv(async (env) => {
|
|
3024
|
+
const { stream, events } = await seed(env, 5);
|
|
3025
|
+
const read = await readOrFail(env, stream, {
|
|
3026
|
+
fromVersion: 0,
|
|
3027
|
+
limit: 2
|
|
3028
|
+
});
|
|
3029
|
+
const collected = [];
|
|
3030
|
+
for await (const page of read.pages) {
|
|
3031
|
+
if (collected.length === 0) await env.append(stream, [harness.createEvent(stream, 6)]);
|
|
3032
|
+
collected.push(...page);
|
|
3033
|
+
}
|
|
3034
|
+
assert(read.targetVersion === events.length, "an append after the read must not move the target version");
|
|
3035
|
+
assert(sameEventIds(collected, events), "the pages must end at the target version and leave the later append out");
|
|
3036
|
+
})
|
|
3037
|
+
},
|
|
3038
|
+
{
|
|
3039
|
+
name: "no replayable read: an absent stream and a window outside the stream",
|
|
3040
|
+
run: inEnv(async (env) => {
|
|
3041
|
+
const { stream, events } = await seed(env, 3);
|
|
3042
|
+
const absent = harness.createStream();
|
|
3043
|
+
const outside = [
|
|
3044
|
+
{
|
|
3045
|
+
fromVersion: 0,
|
|
3046
|
+
toVersion: events.length + 1,
|
|
3047
|
+
limit: 2
|
|
3048
|
+
},
|
|
3049
|
+
{
|
|
3050
|
+
fromVersion: events.length + 1,
|
|
3051
|
+
limit: 2
|
|
3052
|
+
},
|
|
3053
|
+
{
|
|
3054
|
+
fromVersion: 3,
|
|
3055
|
+
toVersion: 2,
|
|
3056
|
+
limit: 2
|
|
3057
|
+
}
|
|
3058
|
+
];
|
|
3059
|
+
assert(await env.read({ ...absent }, {
|
|
3060
|
+
fromVersion: 0,
|
|
3061
|
+
limit: 2
|
|
3062
|
+
}) === void 0, "an absent stream must give no replayable read");
|
|
3063
|
+
for (const window of outside) assert(await env.read({ ...stream }, window) === void 0, `the window (${window.fromVersion}, ${window.toVersion ?? "head"}] lies outside a stream at ${events.length} and must give no replayable read`);
|
|
3064
|
+
})
|
|
3065
|
+
}
|
|
3066
|
+
];
|
|
3067
|
+
}
|
|
3068
|
+
|
|
2577
3069
|
//#endregion
|
|
2578
3070
|
//#region src/testing/repository-contract.ts
|
|
2579
3071
|
/**
|
|
@@ -2679,6 +3171,7 @@ function createRepositoryContractTests(harness) {
|
|
|
2679
3171
|
writerB.release();
|
|
2680
3172
|
const rejection = await captureRejection(writerB.call);
|
|
2681
3173
|
assertChainContainsKitError(rejection, ["CONCURRENCY_CONFLICT"], `stale update must reject with ConcurrencyConflictError; got ${describeError(rejection)}`);
|
|
3174
|
+
assertChainConflictNamesIntent(rejection, "update", "the stale update conflict must name the update in its intent");
|
|
2682
3175
|
const final = await reload(environment, seeded.id);
|
|
2683
3176
|
assertEqual(final.version, committedA.version, "the stale writer must not replace writer A's version");
|
|
2684
3177
|
if (snapshotState) assert(deepEqual(snapshotState.call(harness, final), snapshotState.call(harness, committedA)), "the stale writer must not replace writer A's state");
|
|
@@ -2835,6 +3328,7 @@ function createRepositoryContractTests(harness) {
|
|
|
2835
3328
|
staleRemove.release();
|
|
2836
3329
|
const rejection = await captureRejection(staleRemove.call);
|
|
2837
3330
|
assertChainContainsKitError(rejection, ["CONCURRENCY_CONFLICT"], `stale remove must reject with ConcurrencyConflictError; got ${describeError(rejection)}`);
|
|
3331
|
+
assertChainConflictNamesIntent(rejection, "remove", "the stale remove conflict must name the remove in its intent");
|
|
2838
3332
|
assert(await reload(environment, seeded.id) !== void 0, "stale remove must not delete the concurrent winner");
|
|
2839
3333
|
})
|
|
2840
3334
|
})));
|
|
@@ -2853,7 +3347,7 @@ function snapshot(version, state, schemaVersion) {
|
|
|
2853
3347
|
};
|
|
2854
3348
|
}
|
|
2855
3349
|
const id = (value) => value;
|
|
2856
|
-
const
|
|
3350
|
+
const identity = (aggregateType, aggregateId) => ({
|
|
2857
3351
|
aggregateType,
|
|
2858
3352
|
aggregateId: id(aggregateId)
|
|
2859
3353
|
});
|
|
@@ -2873,7 +3367,7 @@ function createSnapshotStoreContractTests(harness) {
|
|
|
2873
3367
|
{
|
|
2874
3368
|
name: "an aggregate without a snapshot loads undefined",
|
|
2875
3369
|
run: inEnv(async (env) => {
|
|
2876
|
-
assertEqual(await env.store.load(
|
|
3370
|
+
assertEqual(await env.store.load(identity("Order", "o-1")), void 0, "a fresh store must report no snapshot; the repository falls back to full replay");
|
|
2877
3371
|
})
|
|
2878
3372
|
},
|
|
2879
3373
|
{
|
|
@@ -2887,8 +3381,8 @@ function createSnapshotStoreContractTests(harness) {
|
|
|
2887
3381
|
}],
|
|
2888
3382
|
note: "hi"
|
|
2889
3383
|
}, 3);
|
|
2890
|
-
await env.store.save(
|
|
2891
|
-
const loaded = await env.store.load(
|
|
3384
|
+
await env.store.save(identity("Order", "o-1"), stored);
|
|
3385
|
+
const loaded = await env.store.load(identity("Order", "o-1"));
|
|
2892
3386
|
assert(loaded !== void 0, "the saved snapshot must load");
|
|
2893
3387
|
assert(deepEqual(loaded.state, stored.state), "the state must round-trip deep-equal as plain data");
|
|
2894
3388
|
assertEqual(loaded.version, 42, "the aggregate version must round-trip");
|
|
@@ -2900,67 +3394,67 @@ function createSnapshotStoreContractTests(harness) {
|
|
|
2900
3394
|
{
|
|
2901
3395
|
name: "an absent schemaVersion round-trips as absent",
|
|
2902
3396
|
run: inEnv(async (env) => {
|
|
2903
|
-
await env.store.save(
|
|
3397
|
+
await env.store.save(identity("Order", "o-1"), snapshot(1, {
|
|
2904
3398
|
total: 0,
|
|
2905
3399
|
items: []
|
|
2906
3400
|
}));
|
|
2907
|
-
const loaded = await env.store.load(
|
|
3401
|
+
const loaded = await env.store.load(identity("Order", "o-1"));
|
|
2908
3402
|
assertEqual(loaded?.schemaVersion, void 0, "a snapshot stored without schemaVersion must not come back with a fabricated one; restore treats absence as schema 1");
|
|
2909
3403
|
})
|
|
2910
3404
|
},
|
|
2911
3405
|
{
|
|
2912
3406
|
name: "save replaces the previous snapshot: latest wins, no history",
|
|
2913
3407
|
run: inEnv(async (env) => {
|
|
2914
|
-
await env.store.save(
|
|
3408
|
+
await env.store.save(identity("Order", "o-1"), snapshot(10, {
|
|
2915
3409
|
total: 1,
|
|
2916
3410
|
items: []
|
|
2917
3411
|
}));
|
|
2918
|
-
await env.store.save(
|
|
3412
|
+
await env.store.save(identity("Order", "o-1"), snapshot(20, {
|
|
2919
3413
|
total: 2,
|
|
2920
3414
|
items: []
|
|
2921
3415
|
}));
|
|
2922
|
-
const loaded = await env.store.load(
|
|
3416
|
+
const loaded = await env.store.load(identity("Order", "o-1"));
|
|
2923
3417
|
assert(loaded?.version === 20 && loaded.state.total === 2, "load must return the latest snapshot only");
|
|
2924
3418
|
})
|
|
2925
3419
|
},
|
|
2926
3420
|
{
|
|
2927
3421
|
name: "snapshots are isolated per aggregate type AND per aggregate id",
|
|
2928
3422
|
run: inEnv(async (env) => {
|
|
2929
|
-
await env.store.save(
|
|
3423
|
+
await env.store.save(identity("Order", "x-1"), snapshot(1, {
|
|
2930
3424
|
total: 1,
|
|
2931
3425
|
items: []
|
|
2932
3426
|
}));
|
|
2933
|
-
await env.store.save(
|
|
3427
|
+
await env.store.save(identity("Invoice", "x-1"), snapshot(2, {
|
|
2934
3428
|
total: 2,
|
|
2935
3429
|
items: []
|
|
2936
3430
|
}));
|
|
2937
|
-
await env.store.save(
|
|
3431
|
+
await env.store.save(identity("Order", "x-2"), snapshot(3, {
|
|
2938
3432
|
total: 3,
|
|
2939
3433
|
items: []
|
|
2940
3434
|
}));
|
|
2941
3435
|
const [orderX1, invoiceX1, orderX2] = await Promise.all([
|
|
2942
|
-
env.store.load(
|
|
2943
|
-
env.store.load(
|
|
2944
|
-
env.store.load(
|
|
3436
|
+
env.store.load(identity("Order", "x-1")),
|
|
3437
|
+
env.store.load(identity("Invoice", "x-1")),
|
|
3438
|
+
env.store.load(identity("Order", "x-2"))
|
|
2945
3439
|
]);
|
|
2946
3440
|
assert(orderX1?.version === 1 && invoiceX1?.version === 2 && orderX2?.version === 3, "the key is the (aggregateType, aggregateId) pair; neither half may bleed into the other");
|
|
2947
3441
|
})
|
|
2948
3442
|
},
|
|
2949
3443
|
{
|
|
2950
|
-
name: "delete removes exactly the
|
|
3444
|
+
name: "delete removes exactly the snapshot of the given aggregate identity and tolerates unknown keys",
|
|
2951
3445
|
run: inEnv(async (env) => {
|
|
2952
|
-
await env.store.save(
|
|
3446
|
+
await env.store.save(identity("Order", "o-1"), snapshot(1, {
|
|
2953
3447
|
total: 1,
|
|
2954
3448
|
items: []
|
|
2955
3449
|
}));
|
|
2956
|
-
await env.store.save(
|
|
3450
|
+
await env.store.save(identity("Order", "o-2"), snapshot(2, {
|
|
2957
3451
|
total: 2,
|
|
2958
3452
|
items: []
|
|
2959
3453
|
}));
|
|
2960
|
-
await env.store.delete(
|
|
2961
|
-
await env.store.delete(
|
|
2962
|
-
assertEqual(await env.store.load(
|
|
2963
|
-
assertEqual((await env.store.load(
|
|
3454
|
+
await env.store.delete(identity("Order", "o-1"));
|
|
3455
|
+
await env.store.delete(identity("Order", "never-saved"));
|
|
3456
|
+
assertEqual(await env.store.load(identity("Order", "o-1")), void 0, "the deleted snapshot must be gone (schema-migration fallback and erasure both rely on it)");
|
|
3457
|
+
assertEqual((await env.store.load(identity("Order", "o-2")))?.version, 2, "a sibling snapshot must survive the delete");
|
|
2964
3458
|
})
|
|
2965
3459
|
},
|
|
2966
3460
|
{
|
|
@@ -2973,16 +3467,16 @@ function createSnapshotStoreContractTests(harness) {
|
|
|
2973
3467
|
qty: 1
|
|
2974
3468
|
}]
|
|
2975
3469
|
});
|
|
2976
|
-
await env.store.save(
|
|
3470
|
+
await env.store.save(identity("Order", "o-1"), input);
|
|
2977
3471
|
input.state.items.push({
|
|
2978
3472
|
sku: "hacked",
|
|
2979
3473
|
qty: 99
|
|
2980
3474
|
});
|
|
2981
|
-
const loaded = await env.store.load(
|
|
3475
|
+
const loaded = await env.store.load(identity("Order", "o-1"));
|
|
2982
3476
|
assert(loaded !== void 0, "expected the saved snapshot");
|
|
2983
3477
|
assertEqual(loaded.state.items.length, 1, "save must capture the snapshot by value, not hold the caller's reference");
|
|
2984
3478
|
loaded.state.total = 999;
|
|
2985
|
-
const reloaded = await env.store.load(
|
|
3479
|
+
const reloaded = await env.store.load(identity("Order", "o-1"));
|
|
2986
3480
|
assertEqual(reloaded?.state.total, 5, "load must hand out a detached copy, never live internal state");
|
|
2987
3481
|
})
|
|
2988
3482
|
}
|
|
@@ -2990,5 +3484,5 @@ function createSnapshotStoreContractTests(harness) {
|
|
|
2990
3484
|
}
|
|
2991
3485
|
|
|
2992
3486
|
//#endregion
|
|
2993
|
-
export { createCommandOutboxContractTests, createDeadlineStoreContractTests, createEsRepositoryContractTests, createEventBusContractTests, createEventStoreContractTests, createIdempotencyStoreContractTests, createOutboxContractTests, createProjectionCheckpointStoreContractTests, createRepositoryContractTests, createSnapshotStoreContractTests };
|
|
3487
|
+
export { createCommandOutboxContractTests, createDeadlineStoreContractTests, createEsRepositoryContractTests, createEventBusContractTests, createEventStoreContractTests, createIdempotencyStoreContractTests, createOutboxContractTests, createProjectionCheckpointStoreContractTests, createReplayableStreamPages, createReplayableStreamPagesContractTests, createRepositoryContractTests, createSnapshotStoreContractTests };
|
|
2994
3488
|
//# sourceMappingURL=testing.js.map
|