@shirudo/ddd-kit 3.0.0-rc.4 → 3.0.0-rc.6
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/README.md +4 -20
- package/dist/chunks/{errors.d.ts → kit-errors.d.ts} +272 -48
- package/dist/chunks/{errors.js → kit-errors.js} +275 -57
- package/dist/chunks/kit-errors.js.map +1 -0
- package/dist/chunks/ports.js +910 -110
- package/dist/chunks/ports.js.map +1 -1
- package/dist/chunks/snapshot-store.d.ts +925 -1338
- package/dist/http.d.ts +2 -3
- package/dist/http.js +1 -1
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +3469 -2432
- package/dist/index.js +5663 -4869
- package/dist/index.js.map +1 -1
- package/dist/money.d.ts +11 -11
- package/dist/money.js +9 -9
- package/dist/money.js.map +1 -1
- package/dist/{presentation.d.ts → public-errors.d.ts} +5 -6
- package/dist/{presentation.js → public-errors.js} +5 -5
- package/dist/public-errors.js.map +1 -0
- package/dist/testing.d.ts +61 -11
- package/dist/testing.js +526 -56
- package/dist/testing.js.map +1 -1
- package/package.json +18 -18
- package/dist/chunks/deep-equal-except.js +0 -639
- package/dist/chunks/deep-equal-except.js.map +0 -1
- package/dist/chunks/errors.js.map +0 -1
- package/dist/chunks/utils.d.ts +0 -110
- package/dist/presentation.js.map +0 -1
- package/dist/utils.d.ts +0 -2
- package/dist/utils.js +0 -3
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as isDispatchTrackingOutbox, u as isMintedEvent } from "./chunks/ports.js";
|
|
1
|
+
import { E as deepEqual, t as isDispatchTrackingOutbox, u as isRecordedDomainEvent } from "./chunks/ports.js";
|
|
3
2
|
|
|
4
3
|
//#region src/testing/contract-assertions.ts
|
|
5
4
|
/**
|
|
@@ -80,12 +79,13 @@ function gatedContractTest(gate, test) {
|
|
|
80
79
|
}
|
|
81
80
|
/**
|
|
82
81
|
* Identities of an in-memory pending batch, with the shared precondition
|
|
83
|
-
* that every event
|
|
84
|
-
* suite-specific rule the harness violated when an event is
|
|
82
|
+
* that every event carries the recorded brand. The `requirement` names
|
|
83
|
+
* the suite-specific rule the harness violated when an event is not
|
|
84
|
+
* recorded.
|
|
85
85
|
*/
|
|
86
|
-
function
|
|
86
|
+
function recordedPendingEventIds(events, requirement) {
|
|
87
87
|
return events.map((event) => {
|
|
88
|
-
assert(typeof event === "object" && event !== null &&
|
|
88
|
+
assert(typeof event === "object" && event !== null && isRecordedDomainEvent(event), requirement);
|
|
89
89
|
return event.eventId;
|
|
90
90
|
});
|
|
91
91
|
}
|
|
@@ -276,7 +276,8 @@ function createCommandOutboxContractTests(harness) {
|
|
|
276
276
|
}
|
|
277
277
|
};
|
|
278
278
|
assert(!deepEqual(conflict.messages, original.messages), "harness contract: createCommand must return distinct content per seed, or the manufactured conflict is an exact retry");
|
|
279
|
-
|
|
279
|
+
const rejection = await captureRejection(env.addCommitted([conflict]));
|
|
280
|
+
assert(rejection !== void 0, "a reused origin event id with different messages must reject");
|
|
280
281
|
const stored = await env.readAll();
|
|
281
282
|
assertEqual(stored.length, 1, "a conflicting retry must not append another receipt");
|
|
282
283
|
assert(deepEqual(stored[0], original), "a conflicting retry must not replace the original receipt");
|
|
@@ -313,8 +314,10 @@ function createCommandOutboxContractTests(harness) {
|
|
|
313
314
|
}
|
|
314
315
|
}];
|
|
315
316
|
for (const { fact, candidate } of conflicts) {
|
|
316
|
-
|
|
317
|
-
assert(
|
|
317
|
+
const rejection = await captureRejection(env.addCommitted([candidate]));
|
|
318
|
+
assert(rejection !== void 0, `a reused origin event id with a different source.${fact} must reject`);
|
|
319
|
+
const stored = await env.readAll();
|
|
320
|
+
assert(deepEqual(stored, [original]), `a source.${fact} conflict must leave the original receipt unchanged`);
|
|
318
321
|
}
|
|
319
322
|
})
|
|
320
323
|
},
|
|
@@ -347,8 +350,10 @@ function createCommandOutboxContractTests(harness) {
|
|
|
347
350
|
}
|
|
348
351
|
}
|
|
349
352
|
};
|
|
350
|
-
|
|
351
|
-
assert(
|
|
353
|
+
const rejection = await captureRejection(env.addCommitted([conflict]));
|
|
354
|
+
assert(rejection !== void 0, `a reused origin event id with a different position.${fact} must reject`);
|
|
355
|
+
const stored = await env.readAll();
|
|
356
|
+
assert(deepEqual(stored, [original]), `a position.${fact} conflict must leave the original receipt unchanged`);
|
|
352
357
|
}
|
|
353
358
|
})
|
|
354
359
|
},
|
|
@@ -366,7 +371,8 @@ function createCommandOutboxContractTests(harness) {
|
|
|
366
371
|
}
|
|
367
372
|
};
|
|
368
373
|
assert(!deepEqual(conflictingOriginal.messages, original.messages), "harness contract: createCommand must return distinct content per seed, or the manufactured conflict is an exact retry");
|
|
369
|
-
|
|
374
|
+
const rejection = await captureRejection(env.addCommitted([newCommit, conflictingOriginal]));
|
|
375
|
+
assert(rejection !== void 0, "a batch containing a conflicting origin must reject");
|
|
370
376
|
const stored = await env.readAll();
|
|
371
377
|
assertEqual(stored.length, 1, "a rejected batch must not leave its earlier new receipt behind");
|
|
372
378
|
assert(deepEqual(stored[0], original), "a rejected batch must preserve the pre-existing receipt");
|
|
@@ -412,7 +418,8 @@ function createCommandOutboxContractTests(harness) {
|
|
|
412
418
|
}
|
|
413
419
|
}];
|
|
414
420
|
await env.addCommitted(multiEventCommit);
|
|
415
|
-
|
|
421
|
+
const stored = await env.readAll();
|
|
422
|
+
assert(deepEqual(stored, multiEventCommit), "a multi-event commit must retain its shared version, sequence, and size for every receipt");
|
|
416
423
|
})
|
|
417
424
|
},
|
|
418
425
|
{
|
|
@@ -558,7 +565,8 @@ function createDeadlineStoreContractTests(harness) {
|
|
|
558
565
|
await env.store.cancel("s", "drop");
|
|
559
566
|
await env.store.cancel("s", "never-scheduled");
|
|
560
567
|
});
|
|
561
|
-
|
|
568
|
+
const due = await env.store.due(at(T1), 10);
|
|
569
|
+
assert(deepEqual(due.map((d) => d.key), ["keep"]), "cancel must remove the addressed deadline and nothing else");
|
|
562
570
|
})
|
|
563
571
|
},
|
|
564
572
|
{
|
|
@@ -691,7 +699,8 @@ function createDeadlineStoreContractTests(harness) {
|
|
|
691
699
|
const [poison] = await env.store.due(at(T1), 1);
|
|
692
700
|
assert(poison !== void 0, "expected the earliest due deadline");
|
|
693
701
|
await env.store.markFailed(poison.deliveryId, /* @__PURE__ */ new Error("boom"));
|
|
694
|
-
|
|
702
|
+
const afterOne = await env.store.due(at(T1), 10);
|
|
703
|
+
assertEqual(afterOne.find((d) => d.deliveryId === poison.deliveryId)?.attempts, 1, "attempts must be surfaced on the record after markFailed");
|
|
695
704
|
for (let i = 1; i < ceiling; i++) await env.store.markFailed(poison.deliveryId, /* @__PURE__ */ new Error("boom"));
|
|
696
705
|
assert((await env.store.due(at(T1), 10)).some((d) => d.key === "healthy"), "deadlines carry no cross-address ordering; a dead-lettered neighbor must not block delivery");
|
|
697
706
|
})
|
|
@@ -794,9 +803,10 @@ function createEsRepositoryContractTests(harness) {
|
|
|
794
803
|
const readAll = { limit: 100 };
|
|
795
804
|
const createAggregateWithId = harness.createAggregateWithId;
|
|
796
805
|
const snapshotState = harness.snapshotState;
|
|
806
|
+
const captureSnapshot = harness.captureSnapshot;
|
|
797
807
|
const load = (repository, id) => loadAggregateOrFail(repository, id, "the stream was not appended or replayed correctly");
|
|
798
808
|
const streamFor = (id) => harness.streamKeyFor(id);
|
|
799
|
-
const recordedIds = (events) =>
|
|
809
|
+
const recordedIds = (events) => recordedPendingEventIds(events, "pending events must be recorded before flush");
|
|
800
810
|
const ids = (events) => events.map((event) => {
|
|
801
811
|
assert(typeof event.eventId === "string" && event.eventId.length > 0, "committed events must carry their persisted eventId");
|
|
802
812
|
return event.eventId;
|
|
@@ -891,7 +901,8 @@ function createEsRepositoryContractTests(harness) {
|
|
|
891
901
|
repository.add(aggregate);
|
|
892
902
|
throw new Error("rollback probe");
|
|
893
903
|
}));
|
|
894
|
-
|
|
904
|
+
const stream = await environment.committedStreamEvents(streamFor(aggregate.id), readAll);
|
|
905
|
+
assert(!stream.exists, "rollback must leave the stream absent");
|
|
895
906
|
assertEqual((await environment.committedOutboxEvents()).length, 0, "rollback must leave the outbox empty");
|
|
896
907
|
assert(deepEqual(aggregate.pendingEvents, pending), "rollback must retain the exact pending batch");
|
|
897
908
|
})
|
|
@@ -920,10 +931,12 @@ function createEsRepositoryContractTests(harness) {
|
|
|
920
931
|
const aggregate = harness.createAggregate();
|
|
921
932
|
const pending = [...aggregate.pendingEvents];
|
|
922
933
|
environment.failNextOutboxWrite(/* @__PURE__ */ new Error("outbox failure probe"));
|
|
923
|
-
|
|
934
|
+
const rejection = await captureRejection(environment.run(async ({ repository }) => {
|
|
924
935
|
repository.add(aggregate);
|
|
925
|
-
}))
|
|
926
|
-
assert(
|
|
936
|
+
}));
|
|
937
|
+
assert(rejection !== void 0, "the outbox failure must reject");
|
|
938
|
+
const stream = await environment.committedStreamEvents(streamFor(aggregate.id), readAll);
|
|
939
|
+
assert(!stream.exists, "stream append must roll back with the outbox");
|
|
927
940
|
assertEqual((await environment.committedOutboxEvents()).length, 0, "failed outbox write must commit no envelope");
|
|
928
941
|
assert(deepEqual(aggregate.pendingEvents, pending), "failed commit must acknowledge none of the event batch");
|
|
929
942
|
})
|
|
@@ -982,9 +995,433 @@ function createEsRepositoryContractTests(harness) {
|
|
|
982
995
|
assert(deepEqual(ids(after.events), ids(before.events)), "duplicate add must not modify the existing stream");
|
|
983
996
|
})
|
|
984
997
|
}));
|
|
998
|
+
tests.push(gatedContractTest({
|
|
999
|
+
capability: "captureSnapshot",
|
|
1000
|
+
satisfiedBy: Boolean(captureSnapshot)
|
|
1001
|
+
}, {
|
|
1002
|
+
name: "snapshot catch-up ends at the stream head and folds only the tail",
|
|
1003
|
+
run: inEnvironment(async (environment) => {
|
|
1004
|
+
assert(captureSnapshot !== void 0, "capability gate");
|
|
1005
|
+
const seeded = await seed(environment);
|
|
1006
|
+
await environment.run(async ({ repository }) => {
|
|
1007
|
+
const loaded = await load(repository, seeded.id);
|
|
1008
|
+
harness.mutate(loaded);
|
|
1009
|
+
repository.update(loaded);
|
|
1010
|
+
});
|
|
1011
|
+
const snapshotted = await environment.run(({ repository }) => load(repository, seeded.id));
|
|
1012
|
+
await captureSnapshot.call(harness, snapshotted, environment);
|
|
1013
|
+
const atHead = await environment.run(({ repository }) => load(repository, seeded.id));
|
|
1014
|
+
assert(atHead.version === snapshotted.version, `a snapshot at the head must load at the head ${snapshotted.version}, not beyond it; got ${atHead.version}`);
|
|
1015
|
+
const winner = await environment.run(async ({ repository }) => {
|
|
1016
|
+
const loaded = await load(repository, seeded.id);
|
|
1017
|
+
harness.mutate(loaded);
|
|
1018
|
+
harness.mutate(loaded);
|
|
1019
|
+
repository.update(loaded);
|
|
1020
|
+
return loaded;
|
|
1021
|
+
});
|
|
1022
|
+
const caughtUp = await environment.run(({ repository }) => load(repository, seeded.id));
|
|
1023
|
+
assert(caughtUp.version === winner.version, `snapshot catch-up must end at the stream head ${winner.version}; got ${caughtUp.version}`);
|
|
1024
|
+
if (snapshotState) assert(deepEqual(snapshotState.call(harness, caughtUp), snapshotState.call(harness, winner)), "snapshot catch-up must fold only the tail after the snapshot");
|
|
1025
|
+
})
|
|
1026
|
+
}));
|
|
985
1027
|
return tests;
|
|
986
1028
|
}
|
|
987
1029
|
|
|
1030
|
+
//#endregion
|
|
1031
|
+
//#region src/testing/event-bus-contract.ts
|
|
1032
|
+
/** Resolves after enough turns for a parallel batch to have started. */
|
|
1033
|
+
function turn() {
|
|
1034
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* The event-bus contract test suite: the proof that an implementation
|
|
1038
|
+
* delivers the guarantees the `EventBus` port documents. Ordering,
|
|
1039
|
+
* parallelism within one event, and error collection after the batch are
|
|
1040
|
+
* a **port contract, not a kit guarantee**; this suite is how an
|
|
1041
|
+
* implementation demonstrates them.
|
|
1042
|
+
*
|
|
1043
|
+
* The suite covers the port and nothing else. Construction options of the
|
|
1044
|
+
* kit's own adapter, such as a publish-depth bound or an observer bundle,
|
|
1045
|
+
* are not port behavior and are not pinned here.
|
|
1046
|
+
*
|
|
1047
|
+
* Framework-agnostic: bind with
|
|
1048
|
+
* `(test.skipped ? it.skip : it)(test.name, test.run)`.
|
|
1049
|
+
*/
|
|
1050
|
+
function createEventBusContractTests(harness) {
|
|
1051
|
+
const inEnv = bindContractEnvironment(() => harness.createEnvironment());
|
|
1052
|
+
const first = () => harness.createFirstEvent();
|
|
1053
|
+
const second = () => harness.createSecondEvent();
|
|
1054
|
+
const types = () => {
|
|
1055
|
+
const firstType = first().type;
|
|
1056
|
+
const secondType = second().type;
|
|
1057
|
+
assert(firstType !== secondType, "the harness must supply two event factories with different types");
|
|
1058
|
+
return {
|
|
1059
|
+
firstType,
|
|
1060
|
+
secondType
|
|
1061
|
+
};
|
|
1062
|
+
};
|
|
1063
|
+
return [
|
|
1064
|
+
{
|
|
1065
|
+
name: "dispatches the events of one batch in input order",
|
|
1066
|
+
run: inEnv(async ({ bus }) => {
|
|
1067
|
+
const { firstType, secondType } = types();
|
|
1068
|
+
const seen = [];
|
|
1069
|
+
bus.subscribe(firstType, async (event) => {
|
|
1070
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1071
|
+
seen.push(event.type);
|
|
1072
|
+
});
|
|
1073
|
+
bus.subscribe(secondType, async (event) => {
|
|
1074
|
+
seen.push(event.type);
|
|
1075
|
+
});
|
|
1076
|
+
await bus.publish([
|
|
1077
|
+
first(),
|
|
1078
|
+
second(),
|
|
1079
|
+
first()
|
|
1080
|
+
]);
|
|
1081
|
+
assertEqual(seen.join(","), [
|
|
1082
|
+
firstType,
|
|
1083
|
+
secondType,
|
|
1084
|
+
firstType
|
|
1085
|
+
].join(","), "a batch dispatches its events in input order");
|
|
1086
|
+
})
|
|
1087
|
+
},
|
|
1088
|
+
{
|
|
1089
|
+
name: "starts the handlers of an event only after the previous event finished",
|
|
1090
|
+
run: inEnv(async ({ bus }) => {
|
|
1091
|
+
const { firstType, secondType } = types();
|
|
1092
|
+
const trace = [];
|
|
1093
|
+
bus.subscribe(firstType, async () => {
|
|
1094
|
+
trace.push("first:start");
|
|
1095
|
+
await turn();
|
|
1096
|
+
trace.push("first:end");
|
|
1097
|
+
});
|
|
1098
|
+
bus.subscribe(secondType, async () => {
|
|
1099
|
+
trace.push("second:start");
|
|
1100
|
+
});
|
|
1101
|
+
await bus.publish([first(), second()]);
|
|
1102
|
+
assertEqual(trace.join(","), "first:start,first:end,second:start", "an event dispatches only after the previous one finished");
|
|
1103
|
+
})
|
|
1104
|
+
},
|
|
1105
|
+
{
|
|
1106
|
+
name: "runs the handlers of one event in parallel",
|
|
1107
|
+
run: inEnv(async ({ bus }) => {
|
|
1108
|
+
const { firstType } = types();
|
|
1109
|
+
const trace = [];
|
|
1110
|
+
bus.subscribe(firstType, async () => {
|
|
1111
|
+
trace.push("a:start");
|
|
1112
|
+
await turn();
|
|
1113
|
+
trace.push("a:end");
|
|
1114
|
+
});
|
|
1115
|
+
bus.subscribe(firstType, async () => {
|
|
1116
|
+
trace.push("b:start");
|
|
1117
|
+
await turn();
|
|
1118
|
+
trace.push("b:end");
|
|
1119
|
+
});
|
|
1120
|
+
await bus.publish([first()]);
|
|
1121
|
+
assertEqual(trace.join(","), "a:start,b:start,a:end,b:end", "the handlers of one event run in parallel");
|
|
1122
|
+
})
|
|
1123
|
+
},
|
|
1124
|
+
{
|
|
1125
|
+
name: "runs every handler of an event when a peer fails",
|
|
1126
|
+
run: inEnv(async ({ bus }) => {
|
|
1127
|
+
const { firstType } = types();
|
|
1128
|
+
const seen = [];
|
|
1129
|
+
bus.subscribe(firstType, async () => {
|
|
1130
|
+
throw new Error("first handler failed");
|
|
1131
|
+
});
|
|
1132
|
+
bus.subscribe(firstType, async () => {
|
|
1133
|
+
seen.push("peer");
|
|
1134
|
+
});
|
|
1135
|
+
await captureRejection(bus.publish([first()]));
|
|
1136
|
+
assertEqual(seen.join(","), "peer", "a peer of a failing handler still runs");
|
|
1137
|
+
})
|
|
1138
|
+
},
|
|
1139
|
+
{
|
|
1140
|
+
name: "reaches the caller with a single failure directly",
|
|
1141
|
+
run: inEnv(async ({ bus }) => {
|
|
1142
|
+
const { firstType } = types();
|
|
1143
|
+
const failure = /* @__PURE__ */ new Error("the only handler failed");
|
|
1144
|
+
bus.subscribe(firstType, async () => {
|
|
1145
|
+
throw failure;
|
|
1146
|
+
});
|
|
1147
|
+
const thrown = await captureRejection(bus.publish([first()]));
|
|
1148
|
+
assertEqual(thrown, failure, "a single failure reaches the caller unchanged");
|
|
1149
|
+
})
|
|
1150
|
+
},
|
|
1151
|
+
{
|
|
1152
|
+
name: "collects two or more failures into an AggregateError",
|
|
1153
|
+
run: inEnv(async ({ bus }) => {
|
|
1154
|
+
const { firstType } = types();
|
|
1155
|
+
const one = /* @__PURE__ */ new Error("handler one failed");
|
|
1156
|
+
const two = /* @__PURE__ */ new Error("handler two failed");
|
|
1157
|
+
bus.subscribe(firstType, async () => {
|
|
1158
|
+
throw one;
|
|
1159
|
+
});
|
|
1160
|
+
bus.subscribe(firstType, async () => {
|
|
1161
|
+
throw two;
|
|
1162
|
+
});
|
|
1163
|
+
const thrown = await captureRejection(bus.publish([first()]));
|
|
1164
|
+
assert(thrown instanceof AggregateError, "two failures must reach the caller as an AggregateError");
|
|
1165
|
+
assertEqual(thrown.errors.length, 2, "the AggregateError carries every failure");
|
|
1166
|
+
assert(thrown.errors.includes(one) && thrown.errors.includes(two), "the AggregateError carries both failures");
|
|
1167
|
+
})
|
|
1168
|
+
},
|
|
1169
|
+
{
|
|
1170
|
+
name: "publishes the remaining events of a batch after a failure",
|
|
1171
|
+
run: inEnv(async ({ bus }) => {
|
|
1172
|
+
const { firstType, secondType } = types();
|
|
1173
|
+
const seen = [];
|
|
1174
|
+
bus.subscribe(firstType, async () => {
|
|
1175
|
+
throw new Error("first handler failed");
|
|
1176
|
+
});
|
|
1177
|
+
bus.subscribe(secondType, async (event) => {
|
|
1178
|
+
seen.push(event.type);
|
|
1179
|
+
});
|
|
1180
|
+
await captureRejection(bus.publish([first(), second()]));
|
|
1181
|
+
assertEqual(seen.join(","), secondType, "a failure does not stop the remaining events of the batch");
|
|
1182
|
+
})
|
|
1183
|
+
},
|
|
1184
|
+
{
|
|
1185
|
+
name: "treats a handler that throws synchronously as a rejection",
|
|
1186
|
+
run: inEnv(async ({ bus }) => {
|
|
1187
|
+
const { firstType } = types();
|
|
1188
|
+
const seen = [];
|
|
1189
|
+
bus.subscribe(firstType, () => {
|
|
1190
|
+
throw new Error("thrown synchronously");
|
|
1191
|
+
});
|
|
1192
|
+
bus.subscribe(firstType, async () => {
|
|
1193
|
+
seen.push("peer");
|
|
1194
|
+
});
|
|
1195
|
+
const thrown = await captureRejection(bus.publish([first()]));
|
|
1196
|
+
assert(thrown instanceof Error, "the throw must reach the caller");
|
|
1197
|
+
assertEqual(seen.join(","), "peer", "a synchronous throw does not skip the peers");
|
|
1198
|
+
})
|
|
1199
|
+
},
|
|
1200
|
+
{
|
|
1201
|
+
name: "hands every subscriber the same event object",
|
|
1202
|
+
run: inEnv(async ({ bus }) => {
|
|
1203
|
+
const { firstType } = types();
|
|
1204
|
+
const received = [];
|
|
1205
|
+
bus.subscribe(firstType, (event) => {
|
|
1206
|
+
received.push(event);
|
|
1207
|
+
});
|
|
1208
|
+
bus.subscribeAll((event) => {
|
|
1209
|
+
received.push(event);
|
|
1210
|
+
});
|
|
1211
|
+
const published = first();
|
|
1212
|
+
await bus.publish([published]);
|
|
1213
|
+
assertEqual(received.length, 2, "both subscriptions must receive the event");
|
|
1214
|
+
for (const one of received) assert(one === published, "every subscriber receives the published event itself, so its metadata cannot differ between them");
|
|
1215
|
+
})
|
|
1216
|
+
},
|
|
1217
|
+
{
|
|
1218
|
+
name: "delivers every type of a subscribed set to one handler",
|
|
1219
|
+
run: inEnv(async ({ bus }) => {
|
|
1220
|
+
const { firstType, secondType } = types();
|
|
1221
|
+
const seen = [];
|
|
1222
|
+
bus.subscribeMany([firstType, secondType], (event) => {
|
|
1223
|
+
seen.push(event.type);
|
|
1224
|
+
});
|
|
1225
|
+
await bus.publish([first(), second()]);
|
|
1226
|
+
assertEqual(seen.join(","), [firstType, secondType].join(","), "a set subscription receives every type in the set");
|
|
1227
|
+
})
|
|
1228
|
+
},
|
|
1229
|
+
{
|
|
1230
|
+
name: "releases every subscription of a set with one call",
|
|
1231
|
+
run: inEnv(async ({ bus }) => {
|
|
1232
|
+
const { firstType, secondType } = types();
|
|
1233
|
+
const seen = [];
|
|
1234
|
+
bus.subscribeMany([firstType, secondType], (event) => {
|
|
1235
|
+
seen.push(event.type);
|
|
1236
|
+
})();
|
|
1237
|
+
await bus.publish([first(), second()]);
|
|
1238
|
+
assertEqual(seen.length, 0, "one release must remove every subscription the set made");
|
|
1239
|
+
})
|
|
1240
|
+
},
|
|
1241
|
+
{
|
|
1242
|
+
name: "subscribes a repeated type of a set once",
|
|
1243
|
+
run: inEnv(async ({ bus }) => {
|
|
1244
|
+
const { firstType } = types();
|
|
1245
|
+
let calls = 0;
|
|
1246
|
+
bus.subscribeMany([firstType, firstType], () => {
|
|
1247
|
+
calls++;
|
|
1248
|
+
});
|
|
1249
|
+
await bus.publish([first()]);
|
|
1250
|
+
assertEqual(calls, 1, "the argument is a set, so a repeated type subscribes once");
|
|
1251
|
+
})
|
|
1252
|
+
},
|
|
1253
|
+
{
|
|
1254
|
+
name: "delivers every event type to a catch-all subscription",
|
|
1255
|
+
run: inEnv(async ({ bus }) => {
|
|
1256
|
+
const { firstType, secondType } = types();
|
|
1257
|
+
const seen = [];
|
|
1258
|
+
bus.subscribeAll(async (event) => {
|
|
1259
|
+
seen.push(event.type);
|
|
1260
|
+
});
|
|
1261
|
+
await bus.publish([first(), second()]);
|
|
1262
|
+
assertEqual(seen.join(","), [firstType, secondType].join(","), "a catch-all subscription receives every event type");
|
|
1263
|
+
})
|
|
1264
|
+
},
|
|
1265
|
+
{
|
|
1266
|
+
name: "runs a catch-all handler in the same batch as the typed handlers",
|
|
1267
|
+
run: inEnv(async ({ bus }) => {
|
|
1268
|
+
const { firstType } = types();
|
|
1269
|
+
const seen = [];
|
|
1270
|
+
bus.subscribe(firstType, async () => {
|
|
1271
|
+
throw new Error("typed handler failed");
|
|
1272
|
+
});
|
|
1273
|
+
bus.subscribeAll(async () => {
|
|
1274
|
+
seen.push("catch-all");
|
|
1275
|
+
});
|
|
1276
|
+
await captureRejection(bus.publish([first()]));
|
|
1277
|
+
assertEqual(seen.join(","), "catch-all", "a catch-all handler runs in the same batch as the typed ones");
|
|
1278
|
+
})
|
|
1279
|
+
},
|
|
1280
|
+
{
|
|
1281
|
+
name: "removes exactly one subscription when the same handler subscribed twice",
|
|
1282
|
+
run: inEnv(async ({ bus }) => {
|
|
1283
|
+
const { firstType } = types();
|
|
1284
|
+
let calls = 0;
|
|
1285
|
+
const handler = async () => {
|
|
1286
|
+
calls++;
|
|
1287
|
+
};
|
|
1288
|
+
const release = bus.subscribe(firstType, handler);
|
|
1289
|
+
bus.subscribe(firstType, handler);
|
|
1290
|
+
release();
|
|
1291
|
+
await bus.publish([first()]);
|
|
1292
|
+
assertEqual(calls, 1, "unsubscribe removes exactly one of two identical subscriptions");
|
|
1293
|
+
})
|
|
1294
|
+
},
|
|
1295
|
+
{
|
|
1296
|
+
name: "ignores a second call of the unsubscribe function",
|
|
1297
|
+
run: inEnv(async ({ bus }) => {
|
|
1298
|
+
const { firstType } = types();
|
|
1299
|
+
let calls = 0;
|
|
1300
|
+
const release = bus.subscribe(firstType, async () => {
|
|
1301
|
+
calls++;
|
|
1302
|
+
});
|
|
1303
|
+
bus.subscribe(firstType, async () => {
|
|
1304
|
+
calls++;
|
|
1305
|
+
});
|
|
1306
|
+
release();
|
|
1307
|
+
release();
|
|
1308
|
+
await bus.publish([first()]);
|
|
1309
|
+
assertEqual(calls, 1, "a second unsubscribe removes no further subscription");
|
|
1310
|
+
})
|
|
1311
|
+
},
|
|
1312
|
+
{
|
|
1313
|
+
name: "resolves once() with the next event of its type and stops after it",
|
|
1314
|
+
run: inEnv(async ({ bus }) => {
|
|
1315
|
+
const { firstType } = types();
|
|
1316
|
+
let deliveries = 0;
|
|
1317
|
+
bus.subscribeAll(async () => {
|
|
1318
|
+
deliveries++;
|
|
1319
|
+
});
|
|
1320
|
+
const waiting = bus.once(firstType);
|
|
1321
|
+
const announced = first();
|
|
1322
|
+
await bus.publish([announced]);
|
|
1323
|
+
const received = await waiting;
|
|
1324
|
+
await bus.publish([first()]);
|
|
1325
|
+
assertEqual(received.eventId, announced.eventId, "once() resolves with the first event of that type");
|
|
1326
|
+
assertEqual(deliveries, 2, "the second publication must still reach the bus");
|
|
1327
|
+
})
|
|
1328
|
+
},
|
|
1329
|
+
{
|
|
1330
|
+
name: "rejects once() when its timeout expires",
|
|
1331
|
+
run: inEnv(async ({ bus }) => {
|
|
1332
|
+
const { firstType } = types();
|
|
1333
|
+
const thrown = await captureRejection(bus.once(firstType, { timeoutMs: 5 }));
|
|
1334
|
+
assert(thrown !== void 0, "once() must reject after its timeout");
|
|
1335
|
+
})
|
|
1336
|
+
},
|
|
1337
|
+
{
|
|
1338
|
+
name: "rejects once() with the reason of an aborted signal",
|
|
1339
|
+
run: inEnv(async ({ bus }) => {
|
|
1340
|
+
const { firstType } = types();
|
|
1341
|
+
const controller = new AbortController();
|
|
1342
|
+
const reason = /* @__PURE__ */ new Error("the caller stopped waiting");
|
|
1343
|
+
const waiting = captureRejection(bus.once(firstType, { signal: controller.signal }));
|
|
1344
|
+
controller.abort(reason);
|
|
1345
|
+
assertEqual(await waiting, reason, "once() rejects with the reason of the signal");
|
|
1346
|
+
})
|
|
1347
|
+
},
|
|
1348
|
+
{
|
|
1349
|
+
name: "rejects a publication whose signal is already aborted",
|
|
1350
|
+
run: inEnv(async ({ bus }) => {
|
|
1351
|
+
const { firstType } = types();
|
|
1352
|
+
const controller = new AbortController();
|
|
1353
|
+
controller.abort(/* @__PURE__ */ new Error("stopped before publish"));
|
|
1354
|
+
let called = false;
|
|
1355
|
+
bus.subscribe(firstType, async () => {
|
|
1356
|
+
called = true;
|
|
1357
|
+
});
|
|
1358
|
+
const thrown = await captureRejection(bus.publish([first()], { signal: controller.signal }));
|
|
1359
|
+
assert(thrown !== void 0, "an aborted publication must reject");
|
|
1360
|
+
assertEqual(called, false, "an aborted publication dispatches no handler");
|
|
1361
|
+
})
|
|
1362
|
+
},
|
|
1363
|
+
{
|
|
1364
|
+
name: "refuses every operation after close",
|
|
1365
|
+
run: inEnv(async ({ bus }) => {
|
|
1366
|
+
const { firstType } = types();
|
|
1367
|
+
bus.close();
|
|
1368
|
+
let subscribeThrew = false;
|
|
1369
|
+
try {
|
|
1370
|
+
bus.subscribe(firstType, () => {});
|
|
1371
|
+
} catch {
|
|
1372
|
+
subscribeThrew = true;
|
|
1373
|
+
}
|
|
1374
|
+
let subscribeAllThrew = false;
|
|
1375
|
+
try {
|
|
1376
|
+
bus.subscribeAll(() => {});
|
|
1377
|
+
} catch {
|
|
1378
|
+
subscribeAllThrew = true;
|
|
1379
|
+
}
|
|
1380
|
+
assert(subscribeThrew, "subscribe must refuse a closed bus");
|
|
1381
|
+
assert(subscribeAllThrew, "subscribeAll must refuse a closed bus");
|
|
1382
|
+
assert(await captureRejection(bus.publish([first()])) !== void 0, "publish must refuse a closed bus");
|
|
1383
|
+
assert(await captureRejection(bus.once(firstType)) !== void 0, "once must refuse a closed bus");
|
|
1384
|
+
})
|
|
1385
|
+
},
|
|
1386
|
+
{
|
|
1387
|
+
name: "settles a pending once() when the bus closes",
|
|
1388
|
+
run: inEnv(async ({ bus }) => {
|
|
1389
|
+
const { firstType } = types();
|
|
1390
|
+
const waiting = captureRejection(bus.once(firstType));
|
|
1391
|
+
bus.close();
|
|
1392
|
+
assert(await waiting !== void 0, "closing must settle a pending once()");
|
|
1393
|
+
})
|
|
1394
|
+
},
|
|
1395
|
+
{
|
|
1396
|
+
name: "does nothing when close is called again",
|
|
1397
|
+
run: inEnv(async ({ bus }) => {
|
|
1398
|
+
bus.close();
|
|
1399
|
+
let threw = false;
|
|
1400
|
+
try {
|
|
1401
|
+
bus.close();
|
|
1402
|
+
} catch {
|
|
1403
|
+
threw = true;
|
|
1404
|
+
}
|
|
1405
|
+
assert(threw === false, "a second close must do nothing");
|
|
1406
|
+
})
|
|
1407
|
+
},
|
|
1408
|
+
{
|
|
1409
|
+
name: "bounds the wait, not the handler, when the timeout expires",
|
|
1410
|
+
run: inEnv(async ({ bus }) => {
|
|
1411
|
+
const { firstType } = types();
|
|
1412
|
+
let running = true;
|
|
1413
|
+
bus.subscribe(firstType, async () => {
|
|
1414
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
1415
|
+
running = false;
|
|
1416
|
+
});
|
|
1417
|
+
const thrown = await captureRejection(bus.publish([first()], { timeoutMs: 10 }));
|
|
1418
|
+
assert(thrown !== void 0, "the publication must reject");
|
|
1419
|
+
assertEqual(running, true, "the timeout bounds the wait, so the handler keeps running");
|
|
1420
|
+
})
|
|
1421
|
+
}
|
|
1422
|
+
];
|
|
1423
|
+
}
|
|
1424
|
+
|
|
988
1425
|
//#endregion
|
|
989
1426
|
//#region src/testing/event-store-contract.ts
|
|
990
1427
|
/**
|
|
@@ -1126,7 +1563,10 @@ function createEventStoreContractTests(harness) {
|
|
|
1126
1563
|
toVersion: Number.MAX_SAFE_INTEGER + 1
|
|
1127
1564
|
}
|
|
1128
1565
|
];
|
|
1129
|
-
for (const options of invalidOptions)
|
|
1566
|
+
for (const options of invalidOptions) {
|
|
1567
|
+
const rejection = await captureRejection(store.readStream(firstKey, options));
|
|
1568
|
+
assert(rejection instanceof RangeError, "invalid read bounds must reject with RangeError before querying the stream");
|
|
1569
|
+
}
|
|
1130
1570
|
})
|
|
1131
1571
|
},
|
|
1132
1572
|
{
|
|
@@ -1227,7 +1667,8 @@ function createEventStoreContractTests(harness) {
|
|
|
1227
1667
|
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1228
1668
|
const seeded = [harness.createEvent(firstKey, 30), harness.createEvent(firstKey, 31)];
|
|
1229
1669
|
await store.append(firstKey, seeded, { expectedVersion: 0 });
|
|
1230
|
-
|
|
1670
|
+
const rejection = await captureRejection(store.append({ ...firstKey }, [harness.createEvent(firstKey, 32), harness.createEvent(firstKey, 33)], { expectedVersion: 1 }));
|
|
1671
|
+
assertChainContainsKitError(rejection, ["CONCURRENCY_CONFLICT"], "a stale append must map the adapter conflict to ConcurrencyConflictError");
|
|
1231
1672
|
const stored = await store.readStream(firstKey, fixtureRead);
|
|
1232
1673
|
assert(stored.exists && stored.lastVersion === 2 && stored.events.length === 2 && stored.events[0]?.eventId === seeded[0]?.eventId && stored.events[1]?.eventId === seeded[1]?.eventId, "a rejected multi-event append must leave the stream untouched");
|
|
1233
1674
|
})
|
|
@@ -1238,7 +1679,8 @@ function createEventStoreContractTests(harness) {
|
|
|
1238
1679
|
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1239
1680
|
const seeded = harness.createEvent(firstKey, 50);
|
|
1240
1681
|
await store.append(firstKey, [seeded], { expectedVersion: 0 });
|
|
1241
|
-
|
|
1682
|
+
const rejection = await captureRejection(store.append({ ...firstKey }, [harness.createEvent(firstKey, 51), harness.createEvent(firstKey, 52)], { expectedVersion: 0 }));
|
|
1683
|
+
assertChainContainsKitError(rejection, ["CONCURRENCY_CONFLICT", "DUPLICATE_AGGREGATE"], "a duplicate create must map to ConcurrencyConflictError or the sanctioned DuplicateAggregateError");
|
|
1242
1684
|
const stored = await store.readStream(firstKey, fixtureRead);
|
|
1243
1685
|
assert(stored.exists && stored.lastVersion === 1 && stored.events.length === 1 && stored.events[0]?.eventId === seeded.eventId, "a rejected duplicate-create batch must leave the existing stream untouched");
|
|
1244
1686
|
})
|
|
@@ -1247,7 +1689,8 @@ function createEventStoreContractTests(harness) {
|
|
|
1247
1689
|
name: "OCC: an expectedVersion ahead of an unknown stream conflicts without creating it",
|
|
1248
1690
|
run: inEnv(async ({ store }) => {
|
|
1249
1691
|
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1250
|
-
|
|
1692
|
+
const rejection = await captureRejection(store.append(firstKey, [harness.createEvent(firstKey, 60)], { expectedVersion: 3 }));
|
|
1693
|
+
assertChainContainsKitError(rejection, ["CONCURRENCY_CONFLICT"], "an expectedVersion ahead of the stream must map to ConcurrencyConflictError");
|
|
1251
1694
|
const first = harness.createEvent(firstKey, 61);
|
|
1252
1695
|
await store.append({ ...firstKey }, [first], { expectedVersion: 0 });
|
|
1253
1696
|
const stored = await store.readStream(firstKey, fixtureRead);
|
|
@@ -1318,7 +1761,8 @@ function createIdempotencyStoreContractTests(harness) {
|
|
|
1318
1761
|
return claim;
|
|
1319
1762
|
});
|
|
1320
1763
|
await env.store.confirm(first);
|
|
1321
|
-
|
|
1764
|
+
const rejection = await captureRejection(env.run((ctx) => env.store.claim(ctx, "key-1", "fp-OTHER")));
|
|
1765
|
+
assertChainContainsKitError(rejection, ["IDEMPOTENCY_KEY_REUSE"], "a different fingerprint must throw IdempotencyKeyReuseError, never replay another command's outcome");
|
|
1322
1766
|
})
|
|
1323
1767
|
},
|
|
1324
1768
|
{
|
|
@@ -1357,10 +1801,11 @@ function createIdempotencyStoreContractTests(harness) {
|
|
|
1357
1801
|
{
|
|
1358
1802
|
name: "complete without a pending claim throws the wiring error",
|
|
1359
1803
|
run: inEnv(async (env) => {
|
|
1360
|
-
|
|
1804
|
+
const rejection = await captureRejection(env.run((ctx) => env.store.complete(ctx, {
|
|
1361
1805
|
key: "key-1",
|
|
1362
1806
|
token: "missing"
|
|
1363
|
-
}, "x")))
|
|
1807
|
+
}, "x")));
|
|
1808
|
+
assertChainContainsKitError(rejection, ["IDEMPOTENCY_COMPLETED_WITHOUT_CLAIM"], "complete() without claim() must throw IdempotencyCompletionWithoutClaimError");
|
|
1364
1809
|
})
|
|
1365
1810
|
}
|
|
1366
1811
|
];
|
|
@@ -1401,7 +1846,8 @@ function createIdempotencyStoreContractTests(harness) {
|
|
|
1401
1846
|
const renewed = await env.store.renew(claim);
|
|
1402
1847
|
assert(renewed !== void 0 && new Date(renewed.expiresAt).getTime() > originalExpiry, "renew must extend the lease beyond its previous expiry");
|
|
1403
1848
|
await advanceTimeTo(env, new Date(originalExpiry + 1));
|
|
1404
|
-
|
|
1849
|
+
const rejection = await captureRejection(env.run((ctx) => env.store.claim(ctx, "key-renew", "fp")));
|
|
1850
|
+
assertChainContainsKitError(rejection, ["IDEMPOTENCY_IN_FLIGHT"], "the renewed owner must still hold the key after the original expiry");
|
|
1405
1851
|
})
|
|
1406
1852
|
}), gatedContractTest({
|
|
1407
1853
|
capability: "family: non-transactional",
|
|
@@ -1412,8 +1858,10 @@ function createIdempotencyStoreContractTests(harness) {
|
|
|
1412
1858
|
const first = claimedHandle(await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1")), "a fresh key must be claimed");
|
|
1413
1859
|
assert(first.lease !== void 0, "a non-transactional claim must carry lease timing");
|
|
1414
1860
|
await expireLease(env, first);
|
|
1415
|
-
|
|
1416
|
-
|
|
1861
|
+
const successor = claimedHandle(await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1")), "an expired pending claim must be reclaimed");
|
|
1862
|
+
assert(successor.token !== first.token, "each ownership generation must have a different token");
|
|
1863
|
+
const rejection = await captureRejection(env.run((ctx) => env.store.complete(ctx, first, "stale")));
|
|
1864
|
+
assertChainContainsKitError(rejection, ["IDEMPOTENCY_CLAIM_LOST"], "a stale owner must fail before it can complete after takeover");
|
|
1417
1865
|
})
|
|
1418
1866
|
}), gatedContractTest({
|
|
1419
1867
|
capability: "family: non-transactional",
|
|
@@ -1455,7 +1903,8 @@ function createIdempotencyStoreContractTests(harness) {
|
|
|
1455
1903
|
const rolledBack = await env.run((ctx) => env.store.claim(ctx, "rolled-back", "fp"));
|
|
1456
1904
|
assert(rolledBack.status === "reconciliation-required", "the staged outcome must expose its reconciliation receipt");
|
|
1457
1905
|
await env.store.reconcile(rolledBack.reconciliation, "not-committed");
|
|
1458
|
-
|
|
1906
|
+
const fresh = await env.run((ctx) => env.store.claim(ctx, "rolled-back", "fp"));
|
|
1907
|
+
assertEqual(fresh.status, "claimed", "not-committed evidence must release the staged outcome");
|
|
1459
1908
|
})
|
|
1460
1909
|
}), gatedContractTest({
|
|
1461
1910
|
capability: "family: non-transactional",
|
|
@@ -1467,7 +1916,8 @@ function createIdempotencyStoreContractTests(harness) {
|
|
|
1467
1916
|
const claim = claimedHandle(await env.store.claim(ctx, "key-1", "fp-1"), "a fresh key must be claimed");
|
|
1468
1917
|
await env.store.complete(ctx, claim, "uncommitted");
|
|
1469
1918
|
});
|
|
1470
|
-
|
|
1919
|
+
const rejection = await captureRejection(env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1")));
|
|
1920
|
+
assertChainContainsKitError(rejection, ["IDEMPOTENCY_IN_FLIGHT"], "a staged outcome must never replay; it is in-flight until confirmed");
|
|
1471
1921
|
})
|
|
1472
1922
|
}), gatedContractTest({
|
|
1473
1923
|
capability: "family: non-transactional",
|
|
@@ -1477,7 +1927,8 @@ function createIdempotencyStoreContractTests(harness) {
|
|
|
1477
1927
|
run: inEnv(async (env) => {
|
|
1478
1928
|
const first = claimedHandle(await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1")), "a fresh key must be claimed");
|
|
1479
1929
|
await env.store.abandon(first);
|
|
1480
|
-
|
|
1930
|
+
const claim = await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1"));
|
|
1931
|
+
assertEqual(claim.status, "claimed", "an abandoned pending claim must be claimable again");
|
|
1481
1932
|
})
|
|
1482
1933
|
}), gatedContractTest({
|
|
1483
1934
|
capability: "family: non-transactional",
|
|
@@ -1491,7 +1942,8 @@ function createIdempotencyStoreContractTests(harness) {
|
|
|
1491
1942
|
return claim;
|
|
1492
1943
|
});
|
|
1493
1944
|
await env.store.abandon(first);
|
|
1494
|
-
|
|
1945
|
+
const claim = await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1"));
|
|
1946
|
+
assertEqual(claim.status, "claimed", "an abandoned staged outcome must be claimable again");
|
|
1495
1947
|
})
|
|
1496
1948
|
}), gatedContractTest({
|
|
1497
1949
|
capability: "family: transactional",
|
|
@@ -1504,7 +1956,8 @@ function createIdempotencyStoreContractTests(harness) {
|
|
|
1504
1956
|
const claim = claimedHandle(await env.store.claim(ctx, "key-1", "fp-1"), "a fresh key must be claimed");
|
|
1505
1957
|
await env.store.complete(ctx, claim, "rolled back");
|
|
1506
1958
|
}).catch(() => {});
|
|
1507
|
-
|
|
1959
|
+
const claim = await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1"));
|
|
1960
|
+
assertEqual(claim.status, "claimed", "a rolled-back claim/complete must leave the key claimable");
|
|
1508
1961
|
})
|
|
1509
1962
|
}));
|
|
1510
1963
|
return tests;
|
|
@@ -1555,7 +2008,8 @@ function createOutboxContractTests(harness) {
|
|
|
1555
2008
|
run: inEnv(async (env) => {
|
|
1556
2009
|
await env.addCommitted(commit([harness.createEvent(1), harness.createEvent(2)], 1));
|
|
1557
2010
|
await env.addCommitted(commit([harness.createEvent(3)], 3));
|
|
1558
|
-
|
|
2011
|
+
const records = await takeAndAck(env, 3);
|
|
2012
|
+
assert(deepEqual(records.map(({ position }) => position), [
|
|
1559
2013
|
{
|
|
1560
2014
|
aggregateVersion: 1,
|
|
1561
2015
|
commitSequence: 0,
|
|
@@ -1583,7 +2037,8 @@ function createOutboxContractTests(harness) {
|
|
|
1583
2037
|
const original = commit([harness.createEvent(1)], 1);
|
|
1584
2038
|
const collision = commit([harness.createEvent(2)], 1);
|
|
1585
2039
|
await env.addCommitted(original);
|
|
1586
|
-
|
|
2040
|
+
const rejection = await captureRejection(env.addCommitted(collision));
|
|
2041
|
+
assert(rejection !== void 0, "a different eventId at one qualified source position must reject");
|
|
1587
2042
|
const [record] = await takeAndAck(env, 1);
|
|
1588
2043
|
assertEqual(record?.event.eventId, original[0]?.event.eventId, "the rejected collision must not replace the original record");
|
|
1589
2044
|
await env.addCommitted(commit([harness.createEvent(3)], 2));
|
|
@@ -1609,7 +2064,8 @@ function createOutboxContractTests(harness) {
|
|
|
1609
2064
|
}
|
|
1610
2065
|
];
|
|
1611
2066
|
for (const [index, source] of sources.entries()) await env.addCommitted(commit([harness.createEvent(index + 1)], 1, source));
|
|
1612
|
-
|
|
2067
|
+
const records = await takeAndAck(env, sources.length);
|
|
2068
|
+
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");
|
|
1613
2069
|
})
|
|
1614
2070
|
},
|
|
1615
2071
|
{
|
|
@@ -1668,7 +2124,8 @@ function createOutboxContractTests(harness) {
|
|
|
1668
2124
|
await env.outbox.markDispatched([first.dispatchId]);
|
|
1669
2125
|
await env.outbox.markDispatched([first.dispatchId]);
|
|
1670
2126
|
await env.outbox.markDispatched(["no-such-dispatch-id"]);
|
|
1671
|
-
|
|
2127
|
+
const remaining = await env.outbox.getPending(10);
|
|
2128
|
+
assert(!remaining.some((r) => r.dispatchId === first.dispatchId), "a dispatched record must never come back");
|
|
1672
2129
|
})
|
|
1673
2130
|
},
|
|
1674
2131
|
gatedContractTest({
|
|
@@ -1699,7 +2156,8 @@ function createOutboxContractTests(harness) {
|
|
|
1699
2156
|
} catch (error) {
|
|
1700
2157
|
throw new Error(`Contract violated: add() must swallow a duplicate eventId, not throw. Dedupe means an idempotent add (INSERT ... ON CONFLICT DO NOTHING or equivalent), not a raised unique violation. Got: ${describeError(error)}`);
|
|
1701
2158
|
}
|
|
1702
|
-
|
|
2159
|
+
const pending = await env.outbox.getPending(10);
|
|
2160
|
+
assertEqual(pending.length, 1, "the same eventId must yield one record (unique-key dedupe)");
|
|
1703
2161
|
})
|
|
1704
2162
|
})
|
|
1705
2163
|
];
|
|
@@ -1835,7 +2293,8 @@ function createProjectionCheckpointStoreContractTests(harness) {
|
|
|
1835
2293
|
{
|
|
1836
2294
|
name: "a never-seen (projection, aggregate) pair loads undefined",
|
|
1837
2295
|
run: inEnv(async (env) => {
|
|
1838
|
-
|
|
2296
|
+
const loaded = await env.run((ctx) => env.store.load(ctx, "order-list", order("o-1")));
|
|
2297
|
+
assertEqual(loaded, void 0, "a fresh store must report no watermark");
|
|
1839
2298
|
})
|
|
1840
2299
|
},
|
|
1841
2300
|
gatedContractTest({
|
|
@@ -1859,16 +2318,18 @@ function createProjectionCheckpointStoreContractTests(harness) {
|
|
|
1859
2318
|
};
|
|
1860
2319
|
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");
|
|
1861
2320
|
assertEqual(await advanceOnce(1, 2), 1, "exactly one competing callback may advance an existing checkpoint key from the observed watermark");
|
|
1862
|
-
|
|
2321
|
+
const stored = await env.run((ctx) => env.store.load(ctx, "order-list", address));
|
|
2322
|
+
assert(stored?.position.aggregateVersion === 2, "serialized genesis and existing-row advances must leave the final watermark visible");
|
|
1863
2323
|
})
|
|
1864
2324
|
}),
|
|
1865
2325
|
{
|
|
1866
2326
|
name: "checkpoint locks release after a rejected critical section",
|
|
1867
2327
|
run: inEnv(async (env) => {
|
|
1868
2328
|
const address = order("o-rejected-lock");
|
|
1869
|
-
|
|
2329
|
+
const rejection = await captureRejection(env.run((ctx) => env.store.withCheckpointLocks(ctx, "order-list", [address], async () => {
|
|
1870
2330
|
throw new Error("projection failed");
|
|
1871
|
-
})))
|
|
2331
|
+
})));
|
|
2332
|
+
assert(rejection !== void 0, "the store must propagate a rejected critical section");
|
|
1872
2333
|
let retried = false;
|
|
1873
2334
|
await env.run((ctx) => env.store.withCheckpointLocks(ctx, "order-list", [address], async () => {
|
|
1874
2335
|
retried = true;
|
|
@@ -1902,7 +2363,8 @@ function createProjectionCheckpointStoreContractTests(harness) {
|
|
|
1902
2363
|
const loaded = await env.run((ctx) => env.store.load(ctx, "order-list", order("o-1")));
|
|
1903
2364
|
assert(loaded !== void 0, "expected a stored watermark");
|
|
1904
2365
|
loaded.position.aggregateVersion = 99;
|
|
1905
|
-
|
|
2366
|
+
const reloaded = await env.run((ctx) => env.store.load(ctx, "order-list", order("o-1")));
|
|
2367
|
+
assert(reloaded?.position.aggregateVersion === 2, "the stored watermark must be immune to mutation of a previously loaded copy");
|
|
1906
2368
|
})
|
|
1907
2369
|
},
|
|
1908
2370
|
{
|
|
@@ -1996,7 +2458,8 @@ function createProjectionCheckpointStoreContractTests(harness) {
|
|
|
1996
2458
|
run: inEnv(async (env) => {
|
|
1997
2459
|
if (!env.runRolledBack) throw new Error("Contract violated: harness declared providesRolledBackRuns but the environment lacks runRolledBack");
|
|
1998
2460
|
await env.runRolledBack((ctx) => env.store.save(ctx, "order-list", order("o-1"), checkpoint(pos(1, 0)))).catch(() => {});
|
|
1999
|
-
|
|
2461
|
+
const loaded = await env.run((ctx) => env.store.load(ctx, "order-list", order("o-1")));
|
|
2462
|
+
assertEqual(loaded, void 0, "a checkpoint from a rolled-back transaction must not exist; otherwise events are lost while marked processed");
|
|
2000
2463
|
})
|
|
2001
2464
|
})
|
|
2002
2465
|
];
|
|
@@ -2037,7 +2500,7 @@ function createRepositoryContractTests(harness) {
|
|
|
2037
2500
|
}
|
|
2038
2501
|
const reload = (environment, id) => environment.run(({ repository }) => load(repository, id));
|
|
2039
2502
|
const eventIds = (events) => sortedCommittedEventIds(events);
|
|
2040
|
-
const pendingEventIds = (events) =>
|
|
2503
|
+
const pendingEventIds = (events) => recordedPendingEventIds(events, "the harness must record pending events before persistence");
|
|
2041
2504
|
const tests = [
|
|
2042
2505
|
{
|
|
2043
2506
|
name: "add flushes a new aggregate and its exact event batch atomically",
|
|
@@ -2122,7 +2585,8 @@ function createRepositoryContractTests(harness) {
|
|
|
2122
2585
|
repository.add(aggregate);
|
|
2123
2586
|
throw new Error("rollback probe");
|
|
2124
2587
|
}));
|
|
2125
|
-
|
|
2588
|
+
const absent = await environment.run(({ repository }) => repository.findById(aggregate.id));
|
|
2589
|
+
assert(absent === void 0, "a rolled-back add must leave no row");
|
|
2126
2590
|
assertEqual((await environment.committedOutboxEvents()).length, 0, "a rolled-back add must leave no outbox record");
|
|
2127
2591
|
assert(deepEqual(aggregate.pendingEvents, pending), "rollback must acknowledge none of the registered event batch");
|
|
2128
2592
|
})
|
|
@@ -2134,10 +2598,12 @@ function createRepositoryContractTests(harness) {
|
|
|
2134
2598
|
harness.mutate(aggregate);
|
|
2135
2599
|
const pending = [...aggregate.pendingEvents];
|
|
2136
2600
|
environment.failNextOutboxWrite(/* @__PURE__ */ new Error("outbox failure probe"));
|
|
2137
|
-
|
|
2601
|
+
const rejection = await captureRejection(environment.run(async ({ repository }) => {
|
|
2138
2602
|
repository.add(aggregate);
|
|
2139
|
-
}))
|
|
2140
|
-
assert(
|
|
2603
|
+
}));
|
|
2604
|
+
assert(rejection !== void 0, "the outbox failure must reject");
|
|
2605
|
+
const absent = await environment.run(({ repository }) => repository.findById(aggregate.id));
|
|
2606
|
+
assert(absent === void 0, "state flush must roll back when the outbox write fails");
|
|
2141
2607
|
assertEqual((await environment.committedOutboxEvents()).length, 0, "failed outbox write must commit no envelope");
|
|
2142
2608
|
assert(deepEqual(aggregate.pendingEvents, pending), "failed commit must acknowledge none of the event batch");
|
|
2143
2609
|
})
|
|
@@ -2200,7 +2666,8 @@ function createRepositoryContractTests(harness) {
|
|
|
2200
2666
|
mutateVersionOnly.call(harness, aggregate);
|
|
2201
2667
|
repository.update(aggregate);
|
|
2202
2668
|
});
|
|
2203
|
-
|
|
2669
|
+
const reloaded = await reload(environment, seeded.id);
|
|
2670
|
+
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");
|
|
2204
2671
|
assert(deepEqual(eventIds(await environment.committedOutboxEvents()), eventIds(outboxBefore)), "state-only update must not create an outbox event");
|
|
2205
2672
|
})
|
|
2206
2673
|
}));
|
|
@@ -2217,7 +2684,8 @@ function createRepositoryContractTests(harness) {
|
|
|
2217
2684
|
mutateChildCollection.call(harness, aggregate);
|
|
2218
2685
|
repository.update(aggregate);
|
|
2219
2686
|
});
|
|
2220
|
-
|
|
2687
|
+
const reloaded = await reload(environment, seeded.id);
|
|
2688
|
+
assertEqual(reloaded.version, seeded.version + 1, "nested collection update must advance the persisted root version");
|
|
2221
2689
|
})
|
|
2222
2690
|
}));
|
|
2223
2691
|
tests.push(gatedContractTest({
|
|
@@ -2336,7 +2804,8 @@ function createSnapshotStoreContractTests(harness) {
|
|
|
2336
2804
|
total: 0,
|
|
2337
2805
|
items: []
|
|
2338
2806
|
}));
|
|
2339
|
-
|
|
2807
|
+
const loaded = await env.store.load(address("Order", "o-1"));
|
|
2808
|
+
assertEqual(loaded?.schemaVersion, void 0, "a snapshot stored without schemaVersion must not come back with a fabricated one; restore treats absence as schema 1");
|
|
2340
2809
|
})
|
|
2341
2810
|
},
|
|
2342
2811
|
{
|
|
@@ -2413,12 +2882,13 @@ function createSnapshotStoreContractTests(harness) {
|
|
|
2413
2882
|
assert(loaded !== void 0, "expected the saved snapshot");
|
|
2414
2883
|
assertEqual(loaded.state.items.length, 1, "save must capture the snapshot by value, not hold the caller's reference");
|
|
2415
2884
|
loaded.state.total = 999;
|
|
2416
|
-
|
|
2885
|
+
const reloaded = await env.store.load(address("Order", "o-1"));
|
|
2886
|
+
assertEqual(reloaded?.state.total, 5, "load must hand out a detached copy, never live internal state");
|
|
2417
2887
|
})
|
|
2418
2888
|
}
|
|
2419
2889
|
];
|
|
2420
2890
|
}
|
|
2421
2891
|
|
|
2422
2892
|
//#endregion
|
|
2423
|
-
export { createCommandOutboxContractTests, createDeadlineStoreContractTests, createEsRepositoryContractTests, createEventStoreContractTests, createIdempotencyStoreContractTests, createOutboxContractTests, createProjectionCheckpointStoreContractTests, createRepositoryContractTests, createSnapshotStoreContractTests };
|
|
2893
|
+
export { createCommandOutboxContractTests, createDeadlineStoreContractTests, createEsRepositoryContractTests, createEventBusContractTests, createEventStoreContractTests, createIdempotencyStoreContractTests, createOutboxContractTests, createProjectionCheckpointStoreContractTests, createRepositoryContractTests, createSnapshotStoreContractTests };
|
|
2424
2894
|
//# sourceMappingURL=testing.js.map
|