@shirudo/ddd-kit 2.2.0 → 3.0.0-rc.4
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 +206 -55
- package/dist/chunks/deep-equal-except.js +639 -0
- package/dist/chunks/deep-equal-except.js.map +1 -0
- package/dist/chunks/errors.d.ts +785 -0
- package/dist/chunks/errors.js +822 -0
- package/dist/chunks/errors.js.map +1 -0
- package/dist/chunks/ports.js +891 -0
- package/dist/chunks/ports.js.map +1 -0
- package/dist/chunks/snapshot-store.d.ts +2808 -0
- package/dist/chunks/utils.d.ts +110 -0
- package/dist/http.d.ts +64 -51
- package/dist/http.js +54 -20
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +2351 -2650
- package/dist/index.js +6140 -3915
- package/dist/index.js.map +1 -1
- package/dist/money.d.ts +376 -0
- package/dist/money.js +578 -0
- package/dist/money.js.map +1 -0
- package/dist/presentation.d.ts +86 -37
- package/dist/presentation.js +208 -39
- package/dist/presentation.js.map +1 -1
- package/dist/testing.d.ts +517 -335
- package/dist/testing.js +2396 -1184
- package/dist/testing.js.map +1 -1
- package/dist/utils.d.ts +2 -106
- package/dist/utils.js +2 -530
- package/package.json +35 -18
- package/dist/aggregate-DFi6HlEh.d.ts +0 -771
- package/dist/utils.js.map +0 -1
package/dist/testing.js
CHANGED
|
@@ -1,1212 +1,2424 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { r as deepEqual } from "./chunks/deep-equal-except.js";
|
|
2
|
+
import { t as isDispatchTrackingOutbox, u as isMintedEvent } from "./chunks/ports.js";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"[object BigInt]",
|
|
18
|
-
"[object ArrayBuffer]",
|
|
19
|
-
"[object SharedArrayBuffer]",
|
|
20
|
-
"[object DataView]"
|
|
21
|
-
]);
|
|
22
|
-
function intrinsicGetter(proto, prop) {
|
|
23
|
-
const get = Object.getOwnPropertyDescriptor(proto, prop)?.get;
|
|
24
|
-
if (!get) throw new Error(`missing intrinsic getter for ${prop}`);
|
|
25
|
-
return get;
|
|
26
|
-
}
|
|
27
|
-
__name(intrinsicGetter, "intrinsicGetter");
|
|
28
|
-
var dateGetTime = Date.prototype.getTime;
|
|
29
|
-
var mapSizeGet = intrinsicGetter(Map.prototype, "size");
|
|
30
|
-
var setSizeGet = intrinsicGetter(Set.prototype, "size");
|
|
31
|
-
var weakMapHas = WeakMap.prototype.has;
|
|
32
|
-
var weakSetHas = WeakSet.prototype.has;
|
|
33
|
-
var dataViewByteLengthGet = intrinsicGetter(DataView.prototype, "byteLength");
|
|
34
|
-
var arrayBufferByteLengthGet = intrinsicGetter(
|
|
35
|
-
ArrayBuffer.prototype,
|
|
36
|
-
"byteLength"
|
|
37
|
-
);
|
|
38
|
-
var sharedArrayBufferByteLengthGet = typeof SharedArrayBuffer === "undefined" ? void 0 : intrinsicGetter(SharedArrayBuffer.prototype, "byteLength");
|
|
39
|
-
var regExpSourceGet = intrinsicGetter(RegExp.prototype, "source");
|
|
40
|
-
var booleanValueOf = Boolean.prototype.valueOf;
|
|
41
|
-
var numberValueOf = Number.prototype.valueOf;
|
|
42
|
-
var stringValueOf = String.prototype.valueOf;
|
|
43
|
-
var bigIntValueOf = BigInt.prototype.valueOf;
|
|
44
|
-
var functionToString = Function.prototype.toString;
|
|
45
|
-
var PROBE_KEY = {};
|
|
46
|
-
var INTRINSIC_CONSTRUCTOR_NAMES = [
|
|
47
|
-
"Object",
|
|
48
|
-
"Array",
|
|
49
|
-
"Date",
|
|
50
|
-
"RegExp",
|
|
51
|
-
"Map",
|
|
52
|
-
"Set",
|
|
53
|
-
"WeakMap",
|
|
54
|
-
"WeakSet",
|
|
55
|
-
"Promise",
|
|
56
|
-
"Error",
|
|
57
|
-
"EvalError",
|
|
58
|
-
"RangeError",
|
|
59
|
-
"ReferenceError",
|
|
60
|
-
"SyntaxError",
|
|
61
|
-
"TypeError",
|
|
62
|
-
"URIError",
|
|
63
|
-
"AggregateError",
|
|
64
|
-
"Boolean",
|
|
65
|
-
"Number",
|
|
66
|
-
"String",
|
|
67
|
-
"BigInt",
|
|
68
|
-
"ArrayBuffer",
|
|
69
|
-
"SharedArrayBuffer",
|
|
70
|
-
"DataView",
|
|
71
|
-
"Int8Array",
|
|
72
|
-
"Uint8Array",
|
|
73
|
-
"Uint8ClampedArray",
|
|
74
|
-
"Int16Array",
|
|
75
|
-
"Uint16Array",
|
|
76
|
-
"Int32Array",
|
|
77
|
-
"Uint32Array",
|
|
78
|
-
"Float32Array",
|
|
79
|
-
"Float64Array",
|
|
80
|
-
"BigInt64Array",
|
|
81
|
-
"BigUint64Array"
|
|
82
|
-
];
|
|
83
|
-
var intrinsicConstructorSources = new Map(
|
|
84
|
-
INTRINSIC_CONSTRUCTOR_NAMES.flatMap((name) => {
|
|
85
|
-
const descriptor = Object.getOwnPropertyDescriptor(globalThis, name);
|
|
86
|
-
const intrinsic = descriptor?.value;
|
|
87
|
-
return typeof intrinsic === "function" ? [[name, functionToString.call(intrinsic)]] : [];
|
|
88
|
-
})
|
|
89
|
-
);
|
|
90
|
-
var intrinsicConstructorSourceSet = new Set(
|
|
91
|
-
intrinsicConstructorSources.values()
|
|
92
|
-
);
|
|
93
|
-
var ERROR_INTRINSIC_NAMES = [
|
|
94
|
-
"Error",
|
|
95
|
-
"EvalError",
|
|
96
|
-
"RangeError",
|
|
97
|
-
"ReferenceError",
|
|
98
|
-
"SyntaxError",
|
|
99
|
-
"TypeError",
|
|
100
|
-
"URIError",
|
|
101
|
-
"AggregateError"
|
|
102
|
-
];
|
|
103
|
-
function isIntrinsicConstructorPrototype(prototype, expectedName) {
|
|
104
|
-
const constructorDescriptor = Object.getOwnPropertyDescriptor(
|
|
105
|
-
prototype,
|
|
106
|
-
"constructor"
|
|
107
|
-
);
|
|
108
|
-
const candidateConstructor = constructorDescriptor?.value;
|
|
109
|
-
if (constructorDescriptor === void 0 || !("value" in constructorDescriptor) || typeof candidateConstructor !== "function") {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
let candidateSource;
|
|
113
|
-
try {
|
|
114
|
-
candidateSource = functionToString.call(candidateConstructor);
|
|
115
|
-
} catch {
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
const expectedSource = expectedName === void 0 ? void 0 : intrinsicConstructorSources.get(expectedName);
|
|
119
|
-
if (expectedSource !== void 0 && candidateSource !== expectedSource || expectedSource === void 0 && !intrinsicConstructorSourceSet.has(candidateSource)) {
|
|
120
|
-
return false;
|
|
121
|
-
}
|
|
122
|
-
const nameDescriptor = Object.getOwnPropertyDescriptor(
|
|
123
|
-
candidateConstructor,
|
|
124
|
-
"name"
|
|
125
|
-
);
|
|
126
|
-
const candidateName = nameDescriptor !== void 0 && "value" in nameDescriptor && typeof nameDescriptor.value === "string" ? nameDescriptor.value : void 0;
|
|
127
|
-
const intrinsicName = expectedName ?? candidateName;
|
|
128
|
-
const intrinsicSource = intrinsicName === void 0 ? void 0 : intrinsicConstructorSources.get(intrinsicName);
|
|
129
|
-
return candidateName === intrinsicName && intrinsicSource !== void 0 && candidateSource === intrinsicSource && Object.getOwnPropertyDescriptor(candidateConstructor, "prototype")?.value === prototype;
|
|
130
|
-
}
|
|
131
|
-
__name(isIntrinsicConstructorPrototype, "isIntrinsicConstructorPrototype");
|
|
132
|
-
function hasNativePrototype(value, expectedName) {
|
|
133
|
-
const visited = /* @__PURE__ */ new WeakSet();
|
|
134
|
-
let prototype = Object.getPrototypeOf(value);
|
|
135
|
-
while (prototype !== null && !visited.has(prototype)) {
|
|
136
|
-
visited.add(prototype);
|
|
137
|
-
if (isIntrinsicConstructorPrototype(prototype, expectedName)) {
|
|
138
|
-
return true;
|
|
139
|
-
}
|
|
140
|
-
prototype = Object.getPrototypeOf(prototype);
|
|
141
|
-
}
|
|
142
|
-
return false;
|
|
143
|
-
}
|
|
144
|
-
__name(hasNativePrototype, "hasNativePrototype");
|
|
145
|
-
function hasBrand(obj, tag) {
|
|
146
|
-
try {
|
|
147
|
-
switch (tag) {
|
|
148
|
-
case "[object Date]":
|
|
149
|
-
dateGetTime.call(obj);
|
|
150
|
-
return true;
|
|
151
|
-
case "[object RegExp]":
|
|
152
|
-
regExpSourceGet.call(obj);
|
|
153
|
-
return true;
|
|
154
|
-
case "[object Map]":
|
|
155
|
-
mapSizeGet.call(obj);
|
|
156
|
-
return true;
|
|
157
|
-
case "[object Set]":
|
|
158
|
-
setSizeGet.call(obj);
|
|
159
|
-
return true;
|
|
160
|
-
case "[object WeakMap]":
|
|
161
|
-
weakMapHas.call(obj, PROBE_KEY);
|
|
162
|
-
return true;
|
|
163
|
-
case "[object WeakSet]":
|
|
164
|
-
weakSetHas.call(obj, PROBE_KEY);
|
|
165
|
-
return true;
|
|
166
|
-
case "[object DataView]":
|
|
167
|
-
dataViewByteLengthGet.call(obj);
|
|
168
|
-
return true;
|
|
169
|
-
case "[object ArrayBuffer]":
|
|
170
|
-
arrayBufferByteLengthGet.call(obj);
|
|
171
|
-
return true;
|
|
172
|
-
case "[object SharedArrayBuffer]":
|
|
173
|
-
if (!sharedArrayBufferByteLengthGet) return false;
|
|
174
|
-
sharedArrayBufferByteLengthGet.call(obj);
|
|
175
|
-
return true;
|
|
176
|
-
case "[object Boolean]":
|
|
177
|
-
booleanValueOf.call(obj);
|
|
178
|
-
return true;
|
|
179
|
-
case "[object Number]":
|
|
180
|
-
numberValueOf.call(obj);
|
|
181
|
-
return true;
|
|
182
|
-
case "[object String]":
|
|
183
|
-
stringValueOf.call(obj);
|
|
184
|
-
return true;
|
|
185
|
-
case "[object BigInt]":
|
|
186
|
-
bigIntValueOf.call(obj);
|
|
187
|
-
return true;
|
|
188
|
-
case "[object Promise]":
|
|
189
|
-
return hasNativePrototype(obj, "Promise");
|
|
190
|
-
case "[object Error]":
|
|
191
|
-
return ERROR_INTRINSIC_NAMES.some(
|
|
192
|
-
(name) => hasNativePrototype(obj, name)
|
|
193
|
-
);
|
|
194
|
-
default:
|
|
195
|
-
return false;
|
|
196
|
-
}
|
|
197
|
-
} catch {
|
|
198
|
-
return false;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
__name(hasBrand, "hasBrand");
|
|
202
|
-
function isBuiltInObject(obj, tag) {
|
|
203
|
-
if (ArrayBuffer.isView(obj)) return true;
|
|
204
|
-
if (tag.endsWith("Array]")) return false;
|
|
205
|
-
return BUILT_IN_TAGS.has(tag) && hasBrand(obj, tag);
|
|
206
|
-
}
|
|
207
|
-
__name(isBuiltInObject, "isBuiltInObject");
|
|
208
|
-
|
|
209
|
-
// src/utils/array/deep-equal.ts
|
|
210
|
-
var objProto = Object.prototype;
|
|
211
|
-
var objToString = objProto.toString;
|
|
212
|
-
var objHasOwn = objProto.hasOwnProperty;
|
|
213
|
-
function sameValueZero(a, b) {
|
|
214
|
-
return a === b || Number.isNaN(a) && Number.isNaN(b);
|
|
215
|
-
}
|
|
216
|
-
__name(sameValueZero, "sameValueZero");
|
|
217
|
-
function deepEqual(a, b) {
|
|
218
|
-
return deepEqualInner(a, b, /* @__PURE__ */ new WeakMap());
|
|
219
|
-
}
|
|
220
|
-
__name(deepEqual, "deepEqual");
|
|
221
|
-
function deepEqualInner(a, b, visited) {
|
|
222
|
-
if (a === b) return true;
|
|
223
|
-
const typeA = typeof a;
|
|
224
|
-
const typeB = typeof b;
|
|
225
|
-
if (typeA !== "object" || a === null || typeB !== "object" || b === null) {
|
|
226
|
-
if (typeA === "number" && typeB === "number") {
|
|
227
|
-
return Number.isNaN(a) && Number.isNaN(b);
|
|
228
|
-
}
|
|
229
|
-
return false;
|
|
230
|
-
}
|
|
231
|
-
const objA = a;
|
|
232
|
-
const objB = b;
|
|
233
|
-
let cachedBs = visited.get(objA);
|
|
234
|
-
if (cachedBs?.has(objB)) {
|
|
235
|
-
return true;
|
|
236
|
-
}
|
|
237
|
-
if (!cachedBs) {
|
|
238
|
-
cachedBs = /* @__PURE__ */ new WeakSet();
|
|
239
|
-
visited.set(objA, cachedBs);
|
|
240
|
-
}
|
|
241
|
-
cachedBs.add(objB);
|
|
242
|
-
if (ArrayBuffer.isView(objA) || ArrayBuffer.isView(objB)) {
|
|
243
|
-
if (!ArrayBuffer.isView(objA) || !ArrayBuffer.isView(objB)) return false;
|
|
244
|
-
const tagA2 = objToString.call(objA);
|
|
245
|
-
const tagB2 = objToString.call(objB);
|
|
246
|
-
if (tagA2 !== tagB2) return false;
|
|
247
|
-
if (tagA2 === "[object DataView]") {
|
|
248
|
-
const viewA = objA;
|
|
249
|
-
const viewB = objB;
|
|
250
|
-
if (viewA.byteLength !== viewB.byteLength) return false;
|
|
251
|
-
const len2 = viewA.byteLength;
|
|
252
|
-
for (let i = 0; i < len2; i++) {
|
|
253
|
-
if (viewA.getUint8(i) !== viewB.getUint8(i)) return false;
|
|
254
|
-
}
|
|
255
|
-
return true;
|
|
256
|
-
}
|
|
257
|
-
const arrA = objA;
|
|
258
|
-
const arrB = objB;
|
|
259
|
-
const len = arrA.length;
|
|
260
|
-
if (len !== arrB.length) return false;
|
|
261
|
-
for (let i = 0; i < len; i++) {
|
|
262
|
-
if (!sameValueZero(arrA[i], arrB[i])) return false;
|
|
263
|
-
}
|
|
264
|
-
return true;
|
|
265
|
-
}
|
|
266
|
-
if (Array.isArray(objA) || Array.isArray(objB)) {
|
|
267
|
-
if (!Array.isArray(objA) || !Array.isArray(objB)) return false;
|
|
268
|
-
if (objA.length !== objB.length) return false;
|
|
269
|
-
const keysA = Reflect.ownKeys(objA).filter((key) => key !== "length");
|
|
270
|
-
const keysB = Reflect.ownKeys(objB).filter((key) => key !== "length");
|
|
271
|
-
if (keysA.length !== keysB.length) return false;
|
|
272
|
-
const arrA = objA;
|
|
273
|
-
const arrB = objB;
|
|
274
|
-
for (const key of keysA) {
|
|
275
|
-
if (!objHasOwn.call(objB, key)) return false;
|
|
276
|
-
if (!deepEqualInner(arrA[key], arrB[key], visited)) {
|
|
277
|
-
return false;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
return true;
|
|
281
|
-
}
|
|
282
|
-
const tagA = objToString.call(objA);
|
|
283
|
-
const tagB = objToString.call(objB);
|
|
284
|
-
if (tagA !== tagB) return false;
|
|
285
|
-
const builtInA = isBuiltInObject(objA, tagA);
|
|
286
|
-
const builtInB = isBuiltInObject(objB, tagB);
|
|
287
|
-
if (builtInA !== builtInB) return false;
|
|
288
|
-
if (!builtInA) {
|
|
289
|
-
return comparePlainObjects(objA, objB, visited);
|
|
290
|
-
}
|
|
291
|
-
switch (tagA) {
|
|
292
|
-
case "[object Map]": {
|
|
293
|
-
const mapA = objA;
|
|
294
|
-
const mapB = objB;
|
|
295
|
-
if (mapA.size !== mapB.size) return false;
|
|
296
|
-
for (const [key, valA] of mapA) {
|
|
297
|
-
if (!mapB.has(key)) return false;
|
|
298
|
-
const valB = mapB.get(key);
|
|
299
|
-
if (!deepEqualInner(valA, valB, visited)) return false;
|
|
300
|
-
}
|
|
301
|
-
return true;
|
|
302
|
-
}
|
|
303
|
-
case "[object Set]": {
|
|
304
|
-
const setA = objA;
|
|
305
|
-
const setB = objB;
|
|
306
|
-
if (setA.size !== setB.size) return false;
|
|
307
|
-
for (const value of setA) {
|
|
308
|
-
if (!setB.has(value)) return false;
|
|
309
|
-
}
|
|
310
|
-
return true;
|
|
311
|
-
}
|
|
312
|
-
case "[object Date]": {
|
|
313
|
-
return sameValueZero(objA.getTime(), objB.getTime());
|
|
314
|
-
}
|
|
315
|
-
case "[object RegExp]": {
|
|
316
|
-
const regA = objA;
|
|
317
|
-
const regB = objB;
|
|
318
|
-
return regA.source === regB.source && regA.flags === regB.flags;
|
|
319
|
-
}
|
|
320
|
-
case "[object Boolean]":
|
|
321
|
-
case "[object Number]":
|
|
322
|
-
case "[object String]":
|
|
323
|
-
case "[object BigInt]": {
|
|
324
|
-
return sameValueZero(
|
|
325
|
-
objA.valueOf(),
|
|
326
|
-
objB.valueOf()
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
default: {
|
|
330
|
-
return objA === objB;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
__name(deepEqualInner, "deepEqualInner");
|
|
335
|
-
function comparePlainObjects(objA, objB, visited) {
|
|
336
|
-
const recA = objA;
|
|
337
|
-
const recB = objB;
|
|
338
|
-
const stringKeysA = Object.getOwnPropertyNames(objA);
|
|
339
|
-
const stringKeysB = Object.getOwnPropertyNames(objB);
|
|
340
|
-
if (stringKeysA.length !== stringKeysB.length) return false;
|
|
341
|
-
const symbolKeysA = Object.getOwnPropertySymbols(objA);
|
|
342
|
-
const symbolKeysB = Object.getOwnPropertySymbols(objB);
|
|
343
|
-
if (symbolKeysA.length !== symbolKeysB.length) return false;
|
|
344
|
-
const symbolKeysBSet = new Set(symbolKeysB);
|
|
345
|
-
for (const key of stringKeysA) {
|
|
346
|
-
if (!objHasOwn.call(objB, key)) return false;
|
|
347
|
-
}
|
|
348
|
-
for (const key of symbolKeysA) {
|
|
349
|
-
if (!symbolKeysBSet.has(key)) return false;
|
|
350
|
-
}
|
|
351
|
-
for (const key of stringKeysA) {
|
|
352
|
-
if (!deepEqualInner(recA[key], recB[key], visited)) {
|
|
353
|
-
return false;
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
for (const key of symbolKeysA) {
|
|
357
|
-
if (!deepEqualInner(recA[key], recB[key], visited)) {
|
|
358
|
-
return false;
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
return true;
|
|
362
|
-
}
|
|
363
|
-
__name(comparePlainObjects, "comparePlainObjects");
|
|
364
|
-
|
|
365
|
-
// src/testing/contract-assertions.ts
|
|
4
|
+
//#region src/testing/contract-assertions.ts
|
|
5
|
+
/**
|
|
6
|
+
* Assertion, error-matching, and suite-runner helpers shared by the
|
|
7
|
+
* repository contract suites (state-stored and event-sourced). Internal
|
|
8
|
+
* to the testing entry: not re-exported from `@shirudo/ddd-kit/testing`.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Runs one contract-test body against a fresh environment and tears it
|
|
12
|
+
* down in a finally-like discipline with one subtle, load-bearing rule:
|
|
13
|
+
* a teardown failure (dropping a schema on an aborted pool) must never
|
|
14
|
+
* REPLACE the contract-violation diagnostic that is the suite's entire
|
|
15
|
+
* value. It only surfaces when the body itself succeeded.
|
|
16
|
+
*/
|
|
366
17
|
async function runInContractEnvironment(createEnvironment, body) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
18
|
+
const env = await createEnvironment();
|
|
19
|
+
let bodyFailed = false;
|
|
20
|
+
let bodyError;
|
|
21
|
+
try {
|
|
22
|
+
await body(env);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
bodyFailed = true;
|
|
25
|
+
bodyError = error;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
await env.teardown?.();
|
|
29
|
+
} catch (teardownError) {
|
|
30
|
+
if (!bodyFailed) throw teardownError;
|
|
31
|
+
}
|
|
32
|
+
if (bodyFailed) throw bodyError;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Binds a harness's environment factory into the per-test wrapper the
|
|
36
|
+
* suites build their entries from: `inEnv(body)` yields a test `run`
|
|
37
|
+
* that creates a fresh environment, runs the body, and tears down via
|
|
38
|
+
* {@link runInContractEnvironment}.
|
|
39
|
+
*/
|
|
40
|
+
function bindContractEnvironment(createEnvironment) {
|
|
41
|
+
return (body) => () => runInContractEnvironment(createEnvironment, body);
|
|
386
42
|
}
|
|
387
|
-
|
|
43
|
+
/** Resolves to the rejection reason, or `undefined` when the promise resolved. */
|
|
388
44
|
function captureRejection(promise) {
|
|
389
|
-
|
|
390
|
-
() => void 0,
|
|
391
|
-
(error) => error
|
|
392
|
-
);
|
|
45
|
+
return promise.then(() => void 0, (error) => error);
|
|
393
46
|
}
|
|
394
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Load with a contract diagnostic instead of a bare TypeError downstream.
|
|
49
|
+
* `suspectHint` names the suite-specific likely cause (broken hydration
|
|
50
|
+
* vs broken replay read).
|
|
51
|
+
*/
|
|
395
52
|
async function loadAggregateOrFail(repository, id, suspectHint) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
`getById(${String(id)}) returned null for an aggregate that must exist: ${suspectHint}`
|
|
400
|
-
);
|
|
401
|
-
return loaded;
|
|
53
|
+
const loaded = await repository.findById(id);
|
|
54
|
+
assert(loaded !== null && loaded !== void 0, `findById(${String(id)}) returned no aggregate for an identity that must exist: ${suspectHint}`);
|
|
55
|
+
return loaded;
|
|
402
56
|
}
|
|
403
|
-
|
|
57
|
+
/**
|
|
58
|
+
* A capability-gated test entry whose `run()` rejects loudly, so a naive
|
|
59
|
+
* binding that ignores `skipped` fails instead of green-no-op'ing.
|
|
60
|
+
* Structurally assignable to both suites' test-entry types.
|
|
61
|
+
*/
|
|
404
62
|
function skippedContractTest(name, capability) {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
63
|
+
return {
|
|
64
|
+
name,
|
|
65
|
+
skipped: { capability },
|
|
66
|
+
run: async () => {
|
|
67
|
+
throw new Error(`Contract test skipped: harness capability '${capability}' is not provided. Bind skipped tests with it.skip ((test.skipped ? it.skip : it)(test.name, test.run)) or provide the capability; each skipped capability is an unproven guarantee.`);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Capability gate that keeps a test's NAME single-sourced: a harness
|
|
73
|
+
* that satisfies the gate gets the real test, everyone else gets the
|
|
74
|
+
* loud skipped entry under the same name (see
|
|
75
|
+
* {@link skippedContractTest}). Nests for tests behind several gates;
|
|
76
|
+
* the outermost failing gate's capability wins the skip report.
|
|
77
|
+
*/
|
|
78
|
+
function gatedContractTest(gate, test) {
|
|
79
|
+
return gate.satisfiedBy ? test : skippedContractTest(test.name, gate.capability);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Identities of an in-memory pending batch, with the shared precondition
|
|
83
|
+
* that every event is recorded (minted). The `requirement` names the
|
|
84
|
+
* suite-specific rule the harness violated when an event is unminted.
|
|
85
|
+
*/
|
|
86
|
+
function mintedPendingEventIds(events, requirement) {
|
|
87
|
+
return events.map((event) => {
|
|
88
|
+
assert(typeof event === "object" && event !== null && isMintedEvent(event), requirement);
|
|
89
|
+
return event.eventId;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Sorted identities of committed outbox envelopes. Shared by both
|
|
94
|
+
* repository suites so the projection cannot drift between them.
|
|
95
|
+
*/
|
|
96
|
+
function sortedCommittedEventIds(committed) {
|
|
97
|
+
return committed.map(({ event }) => event.eventId).sort();
|
|
414
98
|
}
|
|
415
|
-
__name(skippedContractTest, "skippedContractTest");
|
|
416
99
|
function assert(condition, message) {
|
|
417
|
-
|
|
418
|
-
throw new Error(`Repository contract violated: ${message}`);
|
|
419
|
-
}
|
|
100
|
+
if (!condition) throw new Error(`Contract violated: ${message}`);
|
|
420
101
|
}
|
|
421
|
-
__name(assert, "assert");
|
|
422
102
|
function assertEqual(actual, expected, message) {
|
|
423
|
-
|
|
424
|
-
throw new Error(
|
|
425
|
-
`Repository contract violated: ${message} (expected ${String(expected)}, got ${String(actual)})`
|
|
426
|
-
);
|
|
427
|
-
}
|
|
103
|
+
if (actual !== expected) throw new Error(`Contract violated: ${message} (expected ${String(expected)}, got ${String(actual)})`);
|
|
428
104
|
}
|
|
429
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Walks the standard `cause` chain (cycle-safe, hostile-getter-safe)
|
|
107
|
+
* looking for an Error that matches the given name. Matching is
|
|
108
|
+
* deliberately by NAME, not `instanceof`: the suite ships in its own
|
|
109
|
+
* bundle entry, and the adapter's errors come from the main entry's
|
|
110
|
+
* copy of the kit (or even a second installed kit version) -
|
|
111
|
+
* cross-copy `instanceof` is always false, name identity is the stable
|
|
112
|
+
* contract. Since v3 the kit's errors are StructuredErrors whose
|
|
113
|
+
* runtime `name` IS their SCREAMING_SNAKE code, minification-stable by
|
|
114
|
+
* construction and inherited by subclasses (a `PgConflictError extends
|
|
115
|
+
* ConcurrencyConflictError` keeps the code as its name). The suites
|
|
116
|
+
* match ONLY the v3 codes. Failure diagnostics render the rejection's
|
|
117
|
+
* cause-chain names ({@link describeError}), so an unexpected error,
|
|
118
|
+
* including one from a different kit copy in the dependency graph, is
|
|
119
|
+
* identifiable from the message without version-specific knowledge in
|
|
120
|
+
* the suite.
|
|
121
|
+
*/
|
|
430
122
|
function chainContainsErrorNamed(error, name) {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (errorMatchesName(current, name)) {
|
|
438
|
-
return true;
|
|
439
|
-
}
|
|
440
|
-
seen.add(current);
|
|
441
|
-
try {
|
|
442
|
-
current = current.cause;
|
|
443
|
-
} catch {
|
|
444
|
-
return false;
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
return false;
|
|
123
|
+
let found = false;
|
|
124
|
+
walkCauseChain(error, (node) => {
|
|
125
|
+
found = errorMatchesName(node, name);
|
|
126
|
+
return found;
|
|
127
|
+
});
|
|
128
|
+
return found;
|
|
448
129
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
130
|
+
/**
|
|
131
|
+
* The one cause-chain walk every chain-inspecting helper in this file
|
|
132
|
+
* is expressed through (cycle-safe, hostile-cause-getter-safe): visits
|
|
133
|
+
* each object node until `visit` asks to stop by returning `true`, the
|
|
134
|
+
* chain ends, repeats, or advancing turns hostile. Single-sourced on
|
|
135
|
+
* purpose: a hardening fix (a depth cap, a new hostile shape) must land
|
|
136
|
+
* in ALL walkers at once, or the suites judge the same adapter
|
|
137
|
+
* rejection inconsistently. Per-node property reads stay the visitor's
|
|
138
|
+
* responsibility; only the `cause` advance is guarded here.
|
|
139
|
+
*/
|
|
140
|
+
function walkCauseChain(error, visit) {
|
|
141
|
+
const seen = /* @__PURE__ */ new Set();
|
|
142
|
+
let current = error;
|
|
143
|
+
while (current !== null && current !== void 0 && typeof current === "object" && !seen.has(current)) {
|
|
144
|
+
seen.add(current);
|
|
145
|
+
if (visit(current)) return;
|
|
146
|
+
try {
|
|
147
|
+
current = current.cause;
|
|
148
|
+
} catch {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Asserts that the cause chain carries a kit error with one of the given
|
|
155
|
+
* codes (since v3, `error.name === error.code`; the codes are the ONLY
|
|
156
|
+
* accepted identity). Failure messages built with {@link describeError}
|
|
157
|
+
* render the rejection's cause-chain names, so an unexpected error, e.g.
|
|
158
|
+
* one from a different `@shirudo/ddd-kit` copy in the dependency graph,
|
|
159
|
+
* is identifiable from the diagnostic without the suite carrying any
|
|
160
|
+
* version-specific knowledge.
|
|
161
|
+
*/
|
|
162
|
+
function assertChainContainsKitError(rejection, codes, message) {
|
|
163
|
+
if (codes.some((code) => chainContainsErrorNamed(rejection, code))) return;
|
|
164
|
+
throw new Error(`Contract violated: ${message}`);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Walks the `cause` chain (cycle-safe, hostile-getter-safe) looking for
|
|
168
|
+
* `retryable === true`: the same loose, property-based contract the
|
|
169
|
+
* kit's retry classifier (`someChainRetryable`) applies. Suites assert
|
|
170
|
+
* retryability with this instead of reading the top-level rejection, so
|
|
171
|
+
* an adapter that wraps a kit error in its own error chain, which
|
|
172
|
+
* {@link assertChainContainsKitError} deliberately tolerates, is judged
|
|
173
|
+
* exactly the way a consumer's retry loop will judge it.
|
|
174
|
+
*
|
|
175
|
+
* Deliberately NOT a call to `someChainRetryable` itself: that
|
|
176
|
+
* classifier throws on a circular cause chain (its callers handle
|
|
177
|
+
* that), while a hardened suite must survive whatever error shape an
|
|
178
|
+
* adapter rejects with and answer with a contract diagnostic, never a
|
|
179
|
+
* helper crash. Same hardening discipline as
|
|
180
|
+
* {@link chainContainsErrorNamed}.
|
|
181
|
+
*/
|
|
182
|
+
function chainContainsRetryable(error) {
|
|
183
|
+
let found = false;
|
|
184
|
+
walkCauseChain(error, (node) => {
|
|
185
|
+
try {
|
|
186
|
+
found = node.retryable === true;
|
|
187
|
+
} catch {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
return found;
|
|
191
|
+
});
|
|
192
|
+
return found;
|
|
452
193
|
}
|
|
453
|
-
__name(chainContainsErrorNamedAnyOf, "chainContainsErrorNamedAnyOf");
|
|
454
194
|
function errorMatchesName(candidate, name) {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
467
|
-
proto = Object.getPrototypeOf(proto);
|
|
468
|
-
}
|
|
469
|
-
} catch {
|
|
470
|
-
}
|
|
471
|
-
return false;
|
|
195
|
+
try {
|
|
196
|
+
if (candidate.name === name) return true;
|
|
197
|
+
} catch {}
|
|
198
|
+
try {
|
|
199
|
+
let proto = Object.getPrototypeOf(candidate);
|
|
200
|
+
for (let depth = 0; proto !== null && depth < 20; depth++) {
|
|
201
|
+
if (proto.constructor?.name === name) return true;
|
|
202
|
+
proto = Object.getPrototypeOf(proto);
|
|
203
|
+
}
|
|
204
|
+
} catch {}
|
|
205
|
+
return false;
|
|
472
206
|
}
|
|
473
|
-
__name(errorMatchesName, "errorMatchesName");
|
|
474
207
|
function describeError(error) {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
208
|
+
if (error instanceof Error) {
|
|
209
|
+
const chain = causeChainNames(error);
|
|
210
|
+
const suffix = chain.length > 1 ? ` (cause chain: ${chain.join(" -> ")})` : "";
|
|
211
|
+
return `${error.name}: ${error.message}${suffix}`;
|
|
212
|
+
}
|
|
213
|
+
return String(error);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Names along the `cause` chain (cycle-safe, hostile-getter-safe), for
|
|
217
|
+
* failure diagnostics: a wrapped rejection shows WHAT it wraps, so an
|
|
218
|
+
* unexpected error deep in the chain (a raw driver error, or an error
|
|
219
|
+
* from a different kit copy) is identifiable from the message alone.
|
|
220
|
+
*/
|
|
221
|
+
function causeChainNames(error) {
|
|
222
|
+
const names = [];
|
|
223
|
+
walkCauseChain(error, (node) => {
|
|
224
|
+
try {
|
|
225
|
+
const { name } = node;
|
|
226
|
+
names.push(typeof name === "string" ? name : "(unnamed)");
|
|
227
|
+
} catch {
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
});
|
|
232
|
+
return names;
|
|
479
233
|
}
|
|
480
|
-
__name(describeError, "describeError");
|
|
481
234
|
|
|
482
|
-
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/testing/command-outbox-contract.ts
|
|
237
|
+
function createCommandOutboxContractTests(harness) {
|
|
238
|
+
const inEnv = bindContractEnvironment(harness.createEnvironment);
|
|
239
|
+
const commit = (seed, commandSeeds = [seed]) => ({
|
|
240
|
+
origin: {
|
|
241
|
+
eventId: `process-event-${seed}`,
|
|
242
|
+
source: {
|
|
243
|
+
aggregateType: "CheckoutProcess",
|
|
244
|
+
aggregateId: "order-1"
|
|
245
|
+
},
|
|
246
|
+
position: {
|
|
247
|
+
aggregateVersion: seed,
|
|
248
|
+
commitSequence: 0,
|
|
249
|
+
commitSize: 1
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
messages: commandSeeds.map((commandSeed, index) => message(seed, index, harness.createCommand(commandSeed)))
|
|
253
|
+
});
|
|
254
|
+
const tests = [
|
|
255
|
+
{
|
|
256
|
+
name: "deduplicates an exact retry by origin event id",
|
|
257
|
+
run: inEnv(async (env) => {
|
|
258
|
+
const original = commit(1);
|
|
259
|
+
await env.addCommitted([original]);
|
|
260
|
+
await env.addCommitted([original]);
|
|
261
|
+
const stored = await env.readAll();
|
|
262
|
+
assertEqual(stored.length, 1, "an exact retry must retain one receipt, not append a duplicate");
|
|
263
|
+
assert(deepEqual(stored[0], original), "an exact retry must preserve the original receipt and commands");
|
|
264
|
+
})
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: "rejects conflicting reuse of an origin event id",
|
|
268
|
+
run: inEnv(async (env) => {
|
|
269
|
+
const original = commit(1);
|
|
270
|
+
await env.addCommitted([original]);
|
|
271
|
+
const conflict = {
|
|
272
|
+
...commit(1, [99]),
|
|
273
|
+
origin: {
|
|
274
|
+
...commit(1, [99]).origin,
|
|
275
|
+
eventId: original.origin.eventId
|
|
276
|
+
}
|
|
277
|
+
};
|
|
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
|
+
assert(await captureRejection(env.addCommitted([conflict])) !== void 0, "a reused origin event id with different messages must reject");
|
|
280
|
+
const stored = await env.readAll();
|
|
281
|
+
assertEqual(stored.length, 1, "a conflicting retry must not append another receipt");
|
|
282
|
+
assert(deepEqual(stored[0], original), "a conflicting retry must not replace the original receipt");
|
|
283
|
+
})
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
name: "rejects an origin event id reused with a different source",
|
|
287
|
+
run: inEnv(async (env) => {
|
|
288
|
+
const original = commit(1);
|
|
289
|
+
await env.addCommitted([original]);
|
|
290
|
+
const conflicts = [{
|
|
291
|
+
fact: "aggregateId",
|
|
292
|
+
candidate: {
|
|
293
|
+
...original,
|
|
294
|
+
origin: {
|
|
295
|
+
...original.origin,
|
|
296
|
+
source: {
|
|
297
|
+
...original.origin.source,
|
|
298
|
+
aggregateId: "order-2"
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}, {
|
|
303
|
+
fact: "aggregateType",
|
|
304
|
+
candidate: {
|
|
305
|
+
...original,
|
|
306
|
+
origin: {
|
|
307
|
+
...original.origin,
|
|
308
|
+
source: {
|
|
309
|
+
...original.origin.source,
|
|
310
|
+
aggregateType: "Order"
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}];
|
|
315
|
+
for (const { fact, candidate } of conflicts) {
|
|
316
|
+
assert(await captureRejection(env.addCommitted([candidate])) !== void 0, `a reused origin event id with a different source.${fact} must reject`);
|
|
317
|
+
assert(deepEqual(await env.readAll(), [original]), `a source.${fact} conflict must leave the original receipt unchanged`);
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: "rejects an origin event id reused with a different position",
|
|
323
|
+
run: inEnv(async (env) => {
|
|
324
|
+
const original = commit(1);
|
|
325
|
+
await env.addCommitted([original]);
|
|
326
|
+
for (const { fact, change } of [
|
|
327
|
+
{
|
|
328
|
+
fact: "aggregateVersion",
|
|
329
|
+
change: { aggregateVersion: 2 }
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
fact: "commitSequence",
|
|
333
|
+
change: { commitSequence: 1 }
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
fact: "commitSize",
|
|
337
|
+
change: { commitSize: 2 }
|
|
338
|
+
}
|
|
339
|
+
]) {
|
|
340
|
+
const conflict = {
|
|
341
|
+
...original,
|
|
342
|
+
origin: {
|
|
343
|
+
...original.origin,
|
|
344
|
+
position: {
|
|
345
|
+
...original.origin.position,
|
|
346
|
+
...change
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
assert(await captureRejection(env.addCommitted([conflict])) !== void 0, `a reused origin event id with a different position.${fact} must reject`);
|
|
351
|
+
assert(deepEqual(await env.readAll(), [original]), `a position.${fact} conflict must leave the original receipt unchanged`);
|
|
352
|
+
}
|
|
353
|
+
})
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
name: "rejects a conflicting batch atomically",
|
|
357
|
+
run: inEnv(async (env) => {
|
|
358
|
+
const original = commit(1);
|
|
359
|
+
await env.addCommitted([original]);
|
|
360
|
+
const newCommit = commit(2);
|
|
361
|
+
const conflictingOriginal = {
|
|
362
|
+
...commit(1, [77]),
|
|
363
|
+
origin: {
|
|
364
|
+
...commit(1, [77]).origin,
|
|
365
|
+
eventId: original.origin.eventId
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
assert(!deepEqual(conflictingOriginal.messages, original.messages), "harness contract: createCommand must return distinct content per seed, or the manufactured conflict is an exact retry");
|
|
369
|
+
assert(await captureRejection(env.addCommitted([newCommit, conflictingOriginal])) !== void 0, "a batch containing a conflicting origin must reject");
|
|
370
|
+
const stored = await env.readAll();
|
|
371
|
+
assertEqual(stored.length, 1, "a rejected batch must not leave its earlier new receipt behind");
|
|
372
|
+
assert(deepEqual(stored[0], original), "a rejected batch must preserve the pre-existing receipt");
|
|
373
|
+
})
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: "retains command and commit input order",
|
|
377
|
+
run: inEnv(async (env) => {
|
|
378
|
+
const first = commit(1, [10, 11]);
|
|
379
|
+
const second = commit(2, [20, 21]);
|
|
380
|
+
await env.addCommitted([first, second]);
|
|
381
|
+
const stored = await env.readAll();
|
|
382
|
+
assert(deepEqual(stored.map(({ origin }) => origin.eventId), [first.origin.eventId, second.origin.eventId]), "commit receipts must retain input order");
|
|
383
|
+
assert(deepEqual(stored.map(({ messages }) => messages.map(({ command }) => command)), [first.messages.map(({ command }) => command), second.messages.map(({ command }) => command)]), "commands inside each receipt must retain mapper order");
|
|
384
|
+
})
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
name: "retains every position in a multi-event aggregate commit",
|
|
388
|
+
run: inEnv(async (env) => {
|
|
389
|
+
const first = commit(10);
|
|
390
|
+
const second = commit(11);
|
|
391
|
+
const commitSize = 2;
|
|
392
|
+
const aggregateVersion = 7;
|
|
393
|
+
const multiEventCommit = [{
|
|
394
|
+
...first,
|
|
395
|
+
origin: {
|
|
396
|
+
...first.origin,
|
|
397
|
+
position: {
|
|
398
|
+
aggregateVersion,
|
|
399
|
+
commitSequence: 0,
|
|
400
|
+
commitSize
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}, {
|
|
404
|
+
...second,
|
|
405
|
+
origin: {
|
|
406
|
+
...second.origin,
|
|
407
|
+
position: {
|
|
408
|
+
aggregateVersion,
|
|
409
|
+
commitSequence: 1,
|
|
410
|
+
commitSize
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}];
|
|
414
|
+
await env.addCommitted(multiEventCommit);
|
|
415
|
+
assert(deepEqual(await env.readAll(), multiEventCommit), "a multi-event commit must retain its shared version, sequence, and size for every receipt");
|
|
416
|
+
})
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
name: "retains an empty command receipt and advances the source cursor",
|
|
420
|
+
run: inEnv(async (env) => {
|
|
421
|
+
const empty = commit(1, []);
|
|
422
|
+
const next = commit(2);
|
|
423
|
+
await env.addCommitted([empty]);
|
|
424
|
+
await env.addCommitted([next]);
|
|
425
|
+
const stored = await env.readAll();
|
|
426
|
+
assertEqual(stored.length, 2, "an empty command batch must retain its source receipt");
|
|
427
|
+
assertEqual(stored[0]?.messages.length, 0, "the retained empty receipt must contain no invented command");
|
|
428
|
+
assert(deepEqual(stored.map(({ origin }) => origin.position.aggregateVersion), [1, 2]), "the source cursor must advance through the empty receipt");
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
];
|
|
432
|
+
tests.push(gatedContractTest({
|
|
433
|
+
capability: "providesRolledBackAdds",
|
|
434
|
+
satisfiedBy: harness.providesRolledBackAdds === true
|
|
435
|
+
}, {
|
|
436
|
+
name: "a rolled-back add leaves no receipt or command behind",
|
|
437
|
+
run: inEnv(async (env) => {
|
|
438
|
+
if (!env.addRolledBack) throw new Error("Contract violated: harness declared providesRolledBackAdds but the environment lacks addRolledBack");
|
|
439
|
+
await env.addRolledBack([commit(1)]);
|
|
440
|
+
assertEqual((await env.readAll()).length, 0, "a rolled-back transaction must persist no command receipt");
|
|
441
|
+
})
|
|
442
|
+
}));
|
|
443
|
+
return tests;
|
|
444
|
+
}
|
|
445
|
+
function message(commitSeed, index, command) {
|
|
446
|
+
return {
|
|
447
|
+
messageId: `process-event-${commitSeed}:command:${index}`,
|
|
448
|
+
recordedAt: "2027-04-05T06:07:08.000Z",
|
|
449
|
+
destination: "participant.commands",
|
|
450
|
+
command,
|
|
451
|
+
conversationId: "checkout-order-1",
|
|
452
|
+
causationId: `process-event-${commitSeed}`
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region src/testing/deadline-store-contract.ts
|
|
458
|
+
const at = (iso) => new Date(iso);
|
|
459
|
+
const T0 = "2026-03-01T10:00:00.000Z";
|
|
460
|
+
const T1 = "2026-03-01T10:05:00.000Z";
|
|
461
|
+
const T2 = "2026-03-01T10:10:00.000Z";
|
|
462
|
+
/**
|
|
463
|
+
* The deadline-store contract test suite: the proof that an adapter
|
|
464
|
+
* delivers the schedule/cancel/due/acknowledge semantics the port
|
|
465
|
+
* documents. Store semantics are an **adapter contract, not a kit
|
|
466
|
+
* guarantee**; this suite is how an adapter demonstrates them.
|
|
467
|
+
*
|
|
468
|
+
* Framework-agnostic: bind with
|
|
469
|
+
* `(test.skipped ? it.skip : it)(test.name, test.run)`.
|
|
470
|
+
*/
|
|
471
|
+
function createDeadlineStoreContractTests(harness) {
|
|
472
|
+
const inEnv = bindContractEnvironment(() => harness.createEnvironment());
|
|
473
|
+
const ceiling = harness.failuresToDeadLetter;
|
|
474
|
+
if (!Number.isInteger(ceiling) || ceiling < 2) throw new Error("Contract violated: failuresToDeadLetter must be an integer >= 2; observing attempts on a pending deadline needs one that survives a failure");
|
|
475
|
+
return [
|
|
476
|
+
{
|
|
477
|
+
name: "a deadline is invisible before its due time and delivered from it onward",
|
|
478
|
+
run: inEnv(async (env) => {
|
|
479
|
+
await env.run(() => env.store.schedule({
|
|
480
|
+
scope: "checkout-saga",
|
|
481
|
+
key: "order-1",
|
|
482
|
+
dueAt: at(T1),
|
|
483
|
+
payload: { kind: "payment-timeout" }
|
|
484
|
+
}));
|
|
485
|
+
assertEqual((await env.store.due(at(T0), 10)).length, 0, "a deadline must not fire early");
|
|
486
|
+
const dueExactly = await env.store.due(at(T1), 10);
|
|
487
|
+
assertEqual(dueExactly.length, 1, "a deadline is due AT its due time (dueAt <= now)");
|
|
488
|
+
const record = dueExactly[0];
|
|
489
|
+
assert(record !== void 0, "expected the due deadline");
|
|
490
|
+
assertEqual(record.scope, "checkout-saga", "scope must round-trip");
|
|
491
|
+
assertEqual(record.key, "order-1", "key must round-trip");
|
|
492
|
+
assertEqual(record.dueAt.getTime(), at(T1).getTime(), "dueAt must round-trip with millisecond fidelity");
|
|
493
|
+
assert(deepEqual(record.payload, { kind: "payment-timeout" }), "the payload must round-trip as plain data");
|
|
494
|
+
assertEqual(record.attempts, 0, "a fresh deadline has no attempts");
|
|
495
|
+
})
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
name: "due returns earliest first and respects the limit",
|
|
499
|
+
run: inEnv(async (env) => {
|
|
500
|
+
await env.run(async () => {
|
|
501
|
+
await env.store.schedule({
|
|
502
|
+
scope: "s",
|
|
503
|
+
key: "late",
|
|
504
|
+
dueAt: at(T2),
|
|
505
|
+
payload: { kind: "late" }
|
|
506
|
+
});
|
|
507
|
+
await env.store.schedule({
|
|
508
|
+
scope: "s",
|
|
509
|
+
key: "early",
|
|
510
|
+
dueAt: at(T0),
|
|
511
|
+
payload: { kind: "early" }
|
|
512
|
+
});
|
|
513
|
+
await env.store.schedule({
|
|
514
|
+
scope: "s",
|
|
515
|
+
key: "middle",
|
|
516
|
+
dueAt: at(T1),
|
|
517
|
+
payload: { kind: "middle" }
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
const firstPage = await env.store.due(at(T2), 2);
|
|
521
|
+
assert(firstPage.length >= 1 && firstPage.length <= 2, "limit must bound the page: up to limit records, at least one while deadlines are due");
|
|
522
|
+
assertEqual(firstPage[0]?.key, "early", "the earliest due deadline comes first");
|
|
523
|
+
})
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
name: "markDelivered consumes the deadline and is idempotent on unknown and repeated ids",
|
|
527
|
+
run: inEnv(async (env) => {
|
|
528
|
+
await env.run(() => env.store.schedule({
|
|
529
|
+
scope: "s",
|
|
530
|
+
key: "k",
|
|
531
|
+
dueAt: at(T0),
|
|
532
|
+
payload: { kind: "x" }
|
|
533
|
+
}));
|
|
534
|
+
const [record] = await env.store.due(at(T1), 10);
|
|
535
|
+
assert(record !== void 0, "expected a due deadline");
|
|
536
|
+
await env.store.markDelivered([record.deliveryId]);
|
|
537
|
+
await env.store.markDelivered([record.deliveryId]);
|
|
538
|
+
await env.store.markDelivered(["no-such-delivery-id"]);
|
|
539
|
+
assert(!(await env.store.due(at(T2), 10)).some((d) => d.deliveryId === record.deliveryId), "a delivered deadline must never come back");
|
|
540
|
+
})
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
name: "cancel removes exactly the addressed deadline and tolerates unknown addresses",
|
|
544
|
+
run: inEnv(async (env) => {
|
|
545
|
+
await env.run(async () => {
|
|
546
|
+
await env.store.schedule({
|
|
547
|
+
scope: "s",
|
|
548
|
+
key: "keep",
|
|
549
|
+
dueAt: at(T0),
|
|
550
|
+
payload: { kind: "keep" }
|
|
551
|
+
});
|
|
552
|
+
await env.store.schedule({
|
|
553
|
+
scope: "s",
|
|
554
|
+
key: "drop",
|
|
555
|
+
dueAt: at(T0),
|
|
556
|
+
payload: { kind: "drop" }
|
|
557
|
+
});
|
|
558
|
+
await env.store.cancel("s", "drop");
|
|
559
|
+
await env.store.cancel("s", "never-scheduled");
|
|
560
|
+
});
|
|
561
|
+
assert(deepEqual((await env.store.due(at(T1), 10)).map((d) => d.key), ["keep"]), "cancel must remove the addressed deadline and nothing else");
|
|
562
|
+
})
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
name: "addresses are isolated per scope",
|
|
566
|
+
run: inEnv(async (env) => {
|
|
567
|
+
await env.run(async () => {
|
|
568
|
+
await env.store.schedule({
|
|
569
|
+
scope: "reservation-hold",
|
|
570
|
+
key: "id-1",
|
|
571
|
+
dueAt: at(T0),
|
|
572
|
+
payload: { kind: "hold" }
|
|
573
|
+
});
|
|
574
|
+
await env.store.schedule({
|
|
575
|
+
scope: "checkout-saga",
|
|
576
|
+
key: "id-1",
|
|
577
|
+
dueAt: at(T0),
|
|
578
|
+
payload: { kind: "timeout" }
|
|
579
|
+
});
|
|
580
|
+
await env.store.cancel("reservation-hold", "id-1");
|
|
581
|
+
});
|
|
582
|
+
const due = await env.store.due(at(T1), 10);
|
|
583
|
+
assert(due.length === 1 && due[0]?.scope === "checkout-saga", "the same key under another scope is a different deadline");
|
|
584
|
+
})
|
|
585
|
+
},
|
|
586
|
+
gatedContractTest({
|
|
587
|
+
capability: "non-claiming due",
|
|
588
|
+
satisfiedBy: !harness.claimsOnDue
|
|
589
|
+
}, {
|
|
590
|
+
name: "schedule on an occupied address replaces it, and a stale ack cannot consume the successor",
|
|
591
|
+
run: inEnv(async (env) => {
|
|
592
|
+
await env.run(() => env.store.schedule({
|
|
593
|
+
scope: "s",
|
|
594
|
+
key: "k",
|
|
595
|
+
dueAt: at(T0),
|
|
596
|
+
payload: {
|
|
597
|
+
kind: "first",
|
|
598
|
+
step: 1
|
|
599
|
+
}
|
|
600
|
+
}));
|
|
601
|
+
const [first] = await env.store.due(at(T1), 10);
|
|
602
|
+
assert(first !== void 0, "expected the first incarnation");
|
|
603
|
+
await env.run(() => env.store.schedule({
|
|
604
|
+
scope: "s",
|
|
605
|
+
key: "k",
|
|
606
|
+
dueAt: at(T1),
|
|
607
|
+
payload: {
|
|
608
|
+
kind: "second",
|
|
609
|
+
step: 2
|
|
610
|
+
}
|
|
611
|
+
}));
|
|
612
|
+
await env.store.markDelivered([first.deliveryId]);
|
|
613
|
+
const due = await env.store.due(at(T2), 10);
|
|
614
|
+
assertEqual(due.length, 1, "exactly one pending deadline exists per address");
|
|
615
|
+
const successor = due[0];
|
|
616
|
+
assert(successor !== void 0, "expected the successor");
|
|
617
|
+
assert(deepEqual(successor.payload, {
|
|
618
|
+
kind: "second",
|
|
619
|
+
step: 2
|
|
620
|
+
}), "the successor carries the rescheduled payload");
|
|
621
|
+
assert(successor.deliveryId !== first.deliveryId, "a reschedule is a fresh incarnation with a fresh deliveryId");
|
|
622
|
+
})
|
|
623
|
+
}),
|
|
624
|
+
{
|
|
625
|
+
name: "after delivery the address is free again for a fresh schedule",
|
|
626
|
+
run: inEnv(async (env) => {
|
|
627
|
+
await env.run(() => env.store.schedule({
|
|
628
|
+
scope: "s",
|
|
629
|
+
key: "k",
|
|
630
|
+
dueAt: at(T0),
|
|
631
|
+
payload: { kind: "first" }
|
|
632
|
+
}));
|
|
633
|
+
const [first] = await env.store.due(at(T1), 10);
|
|
634
|
+
assert(first !== void 0, "expected a due deadline");
|
|
635
|
+
await env.store.markDelivered([first.deliveryId]);
|
|
636
|
+
await env.run(() => env.store.schedule({
|
|
637
|
+
scope: "s",
|
|
638
|
+
key: "k",
|
|
639
|
+
dueAt: at(T1),
|
|
640
|
+
payload: { kind: "again" }
|
|
641
|
+
}));
|
|
642
|
+
const due = await env.store.due(at(T2), 10);
|
|
643
|
+
assert(due.length === 1 && deepEqual(due[0]?.payload, { kind: "again" }), "a consumed address must accept a new deadline");
|
|
644
|
+
})
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
name: "the attempt ceiling dead-letters the deadline, visible in deadLetters with its attempt count",
|
|
648
|
+
run: inEnv(async (env) => {
|
|
649
|
+
await env.run(() => env.store.schedule({
|
|
650
|
+
scope: "s",
|
|
651
|
+
key: "poison",
|
|
652
|
+
dueAt: at(T0),
|
|
653
|
+
payload: { kind: "poison" }
|
|
654
|
+
}));
|
|
655
|
+
const [poison] = await env.store.due(at(T1), 1);
|
|
656
|
+
assert(poison !== void 0, "expected the due deadline");
|
|
657
|
+
let transition;
|
|
658
|
+
for (let i = 0; i < ceiling; i++) {
|
|
659
|
+
const current = await env.store.markFailed(poison.deliveryId, /* @__PURE__ */ new Error("boom"));
|
|
660
|
+
if (i < ceiling - 1) assertEqual(current, void 0, "markFailed must not report a dead-letter transition below the ceiling");
|
|
661
|
+
transition = current;
|
|
662
|
+
}
|
|
663
|
+
assertEqual(transition?.deliveryId, poison.deliveryId, "the ceiling-crossing markFailed call must return the exact dead-letter transition");
|
|
664
|
+
assertEqual(transition?.attempts, ceiling, "the returned transition must carry the final attempt count");
|
|
665
|
+
assertEqual(await env.store.markFailed(poison.deliveryId, /* @__PURE__ */ new Error("late")), void 0, "a late failure report must not repeat the dead-letter transition");
|
|
666
|
+
assert(!(await env.store.due(at(T2), 10)).some((d) => d.deliveryId === poison.deliveryId), "a dead-lettered deadline must stop coming back");
|
|
667
|
+
const dead = await env.store.deadLetters();
|
|
668
|
+
assert(dead.length === 1 && dead[0]?.attempts === ceiling, "the dead-lettered deadline must appear in deadLetters() with its attempt count");
|
|
669
|
+
})
|
|
670
|
+
},
|
|
671
|
+
gatedContractTest({
|
|
672
|
+
capability: "non-claiming due",
|
|
673
|
+
satisfiedBy: !harness.claimsOnDue
|
|
674
|
+
}, {
|
|
675
|
+
name: "attempts surface on redelivery, and a poison deadline does not block its neighbors",
|
|
676
|
+
run: inEnv(async (env) => {
|
|
677
|
+
await env.run(async () => {
|
|
678
|
+
await env.store.schedule({
|
|
679
|
+
scope: "s",
|
|
680
|
+
key: "poison",
|
|
681
|
+
dueAt: at(T0),
|
|
682
|
+
payload: { kind: "poison" }
|
|
683
|
+
});
|
|
684
|
+
await env.store.schedule({
|
|
685
|
+
scope: "s",
|
|
686
|
+
key: "healthy",
|
|
687
|
+
dueAt: at(T0),
|
|
688
|
+
payload: { kind: "healthy" }
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
const [poison] = await env.store.due(at(T1), 1);
|
|
692
|
+
assert(poison !== void 0, "expected the earliest due deadline");
|
|
693
|
+
await env.store.markFailed(poison.deliveryId, /* @__PURE__ */ new Error("boom"));
|
|
694
|
+
assertEqual((await env.store.due(at(T1), 10)).find((d) => d.deliveryId === poison.deliveryId)?.attempts, 1, "attempts must be surfaced on the record after markFailed");
|
|
695
|
+
for (let i = 1; i < ceiling; i++) await env.store.markFailed(poison.deliveryId, /* @__PURE__ */ new Error("boom"));
|
|
696
|
+
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
|
+
})
|
|
698
|
+
}),
|
|
699
|
+
{
|
|
700
|
+
name: "two dead-lettered incarnations of one address are both kept and individually clearable",
|
|
701
|
+
run: inEnv(async (env) => {
|
|
702
|
+
await env.run(() => env.store.schedule({
|
|
703
|
+
scope: "s",
|
|
704
|
+
key: "k",
|
|
705
|
+
dueAt: at(T0),
|
|
706
|
+
payload: { kind: "first" }
|
|
707
|
+
}));
|
|
708
|
+
const [first] = await env.store.due(at(T1), 10);
|
|
709
|
+
assert(first !== void 0, "expected the first incarnation");
|
|
710
|
+
for (let i = 0; i < ceiling; i++) await env.store.markFailed(first.deliveryId, /* @__PURE__ */ new Error("boom"));
|
|
711
|
+
await env.run(() => env.store.schedule({
|
|
712
|
+
scope: "s",
|
|
713
|
+
key: "k",
|
|
714
|
+
dueAt: at(T1),
|
|
715
|
+
payload: { kind: "second" }
|
|
716
|
+
}));
|
|
717
|
+
const [second] = await env.store.due(at(T2), 10);
|
|
718
|
+
assert(second !== void 0, "expected the second incarnation");
|
|
719
|
+
for (let i = 0; i < ceiling; i++) await env.store.markFailed(second.deliveryId, /* @__PURE__ */ new Error("boom"));
|
|
720
|
+
const dead = await env.store.deadLetters();
|
|
721
|
+
assert(dead.length === 2 && dead.some((d) => d.deliveryId === first.deliveryId) && dead.some((d) => d.deliveryId === second.deliveryId), "dead letters are kept per incarnation; a later dead letter of the same address must not overwrite an earlier un-acked one");
|
|
722
|
+
await env.store.markDelivered([first.deliveryId]);
|
|
723
|
+
const remaining = await env.store.deadLetters();
|
|
724
|
+
assert(remaining.length === 1 && remaining[0]?.deliveryId === second.deliveryId, "acknowledging one dead-lettered incarnation must not clear its sibling");
|
|
725
|
+
})
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
name: "late failure reports never resurrect or advance anything, and acking a dead letter clears it",
|
|
729
|
+
run: inEnv(async (env) => {
|
|
730
|
+
await env.run(() => env.store.schedule({
|
|
731
|
+
scope: "s",
|
|
732
|
+
key: "k",
|
|
733
|
+
dueAt: at(T0),
|
|
734
|
+
payload: { kind: "x" }
|
|
735
|
+
}));
|
|
736
|
+
const [record] = await env.store.due(at(T1), 10);
|
|
737
|
+
assert(record !== void 0, "expected a due deadline");
|
|
738
|
+
for (let i = 0; i < ceiling; i++) await env.store.markFailed(record.deliveryId, /* @__PURE__ */ new Error("boom"));
|
|
739
|
+
await env.store.markFailed(record.deliveryId, /* @__PURE__ */ new Error("late"));
|
|
740
|
+
await env.store.markFailed("no-such-id", /* @__PURE__ */ new Error("unknown"));
|
|
741
|
+
assertEqual((await env.store.deadLetters()).length, 1, "late or unknown failure reports must not change the dead-letter set");
|
|
742
|
+
await env.store.markDelivered([record.deliveryId]);
|
|
743
|
+
assertEqual((await env.store.deadLetters()).length, 0, "acking a dead-lettered deadline must clear it");
|
|
744
|
+
})
|
|
745
|
+
},
|
|
746
|
+
gatedContractTest({
|
|
747
|
+
capability: "providesRolledBackRuns",
|
|
748
|
+
satisfiedBy: harness.providesRolledBackRuns === true
|
|
749
|
+
}, {
|
|
750
|
+
name: "a rolled-back schedule leaves no deadline behind",
|
|
751
|
+
run: inEnv(async (env) => {
|
|
752
|
+
if (!env.runRolledBack) throw new Error("Contract violated: harness declared providesRolledBackRuns but the environment lacks runRolledBack");
|
|
753
|
+
await env.runRolledBack(() => env.store.schedule({
|
|
754
|
+
scope: "s",
|
|
755
|
+
key: "ghost",
|
|
756
|
+
dueAt: at(T0),
|
|
757
|
+
payload: { kind: "ghost" }
|
|
758
|
+
})).catch(() => {});
|
|
759
|
+
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");
|
|
760
|
+
})
|
|
761
|
+
}),
|
|
762
|
+
gatedContractTest({
|
|
763
|
+
capability: "providesRolledBackRuns",
|
|
764
|
+
satisfiedBy: harness.providesRolledBackRuns === true
|
|
765
|
+
}, {
|
|
766
|
+
name: "a rolled-back cancel leaves the deadline in place",
|
|
767
|
+
run: inEnv(async (env) => {
|
|
768
|
+
if (!env.runRolledBack) throw new Error("Contract violated: harness declared providesRolledBackRuns but the environment lacks runRolledBack");
|
|
769
|
+
await env.run(() => env.store.schedule({
|
|
770
|
+
scope: "s",
|
|
771
|
+
key: "k",
|
|
772
|
+
dueAt: at(T0),
|
|
773
|
+
payload: { kind: "x" }
|
|
774
|
+
}));
|
|
775
|
+
await env.runRolledBack(() => env.store.cancel("s", "k")).catch(() => {});
|
|
776
|
+
assertEqual((await env.store.due(at(T1), 10)).length, 1, "a cancel from a rolled-back transaction must not have removed the deadline");
|
|
777
|
+
})
|
|
778
|
+
})
|
|
779
|
+
];
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
//#endregion
|
|
783
|
+
//#region src/testing/es-repository-contract.ts
|
|
784
|
+
/**
|
|
785
|
+
* Contract suite for event-stream adapters using v3 Unit-of-Work receipts.
|
|
786
|
+
*
|
|
787
|
+
* `add` and `update` register intent only. At commit, the adapter appends the
|
|
788
|
+
* receipt's exact event batch with the Unit of Work's expected version, in the
|
|
789
|
+
* same transaction as the outbox. `run` must support overlapping calls so the
|
|
790
|
+
* mandatory stale-writer proof exercises a real stream OCC predicate.
|
|
791
|
+
*/
|
|
483
792
|
function createEsRepositoryContractTests(harness) {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
793
|
+
const inEnvironment = bindContractEnvironment(() => harness.createEnvironment());
|
|
794
|
+
const readAll = { limit: 100 };
|
|
795
|
+
const createAggregateWithId = harness.createAggregateWithId;
|
|
796
|
+
const snapshotState = harness.snapshotState;
|
|
797
|
+
const load = (repository, id) => loadAggregateOrFail(repository, id, "the stream was not appended or replayed correctly");
|
|
798
|
+
const streamFor = (id) => harness.streamKeyFor(id);
|
|
799
|
+
const recordedIds = (events) => mintedPendingEventIds(events, "pending events must be recorded before flush");
|
|
800
|
+
const ids = (events) => events.map((event) => {
|
|
801
|
+
assert(typeof event.eventId === "string" && event.eventId.length > 0, "committed events must carry their persisted eventId");
|
|
802
|
+
return event.eventId;
|
|
803
|
+
});
|
|
804
|
+
const outboxIds = (events) => sortedCommittedEventIds(events);
|
|
805
|
+
async function seed(environment) {
|
|
806
|
+
const aggregate = harness.createAggregate();
|
|
807
|
+
await environment.run(async ({ repository }) => {
|
|
808
|
+
repository.add(aggregate);
|
|
809
|
+
});
|
|
810
|
+
return aggregate;
|
|
811
|
+
}
|
|
812
|
+
const tests = [
|
|
813
|
+
{
|
|
814
|
+
name: "add appends the exact creation batch to stream and outbox",
|
|
815
|
+
run: inEnvironment(async (environment) => {
|
|
816
|
+
const aggregate = harness.createAggregate();
|
|
817
|
+
const expectedIds = recordedIds(aggregate.pendingEvents);
|
|
818
|
+
assertEqual(expectedIds.length, 1, "createAggregate must record exactly one creation event");
|
|
819
|
+
await environment.run(async ({ repository }) => {
|
|
820
|
+
repository.add(aggregate);
|
|
821
|
+
});
|
|
822
|
+
const stream = await environment.committedStreamEvents(streamFor(aggregate.id), readAll);
|
|
823
|
+
assert(stream.exists && deepEqual(ids(stream.events), expectedIds), "the stream must contain exactly the registered creation batch");
|
|
824
|
+
assert(deepEqual(outboxIds(await environment.committedOutboxEvents()), [...expectedIds].sort()), "the outbox must contain the same exact creation batch");
|
|
825
|
+
assertEqual(aggregate.pendingEvents.length, 0, "successful commit must acknowledge the creation batch");
|
|
826
|
+
})
|
|
827
|
+
},
|
|
828
|
+
{
|
|
829
|
+
name: "MANDATORY stale append: writer B conflicts after writer A commits and appends no prefix",
|
|
830
|
+
run: inEnvironment(async (environment) => {
|
|
831
|
+
const seeded = await seed(environment);
|
|
832
|
+
let loaded;
|
|
833
|
+
const bLoaded = new Promise((resolve) => {
|
|
834
|
+
loaded = resolve;
|
|
835
|
+
});
|
|
836
|
+
let release;
|
|
837
|
+
const mayAppend = new Promise((resolve) => {
|
|
838
|
+
release = resolve;
|
|
839
|
+
});
|
|
840
|
+
const writerB = environment.run(async ({ repository }) => {
|
|
841
|
+
const stale = await load(repository, seeded.id);
|
|
842
|
+
loaded();
|
|
843
|
+
await mayAppend;
|
|
844
|
+
harness.mutate(stale);
|
|
845
|
+
harness.mutate(stale);
|
|
846
|
+
repository.update(stale);
|
|
847
|
+
});
|
|
848
|
+
await bLoaded;
|
|
849
|
+
const winner = await environment.run(async ({ repository }) => {
|
|
850
|
+
const current = await load(repository, seeded.id);
|
|
851
|
+
harness.mutate(current);
|
|
852
|
+
repository.update(current);
|
|
853
|
+
return current;
|
|
854
|
+
});
|
|
855
|
+
const streamAfterWinner = await environment.committedStreamEvents(streamFor(seeded.id), readAll);
|
|
856
|
+
release();
|
|
857
|
+
const rejection = await captureRejection(writerB);
|
|
858
|
+
assertChainContainsKitError(rejection, ["CONCURRENCY_CONFLICT"], `stale append must reject with ConcurrencyConflictError; got ${describeError(rejection)}`);
|
|
859
|
+
const finalStream = await environment.committedStreamEvents(streamFor(seeded.id), readAll);
|
|
860
|
+
assert(finalStream.exists && deepEqual(ids(finalStream.events), ids(streamAfterWinner.events)), "a rejected multi-event append must leave no prefix in the stream");
|
|
861
|
+
const reloaded = await environment.run(({ repository }) => load(repository, seeded.id));
|
|
862
|
+
assertEqual(reloaded.version, winner.version, "replay must end at the winning stream version");
|
|
863
|
+
if (snapshotState) assert(deepEqual(snapshotState.call(harness, reloaded), snapshotState.call(harness, winner)), "replay must fold to writer A's state");
|
|
864
|
+
})
|
|
865
|
+
},
|
|
866
|
+
{
|
|
867
|
+
name: "replay preserves emission order and returns no pending events",
|
|
868
|
+
run: inEnvironment(async (environment) => {
|
|
869
|
+
const aggregate = harness.createAggregate();
|
|
870
|
+
harness.mutate(aggregate);
|
|
871
|
+
harness.mutate(aggregate);
|
|
872
|
+
const expectedIds = recordedIds(aggregate.pendingEvents);
|
|
873
|
+
await environment.run(async ({ repository }) => {
|
|
874
|
+
repository.add(aggregate);
|
|
875
|
+
});
|
|
876
|
+
const reloaded = await environment.run(({ repository }) => load(repository, aggregate.id));
|
|
877
|
+
assertEqual(reloaded.version, expectedIds.length, "event-sourced version must equal the folded event count");
|
|
878
|
+
assertEqual(reloaded.pendingEvents.length, 0, "replay must not re-record historical events");
|
|
879
|
+
if (snapshotState) assert(deepEqual(snapshotState.call(harness, reloaded), snapshotState.call(harness, aggregate)), "replay must fold to the same state in emission order");
|
|
880
|
+
const stream = await environment.committedStreamEvents(streamFor(aggregate.id), readAll);
|
|
881
|
+
assert(stream.exists && deepEqual(ids(stream.events), expectedIds), "the committed stream must preserve emission order");
|
|
882
|
+
})
|
|
883
|
+
},
|
|
884
|
+
{
|
|
885
|
+
name: "rollback leaves stream and outbox absent and acknowledges nothing",
|
|
886
|
+
run: inEnvironment(async (environment) => {
|
|
887
|
+
const aggregate = harness.createAggregate();
|
|
888
|
+
harness.mutate(aggregate);
|
|
889
|
+
const pending = [...aggregate.pendingEvents];
|
|
890
|
+
await captureRejection(environment.run(async ({ repository }) => {
|
|
891
|
+
repository.add(aggregate);
|
|
892
|
+
throw new Error("rollback probe");
|
|
893
|
+
}));
|
|
894
|
+
assert(!(await environment.committedStreamEvents(streamFor(aggregate.id), readAll)).exists, "rollback must leave the stream absent");
|
|
895
|
+
assertEqual((await environment.committedOutboxEvents()).length, 0, "rollback must leave the outbox empty");
|
|
896
|
+
assert(deepEqual(aggregate.pendingEvents, pending), "rollback must retain the exact pending batch");
|
|
897
|
+
})
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
name: "retrying the same never-persisted instance after rollback creates the full stream",
|
|
901
|
+
run: inEnvironment(async (environment) => {
|
|
902
|
+
const aggregate = harness.createAggregate();
|
|
903
|
+
harness.mutate(aggregate);
|
|
904
|
+
const expectedIds = recordedIds(aggregate.pendingEvents);
|
|
905
|
+
await captureRejection(environment.run(async ({ repository }) => {
|
|
906
|
+
repository.add(aggregate);
|
|
907
|
+
throw new Error("rollback probe");
|
|
908
|
+
}));
|
|
909
|
+
await environment.run(async ({ repository }) => {
|
|
910
|
+
repository.add(aggregate);
|
|
911
|
+
});
|
|
912
|
+
const stream = await environment.committedStreamEvents(streamFor(aggregate.id), readAll);
|
|
913
|
+
assert(stream.exists && deepEqual(ids(stream.events), expectedIds), "the retried add must create the stream with the full pending history");
|
|
914
|
+
assertEqual(aggregate.pendingEvents.length, 0, "the successful retry must acknowledge the whole batch");
|
|
915
|
+
})
|
|
916
|
+
},
|
|
917
|
+
{
|
|
918
|
+
name: "outbox failure rolls the already-appended stream batch back",
|
|
919
|
+
run: inEnvironment(async (environment) => {
|
|
920
|
+
const aggregate = harness.createAggregate();
|
|
921
|
+
const pending = [...aggregate.pendingEvents];
|
|
922
|
+
environment.failNextOutboxWrite(/* @__PURE__ */ new Error("outbox failure probe"));
|
|
923
|
+
assert(await captureRejection(environment.run(async ({ repository }) => {
|
|
924
|
+
repository.add(aggregate);
|
|
925
|
+
})) !== void 0, "the outbox failure must reject");
|
|
926
|
+
assert(!(await environment.committedStreamEvents(streamFor(aggregate.id), readAll)).exists, "stream append must roll back with the outbox");
|
|
927
|
+
assertEqual((await environment.committedOutboxEvents()).length, 0, "failed outbox write must commit no envelope");
|
|
928
|
+
assert(deepEqual(aggregate.pendingEvents, pending), "failed commit must acknowledge none of the event batch");
|
|
929
|
+
})
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
name: "read windows preserve absence, actual head, and point-in-time bounds",
|
|
933
|
+
run: inEnvironment(async (environment) => {
|
|
934
|
+
const missingAggregate = harness.createAggregate();
|
|
935
|
+
const missing = await environment.committedStreamEvents(streamFor(missingAggregate.id), readAll);
|
|
936
|
+
assert(!missing.exists && missing.lastVersion === 0, "a missing stream must report exists=false and head 0");
|
|
937
|
+
const aggregate = harness.createAggregate();
|
|
938
|
+
harness.mutate(aggregate);
|
|
939
|
+
harness.mutate(aggregate);
|
|
940
|
+
await environment.run(async ({ repository }) => {
|
|
941
|
+
repository.add(aggregate);
|
|
942
|
+
});
|
|
943
|
+
const afterOne = await environment.committedStreamEvents(streamFor(aggregate.id), {
|
|
944
|
+
limit: 100,
|
|
945
|
+
fromVersion: 1
|
|
946
|
+
});
|
|
947
|
+
const asOfTwo = await environment.committedStreamEvents(streamFor(aggregate.id), {
|
|
948
|
+
limit: 100,
|
|
949
|
+
toVersion: 2
|
|
950
|
+
});
|
|
951
|
+
assert(afterOne.exists && afterOne.lastVersion === 3 && afterOne.events.length === 2, "fromVersion is exclusive and preserves the actual stream head");
|
|
952
|
+
assert(asOfTwo.exists && asOfTwo.lastVersion === 3 && asOfTwo.events.length === 2, "toVersion is inclusive and preserves the actual stream head");
|
|
953
|
+
})
|
|
954
|
+
},
|
|
955
|
+
{
|
|
956
|
+
name: "identity map returns one replayed instance per Unit of Work",
|
|
957
|
+
run: inEnvironment(async (environment) => {
|
|
958
|
+
const seeded = await seed(environment);
|
|
959
|
+
await environment.run(async ({ repository }) => {
|
|
960
|
+
const first = await repository.findById(seeded.id);
|
|
961
|
+
const second = await repository.findById(seeded.id);
|
|
962
|
+
assert(first !== void 0 && first === second, "repeated stream loads must return the same tracked instance");
|
|
963
|
+
});
|
|
964
|
+
})
|
|
965
|
+
}
|
|
966
|
+
];
|
|
967
|
+
tests.push(gatedContractTest({
|
|
968
|
+
capability: "createAggregateWithId",
|
|
969
|
+
satisfiedBy: Boolean(createAggregateWithId)
|
|
970
|
+
}, {
|
|
971
|
+
name: "duplicate add conflicts and leaves the existing stream untouched",
|
|
972
|
+
run: inEnvironment(async (environment) => {
|
|
973
|
+
assert(createAggregateWithId !== void 0, "capability gate");
|
|
974
|
+
const seeded = await seed(environment);
|
|
975
|
+
const before = await environment.committedStreamEvents(streamFor(seeded.id), readAll);
|
|
976
|
+
const duplicate = createAggregateWithId.call(harness, seeded.id);
|
|
977
|
+
const rejection = await captureRejection(environment.run(async ({ repository }) => {
|
|
978
|
+
repository.add(duplicate);
|
|
979
|
+
}));
|
|
980
|
+
assertChainContainsKitError(rejection, ["CONCURRENCY_CONFLICT", "DUPLICATE_AGGREGATE"], `duplicate stream creation must reject with a mapped kit error; got ${describeError(rejection)}`);
|
|
981
|
+
const after = await environment.committedStreamEvents(streamFor(seeded.id), readAll);
|
|
982
|
+
assert(deepEqual(ids(after.events), ids(before.events)), "duplicate add must not modify the existing stream");
|
|
983
|
+
})
|
|
984
|
+
}));
|
|
985
|
+
return tests;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
//#endregion
|
|
989
|
+
//#region src/testing/event-store-contract.ts
|
|
990
|
+
/**
|
|
991
|
+
* Reusable proof of an EventStore adapter's portable semantics: qualified
|
|
992
|
+
* value identity, ordered reads and slicing, OCC error mapping and atomicity,
|
|
993
|
+
* no-op empty appends, and detached return arrays. Physical-position
|
|
994
|
+
* corruption needs adapter-specific fixture support and is tested there.
|
|
995
|
+
*/
|
|
996
|
+
function createEventStoreContractTests(harness) {
|
|
997
|
+
const inEnv = bindContractEnvironment(() => harness.createEnvironment());
|
|
998
|
+
const fixtureRead = { limit: 100 };
|
|
999
|
+
const hasSameEventIds = (actual, expected) => actual.length === expected.length && actual.every((event, index) => event.eventId === expected[index]?.eventId);
|
|
1000
|
+
return [
|
|
1001
|
+
{
|
|
1002
|
+
name: "unknown stream: read reports explicit absence at version zero",
|
|
1003
|
+
run: inEnv(async ({ store }) => {
|
|
1004
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1005
|
+
const missing = await store.readStream({ ...firstKey }, fixtureRead);
|
|
1006
|
+
assert(!missing.exists && missing.lastVersion === 0 && missing.events.length === 0, "an unknown qualified stream must return the explicit missing state");
|
|
1007
|
+
})
|
|
1008
|
+
},
|
|
1009
|
+
{
|
|
1010
|
+
name: "empty append: no version check and no stream creation",
|
|
1011
|
+
run: inEnv(async ({ store }) => {
|
|
1012
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1013
|
+
await store.append(firstKey, [], { expectedVersion: 999 });
|
|
1014
|
+
const event = harness.createEvent(firstKey, 1);
|
|
1015
|
+
await store.append({ ...firstKey }, [event], { expectedVersion: 0 });
|
|
1016
|
+
const stored = await store.readStream(firstKey, fixtureRead);
|
|
1017
|
+
assert(stored.exists && stored.lastVersion === 1 && stored.events.length === 1 && stored.events[0]?.eventId === event.eventId, "an empty append must not check OCC or create an empty stream");
|
|
1018
|
+
})
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
name: "qualified stream key: equal aggregate ids remain isolated by aggregate type",
|
|
1022
|
+
run: inEnv(async ({ store }) => {
|
|
1023
|
+
const [firstKey, secondKey] = harness.createCollidingStreamKeys();
|
|
1024
|
+
assert(firstKey.aggregateId === secondKey.aggregateId, "createCollidingStreamKeys must return equal raw aggregate ids");
|
|
1025
|
+
assert(firstKey.aggregateType !== secondKey.aggregateType, "createCollidingStreamKeys must return different aggregate types");
|
|
1026
|
+
const firstEvent = harness.createEvent(firstKey, 1);
|
|
1027
|
+
const secondEvent = harness.createEvent(secondKey, 2);
|
|
1028
|
+
assert(firstEvent.eventId !== secondEvent.eventId, "createEvent must produce different event ids for different sequence values");
|
|
1029
|
+
await store.append(firstKey, [firstEvent], { expectedVersion: 0 });
|
|
1030
|
+
await store.append(secondKey, [secondEvent], { expectedVersion: 0 });
|
|
1031
|
+
const firstStream = await store.readStream({ ...firstKey }, fixtureRead);
|
|
1032
|
+
const secondStream = await store.readStream({ ...secondKey }, fixtureRead);
|
|
1033
|
+
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 are value addresses, not identity tokens");
|
|
1034
|
+
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");
|
|
1035
|
+
})
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
name: "append/read: event order and fromVersion slicing are preserved",
|
|
1039
|
+
run: inEnv(async ({ store }) => {
|
|
1040
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1041
|
+
const events = [
|
|
1042
|
+
1,
|
|
1043
|
+
2,
|
|
1044
|
+
3
|
|
1045
|
+
].map((sequence) => harness.createEvent(firstKey, sequence));
|
|
1046
|
+
await store.append(firstKey, events, { expectedVersion: 0 });
|
|
1047
|
+
const whole = await store.readStream({ ...firstKey }, fixtureRead);
|
|
1048
|
+
const afterTwo = await store.readStream({ ...firstKey }, {
|
|
1049
|
+
...fixtureRead,
|
|
1050
|
+
fromVersion: 2
|
|
1051
|
+
});
|
|
1052
|
+
assert(whole.exists && whole.lastVersion === 3 && hasSameEventIds(whole.events, events), "reads must preserve append order");
|
|
1053
|
+
assert(afterTwo.exists && afterTwo.lastVersion === 3 && afterTwo.events.length === 1 && afterTwo.events[0]?.eventId === events[2]?.eventId, "fromVersion 2 must return exactly the events after the first two positions");
|
|
1054
|
+
})
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
name: "paged read: limit bounds every page and fromVersion continues without gaps or duplicates",
|
|
1058
|
+
run: inEnv(async ({ store }) => {
|
|
1059
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1060
|
+
const events = [
|
|
1061
|
+
1,
|
|
1062
|
+
2,
|
|
1063
|
+
3,
|
|
1064
|
+
4,
|
|
1065
|
+
5
|
|
1066
|
+
].map((sequence) => harness.createEvent(firstKey, sequence));
|
|
1067
|
+
await store.append(firstKey, events, { expectedVersion: 0 });
|
|
1068
|
+
const collected = [];
|
|
1069
|
+
let cursor = 0;
|
|
1070
|
+
let targetHead;
|
|
1071
|
+
for (let attempt = 0; attempt < events.length; attempt += 1) {
|
|
1072
|
+
const page = await store.readStream({ ...firstKey }, {
|
|
1073
|
+
fromVersion: cursor,
|
|
1074
|
+
limit: 2,
|
|
1075
|
+
...targetHead === void 0 ? {} : { toVersion: targetHead }
|
|
1076
|
+
});
|
|
1077
|
+
assert(page.exists, "a paged read of an existing stream must retain existence");
|
|
1078
|
+
targetHead ??= page.lastVersion;
|
|
1079
|
+
assert(page.lastVersion >= targetHead, "lastVersion must keep reporting at least the head pinned by the first page");
|
|
1080
|
+
assert(page.events.length > 0 && page.events.length <= 2, "an unread page must make progress without exceeding the requested limit");
|
|
1081
|
+
collected.push(...page.events);
|
|
1082
|
+
cursor += page.events.length;
|
|
1083
|
+
if (cursor >= targetHead) break;
|
|
1084
|
+
}
|
|
1085
|
+
assert(targetHead === events.length && cursor === targetHead, "following fromVersion by each actual page length must reach the pinned head");
|
|
1086
|
+
assert(hasSameEventIds(collected, events), "paged continuation must reproduce append order without gaps or duplicates");
|
|
1087
|
+
const atEnd = await store.readStream({ ...firstKey }, {
|
|
1088
|
+
fromVersion: cursor,
|
|
1089
|
+
toVersion: targetHead,
|
|
1090
|
+
limit: 2
|
|
1091
|
+
});
|
|
1092
|
+
assert(atEnd.exists && atEnd.events.length === 0, "continuing at the pinned head must return an existing empty page");
|
|
1093
|
+
})
|
|
1094
|
+
},
|
|
1095
|
+
{
|
|
1096
|
+
name: "read options: invalid limits and stream positions fail loudly",
|
|
1097
|
+
run: inEnv(async ({ store }) => {
|
|
1098
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1099
|
+
const invalidOptions = [
|
|
1100
|
+
{},
|
|
1101
|
+
{ limit: 0 },
|
|
1102
|
+
{ limit: 1.5 },
|
|
1103
|
+
{ limit: Number.MAX_SAFE_INTEGER + 1 },
|
|
1104
|
+
{
|
|
1105
|
+
limit: 1,
|
|
1106
|
+
fromVersion: -1
|
|
1107
|
+
},
|
|
1108
|
+
{
|
|
1109
|
+
limit: 1,
|
|
1110
|
+
fromVersion: 1.5
|
|
1111
|
+
},
|
|
1112
|
+
{
|
|
1113
|
+
limit: 1,
|
|
1114
|
+
fromVersion: Number.MAX_SAFE_INTEGER + 1
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
limit: 1,
|
|
1118
|
+
toVersion: -1
|
|
1119
|
+
},
|
|
1120
|
+
{
|
|
1121
|
+
limit: 1,
|
|
1122
|
+
toVersion: 1.5
|
|
1123
|
+
},
|
|
1124
|
+
{
|
|
1125
|
+
limit: 1,
|
|
1126
|
+
toVersion: Number.MAX_SAFE_INTEGER + 1
|
|
1127
|
+
}
|
|
1128
|
+
];
|
|
1129
|
+
for (const options of invalidOptions) assert(await captureRejection(store.readStream(firstKey, options)) instanceof RangeError, "invalid read bounds must reject with RangeError before querying the stream");
|
|
1130
|
+
})
|
|
1131
|
+
},
|
|
1132
|
+
{
|
|
1133
|
+
name: "bounded read: toVersion is inclusive while lastVersion remains the actual head",
|
|
1134
|
+
run: inEnv(async ({ store }) => {
|
|
1135
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1136
|
+
const events = [
|
|
1137
|
+
1,
|
|
1138
|
+
2,
|
|
1139
|
+
3,
|
|
1140
|
+
4
|
|
1141
|
+
].map((sequence) => harness.createEvent(firstKey, sequence));
|
|
1142
|
+
await store.append(firstKey, events, { expectedVersion: 0 });
|
|
1143
|
+
const bounded = await store.readStream({ ...firstKey }, {
|
|
1144
|
+
...fixtureRead,
|
|
1145
|
+
fromVersion: 1,
|
|
1146
|
+
toVersion: 3
|
|
1147
|
+
});
|
|
1148
|
+
assert(bounded.exists && bounded.lastVersion === 4 && hasSameEventIds(bounded.events, events.slice(1, 3)), "(fromVersion, toVersion] must include positions 2 and 3 while lastVersion reports the actual head at 4");
|
|
1149
|
+
})
|
|
1150
|
+
},
|
|
1151
|
+
{
|
|
1152
|
+
name: "bounded read edges: zero, beyond-head, and inverted ranges are empty or clamped",
|
|
1153
|
+
run: inEnv(async ({ store }) => {
|
|
1154
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1155
|
+
const events = [
|
|
1156
|
+
1,
|
|
1157
|
+
2,
|
|
1158
|
+
3
|
|
1159
|
+
].map((sequence) => harness.createEvent(firstKey, sequence));
|
|
1160
|
+
await store.append(firstKey, events, { expectedVersion: 0 });
|
|
1161
|
+
const atZero = await store.readStream({ ...firstKey }, {
|
|
1162
|
+
...fixtureRead,
|
|
1163
|
+
toVersion: 0
|
|
1164
|
+
});
|
|
1165
|
+
const beyondHead = await store.readStream({ ...firstKey }, {
|
|
1166
|
+
...fixtureRead,
|
|
1167
|
+
toVersion: 99
|
|
1168
|
+
});
|
|
1169
|
+
const inverted = await store.readStream({ ...firstKey }, {
|
|
1170
|
+
...fixtureRead,
|
|
1171
|
+
fromVersion: 2,
|
|
1172
|
+
toVersion: 1
|
|
1173
|
+
});
|
|
1174
|
+
const equalBounds = await store.readStream({ ...firstKey }, {
|
|
1175
|
+
...fixtureRead,
|
|
1176
|
+
fromVersion: 2,
|
|
1177
|
+
toVersion: 2
|
|
1178
|
+
});
|
|
1179
|
+
assert(atZero.exists && atZero.lastVersion === 3 && atZero.events.length === 0, "toVersion=0 must return an existing empty window with the actual head");
|
|
1180
|
+
assert(beyondHead.exists && beyondHead.lastVersion === 3 && hasSameEventIds(beyondHead.events, events), "toVersion beyond the head must clamp to the actual stream head");
|
|
1181
|
+
assert(inverted.exists && inverted.lastVersion === 3 && inverted.events.length === 0 && equalBounds.exists && equalBounds.lastVersion === 3 && equalBounds.events.length === 0, "fromVersion >= toVersion describes an empty interval and must not throw");
|
|
1182
|
+
})
|
|
1183
|
+
},
|
|
1184
|
+
{
|
|
1185
|
+
name: "read state: empty and beyond-head windows retain existence and the actual stream head",
|
|
1186
|
+
run: inEnv(async ({ store }) => {
|
|
1187
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1188
|
+
await store.append(firstKey, [harness.createEvent(firstKey, 4), harness.createEvent(firstKey, 5)], { expectedVersion: 0 });
|
|
1189
|
+
const atHead = await store.readStream({ ...firstKey }, {
|
|
1190
|
+
...fixtureRead,
|
|
1191
|
+
fromVersion: 2
|
|
1192
|
+
});
|
|
1193
|
+
const beyondHead = await store.readStream({ ...firstKey }, {
|
|
1194
|
+
...fixtureRead,
|
|
1195
|
+
fromVersion: 99
|
|
1196
|
+
});
|
|
1197
|
+
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");
|
|
1198
|
+
})
|
|
1199
|
+
},
|
|
1200
|
+
{
|
|
1201
|
+
name: "qualified fromVersion: slicing one type cannot observe a colliding raw id",
|
|
1202
|
+
run: inEnv(async ({ store }) => {
|
|
1203
|
+
const [firstKey, secondKey] = harness.createCollidingStreamKeys();
|
|
1204
|
+
const firstEvents = [
|
|
1205
|
+
10,
|
|
1206
|
+
11,
|
|
1207
|
+
12
|
|
1208
|
+
].map((sequence) => harness.createEvent(firstKey, sequence));
|
|
1209
|
+
const secondEvents = [20, 21].map((sequence) => harness.createEvent(secondKey, sequence));
|
|
1210
|
+
await store.append(firstKey, firstEvents, { expectedVersion: 0 });
|
|
1211
|
+
await store.append(secondKey, secondEvents, { expectedVersion: 0 });
|
|
1212
|
+
const firstTail = await store.readStream({ ...firstKey }, {
|
|
1213
|
+
...fixtureRead,
|
|
1214
|
+
fromVersion: 1
|
|
1215
|
+
});
|
|
1216
|
+
const secondTail = await store.readStream({ ...secondKey }, {
|
|
1217
|
+
...fixtureRead,
|
|
1218
|
+
fromVersion: 1
|
|
1219
|
+
});
|
|
1220
|
+
assert(firstTail.exists && firstTail.lastVersion === 3 && firstTail.events.length === 2 && firstTail.events[0]?.eventId === firstEvents[1]?.eventId && firstTail.events[1]?.eventId === firstEvents[2]?.eventId, "fromVersion must slice only the requested aggregate type's stream");
|
|
1221
|
+
assert(secondTail.exists && secondTail.lastVersion === 2 && secondTail.events.length === 1 && secondTail.events[0]?.eventId === secondEvents[1]?.eventId, "a colliding raw id under another type must retain its independent version window");
|
|
1222
|
+
})
|
|
1223
|
+
},
|
|
1224
|
+
{
|
|
1225
|
+
name: "OCC: a rejected multi-event append is atomic and maps to ConcurrencyConflictError",
|
|
1226
|
+
run: inEnv(async ({ store }) => {
|
|
1227
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1228
|
+
const seeded = [harness.createEvent(firstKey, 30), harness.createEvent(firstKey, 31)];
|
|
1229
|
+
await store.append(firstKey, seeded, { expectedVersion: 0 });
|
|
1230
|
+
assertChainContainsKitError(await captureRejection(store.append({ ...firstKey }, [harness.createEvent(firstKey, 32), harness.createEvent(firstKey, 33)], { expectedVersion: 1 })), ["CONCURRENCY_CONFLICT"], "a stale append must map the adapter conflict to ConcurrencyConflictError");
|
|
1231
|
+
const stored = await store.readStream(firstKey, fixtureRead);
|
|
1232
|
+
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
|
+
})
|
|
1234
|
+
},
|
|
1235
|
+
{
|
|
1236
|
+
name: "OCC: duplicate create is rejected atomically with a sanctioned kit error",
|
|
1237
|
+
run: inEnv(async ({ store }) => {
|
|
1238
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1239
|
+
const seeded = harness.createEvent(firstKey, 50);
|
|
1240
|
+
await store.append(firstKey, [seeded], { expectedVersion: 0 });
|
|
1241
|
+
assertChainContainsKitError(await captureRejection(store.append({ ...firstKey }, [harness.createEvent(firstKey, 51), harness.createEvent(firstKey, 52)], { expectedVersion: 0 })), ["CONCURRENCY_CONFLICT", "DUPLICATE_AGGREGATE"], "a duplicate create must map to ConcurrencyConflictError or the sanctioned DuplicateAggregateError");
|
|
1242
|
+
const stored = await store.readStream(firstKey, fixtureRead);
|
|
1243
|
+
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
|
+
})
|
|
1245
|
+
},
|
|
1246
|
+
{
|
|
1247
|
+
name: "OCC: an expectedVersion ahead of an unknown stream conflicts without creating it",
|
|
1248
|
+
run: inEnv(async ({ store }) => {
|
|
1249
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1250
|
+
assertChainContainsKitError(await captureRejection(store.append(firstKey, [harness.createEvent(firstKey, 60)], { expectedVersion: 3 })), ["CONCURRENCY_CONFLICT"], "an expectedVersion ahead of the stream must map to ConcurrencyConflictError");
|
|
1251
|
+
const first = harness.createEvent(firstKey, 61);
|
|
1252
|
+
await store.append({ ...firstKey }, [first], { expectedVersion: 0 });
|
|
1253
|
+
const stored = await store.readStream(firstKey, fixtureRead);
|
|
1254
|
+
assert(stored.exists && stored.lastVersion === 1 && stored.events.length === 1 && stored.events[0]?.eventId === first.eventId, "the rejected append must not leave an empty stream or partial events behind");
|
|
1255
|
+
})
|
|
1256
|
+
},
|
|
1257
|
+
{
|
|
1258
|
+
name: "read ownership: a mutation attempt cannot mutate the stream",
|
|
1259
|
+
run: inEnv(async ({ store }) => {
|
|
1260
|
+
const [firstKey] = harness.createCollidingStreamKeys();
|
|
1261
|
+
const seeded = harness.createEvent(firstKey, 40);
|
|
1262
|
+
await store.append(firstKey, [seeded], { expectedVersion: 0 });
|
|
1263
|
+
const callerOwned = (await store.readStream(firstKey, fixtureRead)).events;
|
|
1264
|
+
try {
|
|
1265
|
+
callerOwned.push(harness.createEvent(firstKey, 41));
|
|
1266
|
+
} catch {}
|
|
1267
|
+
const stored = await store.readStream({ ...firstKey }, fixtureRead);
|
|
1268
|
+
assert(stored.exists && stored.events.length === 1 && stored.events[0]?.eventId === seeded.eventId, "readStream must return an owned array, never live internal state");
|
|
1269
|
+
})
|
|
1270
|
+
}
|
|
1271
|
+
];
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
//#endregion
|
|
1275
|
+
//#region src/testing/idempotency-store-contract.ts
|
|
1276
|
+
function claimedHandle(claim, message) {
|
|
1277
|
+
assert(claim.status === "claimed", message);
|
|
1278
|
+
return claim.claim;
|
|
1279
|
+
}
|
|
1280
|
+
async function expireLease(env, claim) {
|
|
1281
|
+
if (!env.expireLease) throw new Error("Contract violated: the non-transactional family requires expireLease on the environment");
|
|
1282
|
+
await env.expireLease(claim);
|
|
1283
|
+
}
|
|
1284
|
+
async function advanceTimeTo(env, instant) {
|
|
1285
|
+
if (!env.advanceTimeTo) throw new Error("Contract violated: the non-transactional family requires advanceTimeTo on the environment");
|
|
1286
|
+
await env.advanceTimeTo(instant);
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* The idempotency-store contract test suite: the proof that an adapter
|
|
1290
|
+
* delivers the claim/renew/complete/confirm/abandon/reconcile lifecycle
|
|
1291
|
+
* `withIdempotentCommit` documents, for its declared family. Store
|
|
1292
|
+
* semantics are an **adapter contract, not a kit guarantee**; this
|
|
1293
|
+
* suite is how an adapter demonstrates them.
|
|
1294
|
+
*
|
|
1295
|
+
* Framework-agnostic: bind with
|
|
1296
|
+
* `(test.skipped ? it.skip : it)(test.name, test.run)`.
|
|
1297
|
+
*/
|
|
1298
|
+
function createIdempotencyStoreContractTests(harness) {
|
|
1299
|
+
const inEnv = bindContractEnvironment(() => harness.createEnvironment());
|
|
1300
|
+
const tests = [
|
|
1301
|
+
{
|
|
1302
|
+
name: "a fresh key is claimed; the full lifecycle replays the outcome",
|
|
1303
|
+
run: inEnv(async (env) => {
|
|
1304
|
+
const claim = claimedHandle(await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1")), "a fresh key must be claimed by this execution");
|
|
1305
|
+
await env.run((ctx) => env.store.complete(ctx, claim, { total: 42 }));
|
|
1306
|
+
await env.store.confirm(claim);
|
|
1307
|
+
const replay = await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1"));
|
|
1308
|
+
assert(replay.status === "completed", "a completed and confirmed key must replay as completed");
|
|
1309
|
+
assert(deepEqual(replay.outcome, { total: 42 }), "the replayed outcome must round-trip the stored value");
|
|
1310
|
+
})
|
|
1311
|
+
},
|
|
1312
|
+
{
|
|
1313
|
+
name: "the same key with a different fingerprint throws IdempotencyKeyReuseError",
|
|
1314
|
+
run: inEnv(async (env) => {
|
|
1315
|
+
const first = await env.run(async (ctx) => {
|
|
1316
|
+
const claim = claimedHandle(await env.store.claim(ctx, "key-1", "fp-1"), "a fresh key must be claimed");
|
|
1317
|
+
await env.store.complete(ctx, claim, "done");
|
|
1318
|
+
return claim;
|
|
1319
|
+
});
|
|
1320
|
+
await env.store.confirm(first);
|
|
1321
|
+
assertChainContainsKitError(await captureRejection(env.run((ctx) => env.store.claim(ctx, "key-1", "fp-OTHER"))), ["IDEMPOTENCY_KEY_REUSE"], "a different fingerprint must throw IdempotencyKeyReuseError, never replay another command's outcome");
|
|
1322
|
+
})
|
|
1323
|
+
},
|
|
1324
|
+
{
|
|
1325
|
+
name: "abandon never destroys a completed, confirmed outcome",
|
|
1326
|
+
run: inEnv(async (env) => {
|
|
1327
|
+
const first = await env.run(async (ctx) => {
|
|
1328
|
+
const claim = claimedHandle(await env.store.claim(ctx, "key-1", "fp-1"), "a fresh key must be claimed");
|
|
1329
|
+
await env.store.complete(ctx, claim, "done");
|
|
1330
|
+
return claim;
|
|
1331
|
+
});
|
|
1332
|
+
await env.store.confirm(first);
|
|
1333
|
+
await env.store.abandon(first);
|
|
1334
|
+
const claim = await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1"));
|
|
1335
|
+
assert(claim.status === "completed", "abandon must not release a completed, confirmed record");
|
|
1336
|
+
assertEqual(claim.outcome, "done", "the outcome survives");
|
|
1337
|
+
})
|
|
1338
|
+
},
|
|
1339
|
+
{
|
|
1340
|
+
name: "confirm is idempotent, and confirming a missing key is a no-op",
|
|
1341
|
+
run: inEnv(async (env) => {
|
|
1342
|
+
const first = await env.run(async (ctx) => {
|
|
1343
|
+
const claim = claimedHandle(await env.store.claim(ctx, "key-1", "fp-1"), "a fresh key must be claimed");
|
|
1344
|
+
await env.store.complete(ctx, claim, "done");
|
|
1345
|
+
return claim;
|
|
1346
|
+
});
|
|
1347
|
+
await env.store.confirm(first);
|
|
1348
|
+
await env.store.confirm(first);
|
|
1349
|
+
await env.store.confirm({
|
|
1350
|
+
key: "never-claimed",
|
|
1351
|
+
token: "missing"
|
|
1352
|
+
});
|
|
1353
|
+
const claim = await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1"));
|
|
1354
|
+
assert(claim.status === "completed" && claim.outcome === "done", "re-confirms and unknown-key confirms must change nothing");
|
|
1355
|
+
})
|
|
1356
|
+
},
|
|
1357
|
+
{
|
|
1358
|
+
name: "complete without a pending claim throws the wiring error",
|
|
1359
|
+
run: inEnv(async (env) => {
|
|
1360
|
+
assertChainContainsKitError(await captureRejection(env.run((ctx) => env.store.complete(ctx, {
|
|
1361
|
+
key: "key-1",
|
|
1362
|
+
token: "missing"
|
|
1363
|
+
}, "x"))), ["IDEMPOTENCY_COMPLETED_WITHOUT_CLAIM"], "complete() without claim() must throw IdempotencyCompletionWithoutClaimError");
|
|
1364
|
+
})
|
|
1365
|
+
}
|
|
1366
|
+
];
|
|
1367
|
+
const nonTransactional = harness.family === "non-transactional";
|
|
1368
|
+
if (!nonTransactional) tests.push({
|
|
1369
|
+
name: "a committed complete replays even without confirm (commit is the finalize)",
|
|
1370
|
+
run: inEnv(async (env) => {
|
|
1371
|
+
await env.run(async (ctx) => {
|
|
1372
|
+
const claim = claimedHandle(await env.store.claim(ctx, "key-1", "fp-1"), "a fresh key must be claimed");
|
|
1373
|
+
await env.store.complete(ctx, claim, "done");
|
|
1374
|
+
});
|
|
1375
|
+
const claim = await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1"));
|
|
1376
|
+
assert(claim.status === "completed" && claim.outcome === "done", "a committed complete must replay without a confirm call");
|
|
1377
|
+
})
|
|
1378
|
+
});
|
|
1379
|
+
tests.push(gatedContractTest({
|
|
1380
|
+
capability: "family: non-transactional",
|
|
1381
|
+
satisfiedBy: nonTransactional
|
|
1382
|
+
}, {
|
|
1383
|
+
name: "a pending claim is in-flight for concurrent claimers, and the error is retryable",
|
|
1384
|
+
run: inEnv(async (env) => {
|
|
1385
|
+
await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1"));
|
|
1386
|
+
const rejection = await captureRejection(env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1")));
|
|
1387
|
+
assertChainContainsKitError(rejection, ["IDEMPOTENCY_IN_FLIGHT"], "claiming a pending key must throw IdempotencyInFlightError");
|
|
1388
|
+
assert(chainContainsRetryable(rejection), "the in-flight error must be retryable (on the rejection or its cause chain)");
|
|
1389
|
+
})
|
|
1390
|
+
}), gatedContractTest({
|
|
1391
|
+
capability: "family: non-transactional",
|
|
1392
|
+
satisfiedBy: nonTransactional
|
|
1393
|
+
}, {
|
|
1394
|
+
name: "renew extends ownership beyond the original lease expiry",
|
|
1395
|
+
run: inEnv(async (env) => {
|
|
1396
|
+
const claim = claimedHandle(await env.run((ctx) => env.store.claim(ctx, "key-renew", "fp")), "a fresh key must be claimed");
|
|
1397
|
+
assert(claim.lease !== void 0, "a non-transactional claim must carry lease timing");
|
|
1398
|
+
const originalExpiry = new Date(claim.lease.expiresAt).getTime();
|
|
1399
|
+
assert(Number.isFinite(originalExpiry) && new Date(originalExpiry).toISOString() === claim.lease.expiresAt && Number.isSafeInteger(claim.lease.renewAfterMs) && claim.lease.renewAfterMs > 0, "lease timing must carry a valid expiry and positive safe renewal delay");
|
|
1400
|
+
await advanceTimeTo(env, /* @__PURE__ */ new Date(originalExpiry - 1));
|
|
1401
|
+
const renewed = await env.store.renew(claim);
|
|
1402
|
+
assert(renewed !== void 0 && new Date(renewed.expiresAt).getTime() > originalExpiry, "renew must extend the lease beyond its previous expiry");
|
|
1403
|
+
await advanceTimeTo(env, new Date(originalExpiry + 1));
|
|
1404
|
+
assertChainContainsKitError(await captureRejection(env.run((ctx) => env.store.claim(ctx, "key-renew", "fp"))), ["IDEMPOTENCY_IN_FLIGHT"], "the renewed owner must still hold the key after the original expiry");
|
|
1405
|
+
})
|
|
1406
|
+
}), gatedContractTest({
|
|
1407
|
+
capability: "family: non-transactional",
|
|
1408
|
+
satisfiedBy: nonTransactional
|
|
1409
|
+
}, {
|
|
1410
|
+
name: "an expired pending lease is reclaimed under a new token and fences its stale owner",
|
|
1411
|
+
run: inEnv(async (env) => {
|
|
1412
|
+
const first = claimedHandle(await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1")), "a fresh key must be claimed");
|
|
1413
|
+
assert(first.lease !== void 0, "a non-transactional claim must carry lease timing");
|
|
1414
|
+
await expireLease(env, first);
|
|
1415
|
+
assert(claimedHandle(await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1")), "an expired pending claim must be reclaimed").token !== first.token, "each ownership generation must have a different token");
|
|
1416
|
+
assertChainContainsKitError(await captureRejection(env.run((ctx) => env.store.complete(ctx, first, "stale"))), ["IDEMPOTENCY_CLAIM_LOST"], "a stale owner must fail before it can complete after takeover");
|
|
1417
|
+
})
|
|
1418
|
+
}), gatedContractTest({
|
|
1419
|
+
capability: "family: non-transactional",
|
|
1420
|
+
satisfiedBy: nonTransactional
|
|
1421
|
+
}, {
|
|
1422
|
+
name: "an expired staged outcome requires reconciliation and never auto-replays",
|
|
1423
|
+
run: inEnv(async (env) => {
|
|
1424
|
+
const first = await env.run(async (ctx) => {
|
|
1425
|
+
const claim = claimedHandle(await env.store.claim(ctx, "key-1", "fp-1"), "a fresh key must be claimed");
|
|
1426
|
+
await env.store.complete(ctx, claim, "uncertain");
|
|
1427
|
+
return claim;
|
|
1428
|
+
});
|
|
1429
|
+
await expireLease(env, first);
|
|
1430
|
+
const claim = await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1"));
|
|
1431
|
+
assert(claim.status === "reconciliation-required", "an expired staged outcome must require authoritative reconciliation");
|
|
1432
|
+
assertEqual(claim.reconciliation.token, first.token, "the reconciliation receipt identifies the staged owner");
|
|
1433
|
+
})
|
|
1434
|
+
}), gatedContractTest({
|
|
1435
|
+
capability: "family: non-transactional",
|
|
1436
|
+
satisfiedBy: nonTransactional
|
|
1437
|
+
}, {
|
|
1438
|
+
name: "reconciliation confirms committed work or releases proven rollback",
|
|
1439
|
+
run: inEnv(async (env) => {
|
|
1440
|
+
await expireLease(env, await env.run(async (ctx) => {
|
|
1441
|
+
const claim = claimedHandle(await env.store.claim(ctx, "committed", "fp"), "a fresh key must be claimed");
|
|
1442
|
+
await env.store.complete(ctx, claim, "winner");
|
|
1443
|
+
return claim;
|
|
1444
|
+
}));
|
|
1445
|
+
const committed = await env.run((ctx) => env.store.claim(ctx, "committed", "fp"));
|
|
1446
|
+
assert(committed.status === "reconciliation-required", "the staged outcome must expose its reconciliation receipt");
|
|
1447
|
+
await env.store.reconcile(committed.reconciliation, "committed");
|
|
1448
|
+
const replay = await env.run((ctx) => env.store.claim(ctx, "committed", "fp"));
|
|
1449
|
+
assert(replay.status === "completed" && replay.outcome === "winner", "committed evidence must make the staged outcome replayable");
|
|
1450
|
+
await expireLease(env, await env.run(async (ctx) => {
|
|
1451
|
+
const claim = claimedHandle(await env.store.claim(ctx, "rolled-back", "fp"), "a fresh key must be claimed");
|
|
1452
|
+
await env.store.complete(ctx, claim, "must disappear");
|
|
1453
|
+
return claim;
|
|
1454
|
+
}));
|
|
1455
|
+
const rolledBack = await env.run((ctx) => env.store.claim(ctx, "rolled-back", "fp"));
|
|
1456
|
+
assert(rolledBack.status === "reconciliation-required", "the staged outcome must expose its reconciliation receipt");
|
|
1457
|
+
await env.store.reconcile(rolledBack.reconciliation, "not-committed");
|
|
1458
|
+
assertEqual((await env.run((ctx) => env.store.claim(ctx, "rolled-back", "fp"))).status, "claimed", "not-committed evidence must release the staged outcome");
|
|
1459
|
+
})
|
|
1460
|
+
}), gatedContractTest({
|
|
1461
|
+
capability: "family: non-transactional",
|
|
1462
|
+
satisfiedBy: nonTransactional
|
|
1463
|
+
}, {
|
|
1464
|
+
name: "a staged, unconfirmed outcome is in-flight, never replayed",
|
|
1465
|
+
run: inEnv(async (env) => {
|
|
1466
|
+
await env.run(async (ctx) => {
|
|
1467
|
+
const claim = claimedHandle(await env.store.claim(ctx, "key-1", "fp-1"), "a fresh key must be claimed");
|
|
1468
|
+
await env.store.complete(ctx, claim, "uncommitted");
|
|
1469
|
+
});
|
|
1470
|
+
assertChainContainsKitError(await captureRejection(env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1"))), ["IDEMPOTENCY_IN_FLIGHT"], "a staged outcome must never replay; it is in-flight until confirmed");
|
|
1471
|
+
})
|
|
1472
|
+
}), gatedContractTest({
|
|
1473
|
+
capability: "family: non-transactional",
|
|
1474
|
+
satisfiedBy: nonTransactional
|
|
1475
|
+
}, {
|
|
1476
|
+
name: "abandon releases a pending claim so the next attempt claims fresh",
|
|
1477
|
+
run: inEnv(async (env) => {
|
|
1478
|
+
const first = claimedHandle(await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1")), "a fresh key must be claimed");
|
|
1479
|
+
await env.store.abandon(first);
|
|
1480
|
+
assertEqual((await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1"))).status, "claimed", "an abandoned pending claim must be claimable again");
|
|
1481
|
+
})
|
|
1482
|
+
}), gatedContractTest({
|
|
1483
|
+
capability: "family: non-transactional",
|
|
1484
|
+
satisfiedBy: nonTransactional
|
|
1485
|
+
}, {
|
|
1486
|
+
name: "abandon releases a staged outcome so the next attempt claims fresh",
|
|
1487
|
+
run: inEnv(async (env) => {
|
|
1488
|
+
const first = await env.run(async (ctx) => {
|
|
1489
|
+
const claim = claimedHandle(await env.store.claim(ctx, "key-1", "fp-1"), "a fresh key must be claimed");
|
|
1490
|
+
await env.store.complete(ctx, claim, "uncommitted");
|
|
1491
|
+
return claim;
|
|
1492
|
+
});
|
|
1493
|
+
await env.store.abandon(first);
|
|
1494
|
+
assertEqual((await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1"))).status, "claimed", "an abandoned staged outcome must be claimable again");
|
|
1495
|
+
})
|
|
1496
|
+
}), gatedContractTest({
|
|
1497
|
+
capability: "family: transactional",
|
|
1498
|
+
satisfiedBy: !nonTransactional
|
|
1499
|
+
}, {
|
|
1500
|
+
name: "a rolled-back transaction releases the claim (single-transaction pattern)",
|
|
1501
|
+
run: inEnv(async (env) => {
|
|
1502
|
+
if (!env.runRolledBack) throw new Error("Contract violated: the transactional family requires runRolledBack on the environment");
|
|
1503
|
+
await env.runRolledBack(async (ctx) => {
|
|
1504
|
+
const claim = claimedHandle(await env.store.claim(ctx, "key-1", "fp-1"), "a fresh key must be claimed");
|
|
1505
|
+
await env.store.complete(ctx, claim, "rolled back");
|
|
1506
|
+
}).catch(() => {});
|
|
1507
|
+
assertEqual((await env.run((ctx) => env.store.claim(ctx, "key-1", "fp-1"))).status, "claimed", "a rolled-back claim/complete must leave the key claimable");
|
|
1508
|
+
})
|
|
1509
|
+
}));
|
|
1510
|
+
return tests;
|
|
763
1511
|
}
|
|
764
|
-
__name(createEsRepositoryContractTests, "createEsRepositoryContractTests");
|
|
765
1512
|
|
|
766
|
-
|
|
1513
|
+
//#endregion
|
|
1514
|
+
//#region src/testing/outbox-contract.ts
|
|
1515
|
+
/**
|
|
1516
|
+
* The outbox contract test suite: the proof that an adapter delivers
|
|
1517
|
+
* the guarantees `withCommit` and `OutboxDispatcher` document. The kit
|
|
1518
|
+
* is store-agnostic, so commit-order reads, qualified source-position
|
|
1519
|
+
* identity, eventful-predecessor linkage, idempotent acks, and rollback
|
|
1520
|
+
* purity are an **adapter contract, not a kit guarantee**; this suite is how
|
|
1521
|
+
* an adapter demonstrates them. Its source-law tests also prove that colliding
|
|
1522
|
+
* raw ids stay isolated by aggregate type and aggregate id.
|
|
1523
|
+
*
|
|
1524
|
+
* Framework-agnostic: bind with
|
|
1525
|
+
* `(test.skipped ? it.skip : it)(test.name, test.run)`.
|
|
1526
|
+
*/
|
|
1527
|
+
function createOutboxContractTests(harness) {
|
|
1528
|
+
const inEnv = bindContractEnvironment(() => harness.createEnvironment());
|
|
1529
|
+
const defaultSource = {
|
|
1530
|
+
aggregateType: "ContractAggregate",
|
|
1531
|
+
aggregateId: "contract-aggregate"
|
|
1532
|
+
};
|
|
1533
|
+
const commit = (events, aggregateVersion = 1, source = defaultSource) => events.map((event, commitSequence) => ({
|
|
1534
|
+
event,
|
|
1535
|
+
source,
|
|
1536
|
+
position: {
|
|
1537
|
+
aggregateVersion,
|
|
1538
|
+
commitSequence,
|
|
1539
|
+
commitSize: events.length
|
|
1540
|
+
}
|
|
1541
|
+
}));
|
|
1542
|
+
const takeAndAck = async (env, count) => {
|
|
1543
|
+
const records = [];
|
|
1544
|
+
for (let index = 0; index < count; index += 1) {
|
|
1545
|
+
const [record] = await env.outbox.getPending(1);
|
|
1546
|
+
assert(record !== void 0, `expected committed outbox record ${index + 1} of ${count}`);
|
|
1547
|
+
records.push(record);
|
|
1548
|
+
await env.outbox.markDispatched([record.dispatchId]);
|
|
1549
|
+
}
|
|
1550
|
+
return records;
|
|
1551
|
+
};
|
|
1552
|
+
const tests = [
|
|
1553
|
+
{
|
|
1554
|
+
name: "finalizes complete commit receipts and links the next eventful commit",
|
|
1555
|
+
run: inEnv(async (env) => {
|
|
1556
|
+
await env.addCommitted(commit([harness.createEvent(1), harness.createEvent(2)], 1));
|
|
1557
|
+
await env.addCommitted(commit([harness.createEvent(3)], 3));
|
|
1558
|
+
assert(deepEqual((await takeAndAck(env, 3)).map(({ position }) => position), [
|
|
1559
|
+
{
|
|
1560
|
+
aggregateVersion: 1,
|
|
1561
|
+
commitSequence: 0,
|
|
1562
|
+
commitSize: 2,
|
|
1563
|
+
previousEventfulAggregateVersion: null
|
|
1564
|
+
},
|
|
1565
|
+
{
|
|
1566
|
+
aggregateVersion: 1,
|
|
1567
|
+
commitSequence: 1,
|
|
1568
|
+
commitSize: 2,
|
|
1569
|
+
previousEventfulAggregateVersion: null
|
|
1570
|
+
},
|
|
1571
|
+
{
|
|
1572
|
+
aggregateVersion: 3,
|
|
1573
|
+
commitSequence: 0,
|
|
1574
|
+
commitSize: 1,
|
|
1575
|
+
previousEventfulAggregateVersion: 1
|
|
1576
|
+
}
|
|
1577
|
+
]), "the source must preserve zero-based commit completeness and link the next eventful commit to version 1");
|
|
1578
|
+
})
|
|
1579
|
+
},
|
|
1580
|
+
{
|
|
1581
|
+
name: "rejects different event identities at one qualified source position",
|
|
1582
|
+
run: inEnv(async (env) => {
|
|
1583
|
+
const original = commit([harness.createEvent(1)], 1);
|
|
1584
|
+
const collision = commit([harness.createEvent(2)], 1);
|
|
1585
|
+
await env.addCommitted(original);
|
|
1586
|
+
assert(await captureRejection(env.addCommitted(collision)) !== void 0, "a different eventId at one qualified source position must reject");
|
|
1587
|
+
const [record] = await takeAndAck(env, 1);
|
|
1588
|
+
assertEqual(record?.event.eventId, original[0]?.event.eventId, "the rejected collision must not replace the original record");
|
|
1589
|
+
await env.addCommitted(commit([harness.createEvent(3)], 2));
|
|
1590
|
+
const [next] = await takeAndAck(env, 1);
|
|
1591
|
+
assertEqual(next?.position.previousEventfulAggregateVersion, 1, "the rejected collision must not change the source head");
|
|
1592
|
+
})
|
|
1593
|
+
},
|
|
1594
|
+
{
|
|
1595
|
+
name: "keeps event-source heads isolated by aggregate type and id",
|
|
1596
|
+
run: inEnv(async (env) => {
|
|
1597
|
+
const sources = [
|
|
1598
|
+
{
|
|
1599
|
+
aggregateType: "Order",
|
|
1600
|
+
aggregateId: "1"
|
|
1601
|
+
},
|
|
1602
|
+
{
|
|
1603
|
+
aggregateType: "Payment",
|
|
1604
|
+
aggregateId: "1"
|
|
1605
|
+
},
|
|
1606
|
+
{
|
|
1607
|
+
aggregateType: "Order",
|
|
1608
|
+
aggregateId: "2"
|
|
1609
|
+
}
|
|
1610
|
+
];
|
|
1611
|
+
for (const [index, source] of sources.entries()) await env.addCommitted(commit([harness.createEvent(index + 1)], 1, source));
|
|
1612
|
+
assert((await takeAndAck(env, sources.length)).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
|
+
})
|
|
1614
|
+
},
|
|
1615
|
+
{
|
|
1616
|
+
name: "getPending returns records in commit order, across separate committed adds",
|
|
1617
|
+
run: inEnv(async (env) => {
|
|
1618
|
+
await env.addCommitted(commit([harness.createEvent(1), harness.createEvent(2)], 1));
|
|
1619
|
+
await env.addCommitted(commit([harness.createEvent(3)], 2));
|
|
1620
|
+
const pending = await env.outbox.getPending(10);
|
|
1621
|
+
const expectedIds = [
|
|
1622
|
+
1,
|
|
1623
|
+
2,
|
|
1624
|
+
3
|
|
1625
|
+
].map((seed) => harness.createEvent(seed).eventId);
|
|
1626
|
+
assert(pending.length >= 1, "a non-empty backlog must surface at least one record");
|
|
1627
|
+
assert(deepEqual(pending.map((record) => record.event.eventId), expectedIds.slice(0, pending.length)), "records must come back in the order add() persisted them");
|
|
1628
|
+
})
|
|
1629
|
+
},
|
|
1630
|
+
{
|
|
1631
|
+
name: "getPending respects the limit",
|
|
1632
|
+
run: inEnv(async (env) => {
|
|
1633
|
+
await env.addCommitted(commit([
|
|
1634
|
+
1,
|
|
1635
|
+
2,
|
|
1636
|
+
3,
|
|
1637
|
+
4
|
|
1638
|
+
].map((s) => harness.createEvent(s))));
|
|
1639
|
+
const firstPage = await env.outbox.getPending(2);
|
|
1640
|
+
assert(firstPage.length >= 1 && firstPage.length <= 2, "limit must bound the page: up to `limit` records, at least one while the backlog is non-empty");
|
|
1641
|
+
})
|
|
1642
|
+
},
|
|
1643
|
+
gatedContractTest({
|
|
1644
|
+
capability: "non-claiming getPending",
|
|
1645
|
+
satisfiedBy: !harness.claimsOnGetPending
|
|
1646
|
+
}, {
|
|
1647
|
+
name: "an un-acked head comes back on the next poll (no silent skipping)",
|
|
1648
|
+
run: inEnv(async (env) => {
|
|
1649
|
+
await env.addCommitted(commit([
|
|
1650
|
+
1,
|
|
1651
|
+
2,
|
|
1652
|
+
3,
|
|
1653
|
+
4
|
|
1654
|
+
].map((s) => harness.createEvent(s))));
|
|
1655
|
+
const firstPage = await env.outbox.getPending(2);
|
|
1656
|
+
const again = await env.outbox.getPending(2);
|
|
1657
|
+
const overlap = Math.min(firstPage.length, again.length);
|
|
1658
|
+
assert(overlap >= 1, "a non-empty backlog must surface at least one record on every poll");
|
|
1659
|
+
assert(deepEqual(again.slice(0, overlap).map((r) => r.dispatchId), firstPage.slice(0, overlap).map((r) => r.dispatchId)), "an un-acked head must come back on the next poll (no silent skipping)");
|
|
1660
|
+
})
|
|
1661
|
+
}),
|
|
1662
|
+
{
|
|
1663
|
+
name: "markDispatched removes records; re-acks and unknown acks are accepted",
|
|
1664
|
+
run: inEnv(async (env) => {
|
|
1665
|
+
await env.addCommitted(commit([harness.createEvent(1), harness.createEvent(2)]));
|
|
1666
|
+
const [first] = await env.outbox.getPending(1);
|
|
1667
|
+
assert(first !== void 0, "expected a pending record");
|
|
1668
|
+
await env.outbox.markDispatched([first.dispatchId]);
|
|
1669
|
+
await env.outbox.markDispatched([first.dispatchId]);
|
|
1670
|
+
await env.outbox.markDispatched(["no-such-dispatch-id"]);
|
|
1671
|
+
assert(!(await env.outbox.getPending(10)).some((r) => r.dispatchId === first.dispatchId), "a dispatched record must never come back");
|
|
1672
|
+
})
|
|
1673
|
+
},
|
|
1674
|
+
gatedContractTest({
|
|
1675
|
+
capability: "non-claiming getPending",
|
|
1676
|
+
satisfiedBy: !harness.claimsOnGetPending
|
|
1677
|
+
}, {
|
|
1678
|
+
name: "idempotent re-acks do not disturb other pending records",
|
|
1679
|
+
run: inEnv(async (env) => {
|
|
1680
|
+
await env.addCommitted(commit([harness.createEvent(1), harness.createEvent(2)]));
|
|
1681
|
+
const [first] = await env.outbox.getPending(1);
|
|
1682
|
+
assert(first !== void 0, "expected a pending record");
|
|
1683
|
+
await env.outbox.markDispatched([first.dispatchId]);
|
|
1684
|
+
await env.outbox.markDispatched([first.dispatchId]);
|
|
1685
|
+
await env.outbox.markDispatched(["no-such-dispatch-id"]);
|
|
1686
|
+
assertEqual((await env.outbox.getPending(10)).length, 1, "idempotent re-acks must not disturb other records");
|
|
1687
|
+
})
|
|
1688
|
+
}),
|
|
1689
|
+
gatedContractTest({
|
|
1690
|
+
capability: "dedupesOnEventId",
|
|
1691
|
+
satisfiedBy: harness.dedupesOnEventId === true
|
|
1692
|
+
}, {
|
|
1693
|
+
name: "re-adding an event with the same eventId is deduped, not duplicated",
|
|
1694
|
+
run: inEnv(async (env) => {
|
|
1695
|
+
const original = commit([harness.createEvent(1)]);
|
|
1696
|
+
await env.addCommitted(original);
|
|
1697
|
+
try {
|
|
1698
|
+
await env.addCommitted(original);
|
|
1699
|
+
} catch (error) {
|
|
1700
|
+
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
|
+
}
|
|
1702
|
+
assertEqual((await env.outbox.getPending(10)).length, 1, "the same eventId must yield one record (unique-key dedupe)");
|
|
1703
|
+
})
|
|
1704
|
+
})
|
|
1705
|
+
];
|
|
1706
|
+
tests.push(gatedContractTest({
|
|
1707
|
+
capability: "providesRolledBackAdds",
|
|
1708
|
+
satisfiedBy: harness.providesRolledBackAdds === true
|
|
1709
|
+
}, {
|
|
1710
|
+
name: "a rolled-back add leaves nothing behind (transactional participation)",
|
|
1711
|
+
run: inEnv(async (env) => {
|
|
1712
|
+
if (!env.addRolledBack) throw new Error("Contract violated: harness declared providesRolledBackAdds but the environment lacks addRolledBack");
|
|
1713
|
+
await env.addRolledBack(commit([harness.createEvent(1)], 1));
|
|
1714
|
+
assertEqual((await env.outbox.getPending(10)).length, 0, "events added in a rolled-back transaction must not appear");
|
|
1715
|
+
await env.addCommitted(commit([harness.createEvent(2)], 2));
|
|
1716
|
+
const [afterRollback] = await takeAndAck(env, 1);
|
|
1717
|
+
assertEqual(afterRollback?.position.previousEventfulAggregateVersion, null, "a rolled-back add must not advance the event-source head");
|
|
1718
|
+
})
|
|
1719
|
+
}));
|
|
1720
|
+
const trackingEnabled = harness.failuresToDeadLetter !== void 0;
|
|
1721
|
+
const attemptCeiling = harness.failuresToDeadLetter ?? 0;
|
|
1722
|
+
const trackingGate = (test) => gatedContractTest({
|
|
1723
|
+
capability: "failuresToDeadLetter (DispatchTrackingOutbox)",
|
|
1724
|
+
satisfiedBy: trackingEnabled
|
|
1725
|
+
}, test);
|
|
1726
|
+
tests.push(trackingGate(gatedContractTest({
|
|
1727
|
+
capability: "non-claiming getPending",
|
|
1728
|
+
satisfiedBy: !harness.claimsOnGetPending
|
|
1729
|
+
}, gatedContractTest({
|
|
1730
|
+
capability: "failuresToDeadLetter >= 2",
|
|
1731
|
+
satisfiedBy: attemptCeiling >= 2
|
|
1732
|
+
}, {
|
|
1733
|
+
name: "markFailed increments attempts surfaced on pending records",
|
|
1734
|
+
run: inEnv(async (env) => {
|
|
1735
|
+
const outbox = env.outbox;
|
|
1736
|
+
assert(isDispatchTrackingOutbox(outbox), "harness declared a tracking outbox");
|
|
1737
|
+
await env.addCommitted(commit([harness.createEvent(1)]));
|
|
1738
|
+
const [record] = await outbox.getPending(1);
|
|
1739
|
+
assert(record !== void 0, "expected a pending record");
|
|
1740
|
+
await outbox.markFailed(record.dispatchId, /* @__PURE__ */ new Error("boom"));
|
|
1741
|
+
const [after] = await outbox.getPending(1);
|
|
1742
|
+
assertEqual(after?.attempts, 1, "attempts must be surfaced on the record after markFailed");
|
|
1743
|
+
})
|
|
1744
|
+
}))), trackingGate({
|
|
1745
|
+
name: "reaching the attempt ceiling dead-letters the record and unblocks getPending",
|
|
1746
|
+
run: inEnv(async (env) => {
|
|
1747
|
+
const outbox = env.outbox;
|
|
1748
|
+
assert(isDispatchTrackingOutbox(outbox), "harness declared a tracking outbox");
|
|
1749
|
+
await env.addCommitted(commit([harness.createEvent(1), harness.createEvent(2)]));
|
|
1750
|
+
const [poison] = await outbox.getPending(1);
|
|
1751
|
+
assert(poison !== void 0, "expected a pending record");
|
|
1752
|
+
let transition;
|
|
1753
|
+
for (let i = 0; i < attemptCeiling; i++) {
|
|
1754
|
+
const current = await outbox.markFailed(poison.dispatchId, /* @__PURE__ */ new Error("poison"));
|
|
1755
|
+
if (i < attemptCeiling - 1) assertEqual(current, void 0, "markFailed must not report a dead-letter transition below the ceiling");
|
|
1756
|
+
transition = current;
|
|
1757
|
+
}
|
|
1758
|
+
assertEqual(transition?.dispatchId, poison.dispatchId, "the ceiling-crossing markFailed call must return the exact dead-letter transition");
|
|
1759
|
+
assertEqual(transition?.attempts, attemptCeiling, "the returned transition must carry the final attempt count");
|
|
1760
|
+
assertEqual(await outbox.markFailed(poison.dispatchId, /* @__PURE__ */ new Error("late")), void 0, "a late failure report must not repeat the dead-letter transition");
|
|
1761
|
+
const pending = await outbox.getPending(10);
|
|
1762
|
+
assertEqual(pending.length, 1, "the dead-lettered record must stop coming back; successors must flow");
|
|
1763
|
+
assert(pending[0]?.dispatchId !== poison.dispatchId, "the surviving record must be the successor, not the poison one");
|
|
1764
|
+
const dead = await outbox.deadLetters();
|
|
1765
|
+
assertEqual(dead.length, 1, "the record must appear in deadLetters()");
|
|
1766
|
+
assertEqual(dead[0]?.attempts, attemptCeiling, "the dead-letter record must carry its attempt count");
|
|
1767
|
+
})
|
|
1768
|
+
}), trackingGate({
|
|
1769
|
+
name: "markFailed on unknown or dispatched ids never resurrects a record",
|
|
1770
|
+
run: inEnv(async (env) => {
|
|
1771
|
+
const outbox = env.outbox;
|
|
1772
|
+
assert(isDispatchTrackingOutbox(outbox), "harness declared a tracking outbox");
|
|
1773
|
+
await env.addCommitted(commit([harness.createEvent(1)]));
|
|
1774
|
+
const [record] = await outbox.getPending(1);
|
|
1775
|
+
assert(record !== void 0, "expected a pending record");
|
|
1776
|
+
await outbox.markDispatched([record.dispatchId]);
|
|
1777
|
+
await outbox.markFailed(record.dispatchId, /* @__PURE__ */ new Error("late report"));
|
|
1778
|
+
await outbox.markFailed("no-such-id", /* @__PURE__ */ new Error("unknown"));
|
|
1779
|
+
assertEqual((await outbox.getPending(10)).length, 0, "late or unknown failure reports must not resurrect records");
|
|
1780
|
+
assertEqual((await outbox.deadLetters()).length, 0, "late or unknown failure reports must not dead-letter anything");
|
|
1781
|
+
})
|
|
1782
|
+
}), trackingGate({
|
|
1783
|
+
name: "markDispatched clears a dead-lettered record (manual redelivery then ack)",
|
|
1784
|
+
run: inEnv(async (env) => {
|
|
1785
|
+
const outbox = env.outbox;
|
|
1786
|
+
assert(isDispatchTrackingOutbox(outbox), "harness declared a tracking outbox");
|
|
1787
|
+
await env.addCommitted(commit([harness.createEvent(1)]));
|
|
1788
|
+
const [record] = await outbox.getPending(1);
|
|
1789
|
+
assert(record !== void 0, "expected a pending record");
|
|
1790
|
+
for (let i = 0; i < attemptCeiling; i++) await outbox.markFailed(record.dispatchId, /* @__PURE__ */ new Error("poison"));
|
|
1791
|
+
await outbox.markDispatched([record.dispatchId]);
|
|
1792
|
+
assertEqual((await outbox.deadLetters()).length, 0, "acking a dead-lettered record must clear it");
|
|
1793
|
+
})
|
|
1794
|
+
}));
|
|
1795
|
+
return tests;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
//#endregion
|
|
1799
|
+
//#region src/testing/projection-checkpoint-contract.ts
|
|
1800
|
+
const pos = (aggregateVersion, commitSequence, commitSize = commitSequence + 1, previousEventfulAggregateVersion = null) => ({
|
|
1801
|
+
aggregateVersion,
|
|
1802
|
+
commitSequence,
|
|
1803
|
+
commitSize,
|
|
1804
|
+
previousEventfulAggregateVersion
|
|
1805
|
+
});
|
|
1806
|
+
const order = (aggregateId) => ({
|
|
1807
|
+
aggregateType: "Order",
|
|
1808
|
+
aggregateId
|
|
1809
|
+
});
|
|
1810
|
+
const checkpoint = (position, lastAppliedEventId = "evt-at-watermark") => ({
|
|
1811
|
+
position,
|
|
1812
|
+
lastAppliedEventId
|
|
1813
|
+
});
|
|
1814
|
+
/**
|
|
1815
|
+
* The projection-checkpoint-store contract test suite: the proof that
|
|
1816
|
+
* an adapter delivers the watermark semantics the `Projector`
|
|
1817
|
+
* documents. Checkpoint semantics are an **adapter contract, not a
|
|
1818
|
+
* kit guarantee**; this suite is how an adapter demonstrates them. Enable its
|
|
1819
|
+
* concurrent-runs capability to prove genesis-safe exclusion rather than
|
|
1820
|
+
* leaving that guarantee visibly skipped.
|
|
1821
|
+
*
|
|
1822
|
+
* The concurrent test exercises commit visibility, but cannot deterministically
|
|
1823
|
+
* hold an adapter between return from `withCheckpointLocks` and its surrounding
|
|
1824
|
+
* transaction commit. It can therefore expose an early lock release only when
|
|
1825
|
+
* a waiter enters during that window; holding database locks through commit or
|
|
1826
|
+
* rollback remains an explicit adapter responsibility, not a complete proof
|
|
1827
|
+
* supplied by this suite.
|
|
1828
|
+
*
|
|
1829
|
+
* Framework-agnostic: bind with
|
|
1830
|
+
* `(test.skipped ? it.skip : it)(test.name, test.run)`.
|
|
1831
|
+
*/
|
|
1832
|
+
function createProjectionCheckpointStoreContractTests(harness) {
|
|
1833
|
+
const inEnv = bindContractEnvironment(() => harness.createEnvironment());
|
|
1834
|
+
return [
|
|
1835
|
+
{
|
|
1836
|
+
name: "a never-seen (projection, aggregate) pair loads undefined",
|
|
1837
|
+
run: inEnv(async (env) => {
|
|
1838
|
+
assertEqual(await env.run((ctx) => env.store.load(ctx, "order-list", order("o-1"))), void 0, "a fresh store must report no watermark");
|
|
1839
|
+
})
|
|
1840
|
+
},
|
|
1841
|
+
gatedContractTest({
|
|
1842
|
+
capability: "providesConcurrentRuns",
|
|
1843
|
+
satisfiedBy: harness.providesConcurrentRuns === true
|
|
1844
|
+
}, {
|
|
1845
|
+
name: "checkpoint locks serialize competing critical sections for absent and existing rows",
|
|
1846
|
+
run: inEnv(async (env) => {
|
|
1847
|
+
const runConcurrently = env.runConcurrently;
|
|
1848
|
+
if (!runConcurrently) throw new Error("Contract violated: harness declared providesConcurrentRuns but the environment lacks runConcurrently");
|
|
1849
|
+
const contenders = 8;
|
|
1850
|
+
const address = order("o-locked");
|
|
1851
|
+
const advanceOnce = async (expectedVersion, nextVersion) => {
|
|
1852
|
+
return (await runConcurrently(Array.from({ length: contenders }, () => (ctx) => env.store.withCheckpointLocks(ctx, "order-list", [address], async () => {
|
|
1853
|
+
const stored = await env.store.load(ctx, "order-list", address);
|
|
1854
|
+
if (expectedVersion === void 0 ? stored !== void 0 : stored?.position.aggregateVersion !== expectedVersion) return false;
|
|
1855
|
+
await Promise.resolve();
|
|
1856
|
+
await env.store.save(ctx, "order-list", address, checkpoint(pos(nextVersion, 0), `evt-v${nextVersion}`));
|
|
1857
|
+
return true;
|
|
1858
|
+
})))).filter((advanced) => advanced).length;
|
|
1859
|
+
};
|
|
1860
|
+
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
|
+
assertEqual(await advanceOnce(1, 2), 1, "exactly one competing callback may advance an existing checkpoint key from the observed watermark");
|
|
1862
|
+
assert((await env.run((ctx) => env.store.load(ctx, "order-list", address)))?.position.aggregateVersion === 2, "serialized genesis and existing-row advances must leave the final watermark visible");
|
|
1863
|
+
})
|
|
1864
|
+
}),
|
|
1865
|
+
{
|
|
1866
|
+
name: "checkpoint locks release after a rejected critical section",
|
|
1867
|
+
run: inEnv(async (env) => {
|
|
1868
|
+
const address = order("o-rejected-lock");
|
|
1869
|
+
assert(await captureRejection(env.run((ctx) => env.store.withCheckpointLocks(ctx, "order-list", [address], async () => {
|
|
1870
|
+
throw new Error("projection failed");
|
|
1871
|
+
}))) !== void 0, "the store must propagate a rejected critical section");
|
|
1872
|
+
let retried = false;
|
|
1873
|
+
await env.run((ctx) => env.store.withCheckpointLocks(ctx, "order-list", [address], async () => {
|
|
1874
|
+
retried = true;
|
|
1875
|
+
}));
|
|
1876
|
+
assert(retried, "a rejected callback must release its key so redelivery can enter");
|
|
1877
|
+
})
|
|
1878
|
+
},
|
|
1879
|
+
{
|
|
1880
|
+
name: "save/load round-trips the complete checkpoint receipt",
|
|
1881
|
+
run: inEnv(async (env) => {
|
|
1882
|
+
await env.run((ctx) => env.store.save(ctx, "order-list", order("o-1"), checkpoint(pos(5, 2, 3, 3), "evt-o-1-5-2")));
|
|
1883
|
+
const loaded = await env.run((ctx) => env.store.load(ctx, "order-list", order("o-1")));
|
|
1884
|
+
assert(loaded?.position.aggregateVersion === 5 && loaded.position.commitSequence === 2 && loaded.position.commitSize === 3 && loaded.position.previousEventfulAggregateVersion === 3 && loaded.lastAppliedEventId === "evt-o-1-5-2", "the stored checkpoint must round-trip every cursor field and the watermark event identity");
|
|
1885
|
+
})
|
|
1886
|
+
},
|
|
1887
|
+
{
|
|
1888
|
+
name: "save overwrites the previous watermark (last write wins; monotonicity is the projector's job)",
|
|
1889
|
+
run: inEnv(async (env) => {
|
|
1890
|
+
await env.run(async (ctx) => {
|
|
1891
|
+
await env.store.save(ctx, "order-list", order("o-1"), checkpoint(pos(5, 0), "evt-first"));
|
|
1892
|
+
await env.store.save(ctx, "order-list", order("o-1"), checkpoint(pos(5, 1), "evt-second"));
|
|
1893
|
+
});
|
|
1894
|
+
const loaded = await env.run((ctx) => env.store.load(ctx, "order-list", order("o-1")));
|
|
1895
|
+
assert(loaded?.position.aggregateVersion === 5 && loaded.position.commitSequence === 1 && loaded.lastAppliedEventId === "evt-second", "a later save must replace the stored watermark verbatim");
|
|
1896
|
+
})
|
|
1897
|
+
},
|
|
1898
|
+
{
|
|
1899
|
+
name: "a loaded position is a detached copy; mutating it must not move the watermark",
|
|
1900
|
+
run: inEnv(async (env) => {
|
|
1901
|
+
await env.run((ctx) => env.store.save(ctx, "order-list", order("o-1"), checkpoint(pos(2, 0))));
|
|
1902
|
+
const loaded = await env.run((ctx) => env.store.load(ctx, "order-list", order("o-1")));
|
|
1903
|
+
assert(loaded !== void 0, "expected a stored watermark");
|
|
1904
|
+
loaded.position.aggregateVersion = 99;
|
|
1905
|
+
assert((await env.run((ctx) => env.store.load(ctx, "order-list", order("o-1"))))?.position.aggregateVersion === 2, "the stored watermark must be immune to mutation of a previously loaded copy");
|
|
1906
|
+
})
|
|
1907
|
+
},
|
|
1908
|
+
{
|
|
1909
|
+
name: "watermarks are isolated per projection and per aggregate",
|
|
1910
|
+
run: inEnv(async (env) => {
|
|
1911
|
+
await env.run(async (ctx) => {
|
|
1912
|
+
await env.store.save(ctx, "order-list", order("o-1"), checkpoint(pos(3, 0)));
|
|
1913
|
+
await env.store.save(ctx, "order-detail", order("o-1"), checkpoint(pos(1, 0)));
|
|
1914
|
+
await env.store.save(ctx, "order-list", order("o-2"), checkpoint(pos(7, 0)));
|
|
1915
|
+
});
|
|
1916
|
+
const [listO1, detailO1, listO2] = await env.run((ctx) => Promise.all([
|
|
1917
|
+
env.store.load(ctx, "order-list", order("o-1")),
|
|
1918
|
+
env.store.load(ctx, "order-detail", order("o-1")),
|
|
1919
|
+
env.store.load(ctx, "order-list", order("o-2"))
|
|
1920
|
+
]));
|
|
1921
|
+
assert(listO1?.position.aggregateVersion === 3 && detailO1?.position.aggregateVersion === 1 && listO2?.position.aggregateVersion === 7, "the watermark key is the (projection, aggregateType, aggregateId) triple; no part may bleed into another");
|
|
1922
|
+
})
|
|
1923
|
+
},
|
|
1924
|
+
{
|
|
1925
|
+
name: "watermarks are isolated per aggregate TYPE: colliding raw ids do not share a checkpoint",
|
|
1926
|
+
run: inEnv(async (env) => {
|
|
1927
|
+
await env.run(async (ctx) => {
|
|
1928
|
+
await env.store.save(ctx, "order-list", {
|
|
1929
|
+
aggregateType: "Order",
|
|
1930
|
+
aggregateId: "1"
|
|
1931
|
+
}, checkpoint(pos(10, 0)));
|
|
1932
|
+
await env.store.save(ctx, "order-list", {
|
|
1933
|
+
aggregateType: "Payment",
|
|
1934
|
+
aggregateId: "1"
|
|
1935
|
+
}, checkpoint(pos(1, 0)));
|
|
1936
|
+
});
|
|
1937
|
+
const [orderMark, paymentMark] = await env.run((ctx) => Promise.all([env.store.load(ctx, "order-list", {
|
|
1938
|
+
aggregateType: "Order",
|
|
1939
|
+
aggregateId: "1"
|
|
1940
|
+
}), env.store.load(ctx, "order-list", {
|
|
1941
|
+
aggregateType: "Payment",
|
|
1942
|
+
aggregateId: "1"
|
|
1943
|
+
})]));
|
|
1944
|
+
assert(orderMark?.position.aggregateVersion === 10 && paymentMark?.position.aggregateVersion === 1, "identities are type-scoped: Order 1 at version 10 must not make Payment 1 look processed");
|
|
1945
|
+
})
|
|
1946
|
+
},
|
|
1947
|
+
{
|
|
1948
|
+
name: "address encoding is collision-free even with separator-like characters in either half",
|
|
1949
|
+
run: inEnv(async (env) => {
|
|
1950
|
+
const inType = {
|
|
1951
|
+
aggregateType: "A\0B",
|
|
1952
|
+
aggregateId: "C"
|
|
1953
|
+
};
|
|
1954
|
+
const inId = {
|
|
1955
|
+
aggregateType: "A",
|
|
1956
|
+
aggregateId: "B\0C"
|
|
1957
|
+
};
|
|
1958
|
+
await env.run(async (ctx) => {
|
|
1959
|
+
await env.store.save(ctx, "order-list", inType, checkpoint(pos(10, 0)));
|
|
1960
|
+
await env.store.save(ctx, "order-list", inId, checkpoint(pos(1, 0)));
|
|
1961
|
+
});
|
|
1962
|
+
const [first, second] = await env.run((ctx) => Promise.all([env.store.load(ctx, "order-list", inType), env.store.load(ctx, "order-list", inId)]));
|
|
1963
|
+
assert(first?.position.aggregateVersion === 10 && second?.position.aggregateVersion === 1, "two addresses that differ only in where a hostile separator sits must not share a watermark");
|
|
1964
|
+
})
|
|
1965
|
+
},
|
|
1966
|
+
{
|
|
1967
|
+
name: "hasReached compares the full pair: unseen is false, behind is false, at and past are true",
|
|
1968
|
+
run: inEnv(async (env) => {
|
|
1969
|
+
await env.run((ctx) => env.store.save(ctx, "order-list", order("o-1"), checkpoint(pos(5, 0))));
|
|
1970
|
+
assertEqual(await env.store.hasReached("order-list", order("o-2"), pos(1, 0)), false, "an unseen aggregate has reached nothing");
|
|
1971
|
+
assertEqual(await env.store.hasReached("order-list", order("o-1"), pos(5, 1)), false, "a later commitSequence of the SAME version is not yet reached; comparing on the version alone would lie mid-commit");
|
|
1972
|
+
assertEqual(await env.store.hasReached("order-list", order("o-1"), pos(6, 0)), false, "a later version is not yet reached");
|
|
1973
|
+
assertEqual(await env.store.hasReached("order-list", order("o-1"), pos(5, 0)), true, "the stored position itself is reached");
|
|
1974
|
+
assertEqual(await env.store.hasReached("order-list", order("o-1"), pos(4, 7)), true, "any earlier version is reached regardless of its commitSequence");
|
|
1975
|
+
})
|
|
1976
|
+
},
|
|
1977
|
+
{
|
|
1978
|
+
name: "reset clears only the named projection's checkpoints",
|
|
1979
|
+
run: inEnv(async (env) => {
|
|
1980
|
+
await env.run(async (ctx) => {
|
|
1981
|
+
await env.store.save(ctx, "order-list", order("o-1"), checkpoint(pos(3, 0)));
|
|
1982
|
+
await env.store.save(ctx, "order-detail", order("o-1"), checkpoint(pos(2, 0)));
|
|
1983
|
+
});
|
|
1984
|
+
await env.run((ctx) => env.store.reset(ctx, "order-list"));
|
|
1985
|
+
const [cleared, untouched] = await env.run((ctx) => Promise.all([env.store.load(ctx, "order-list", order("o-1")), env.store.load(ctx, "order-detail", order("o-1"))]));
|
|
1986
|
+
assertEqual(cleared, void 0, "the reset projection must start from zero (rebuild entry point)");
|
|
1987
|
+
assert(untouched?.position.aggregateVersion === 2, "a sibling projection's checkpoints must survive the reset");
|
|
1988
|
+
assertEqual(await env.store.hasReached("order-list", order("o-1"), pos(1, 0)), false, "hasReached must report false after a reset");
|
|
1989
|
+
})
|
|
1990
|
+
},
|
|
1991
|
+
gatedContractTest({
|
|
1992
|
+
capability: "providesRolledBackRuns",
|
|
1993
|
+
satisfiedBy: harness.providesRolledBackRuns === true
|
|
1994
|
+
}, {
|
|
1995
|
+
name: "a rolled-back save leaves no checkpoint behind (atomic update+checkpoint)",
|
|
1996
|
+
run: inEnv(async (env) => {
|
|
1997
|
+
if (!env.runRolledBack) throw new Error("Contract violated: harness declared providesRolledBackRuns but the environment lacks runRolledBack");
|
|
1998
|
+
await env.runRolledBack((ctx) => env.store.save(ctx, "order-list", order("o-1"), checkpoint(pos(1, 0)))).catch(() => {});
|
|
1999
|
+
assertEqual(await env.run((ctx) => env.store.load(ctx, "order-list", order("o-1"))), void 0, "a checkpoint from a rolled-back transaction must not exist; otherwise events are lost while marked processed");
|
|
2000
|
+
})
|
|
2001
|
+
})
|
|
2002
|
+
];
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
//#endregion
|
|
2006
|
+
//#region src/testing/repository-contract.ts
|
|
2007
|
+
/**
|
|
2008
|
+
* Contract suite for the v3 explicit-intent, commit-time-flush protocol.
|
|
2009
|
+
*
|
|
2010
|
+
* The harness must use the public `UnitOfWork` with a real adapter. In
|
|
2011
|
+
* particular, `run` must create a fresh Unit of Work and transaction for each
|
|
2012
|
+
* call and allow two calls to overlap; the mandatory stale-writer proof keeps
|
|
2013
|
+
* writer B open while writer A commits. SQL/ORM adapters therefore need a
|
|
2014
|
+
* real database and connection pool. An in-memory harness proves only itself.
|
|
2015
|
+
*
|
|
2016
|
+
* Writes are synchronous registrations. Durable adapter I/O happens after the
|
|
2017
|
+
* callback returns, while the transaction is still open. A test that passes
|
|
2018
|
+
* because `add` or `update` writes early is not a conforming implementation.
|
|
2019
|
+
*/
|
|
767
2020
|
function createRepositoryContractTests(harness) {
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
name: "stale delete conflicts: deleting from a stale instance throws ConcurrencyConflictError",
|
|
1168
|
-
run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
|
|
1169
|
-
const seeded = await seed(env);
|
|
1170
|
-
const staleB = await reload(env, seeded.id);
|
|
1171
|
-
const versionAfterA = await env.run(
|
|
1172
|
-
async ({ repository }) => {
|
|
1173
|
-
const a = await loadOrFail(repository, seeded.id);
|
|
1174
|
-
harness.mutate(a);
|
|
1175
|
-
await repository.save(a);
|
|
1176
|
-
return a.version;
|
|
1177
|
-
}
|
|
1178
|
-
);
|
|
1179
|
-
const rejection = await captureRejection(
|
|
1180
|
-
env.run(async ({ repository }) => {
|
|
1181
|
-
await repository.delete(staleB);
|
|
1182
|
-
})
|
|
1183
|
-
);
|
|
1184
|
-
assert(
|
|
1185
|
-
chainContainsErrorNamed(rejection, "ConcurrencyConflictError"),
|
|
1186
|
-
`a stale delete must reject with (or wrap) ConcurrencyConflictError; got: ${describeError(rejection)} - an unpredicated DELETE silently destroys the concurrent writer's update`
|
|
1187
|
-
);
|
|
1188
|
-
const final = await env.run(
|
|
1189
|
-
({ repository }) => repository.getById(seeded.id)
|
|
1190
|
-
);
|
|
1191
|
-
assert(
|
|
1192
|
-
final !== null,
|
|
1193
|
-
"the row must still exist after the stale delete was rejected - the predicate must PREVENT the destructive delete, not merely report it"
|
|
1194
|
-
);
|
|
1195
|
-
assertEqual(
|
|
1196
|
-
final.version,
|
|
1197
|
-
versionAfterA,
|
|
1198
|
-
"the surviving row must carry writer A's version"
|
|
1199
|
-
);
|
|
1200
|
-
}), "run")
|
|
1201
|
-
} : skippedTest(
|
|
1202
|
-
"stale delete conflicts: deleting from a stale instance throws ConcurrencyConflictError",
|
|
1203
|
-
"deletesAreVersionChecked"
|
|
1204
|
-
)
|
|
1205
|
-
);
|
|
1206
|
-
return tests;
|
|
2021
|
+
const inEnvironment = bindContractEnvironment(() => harness.createEnvironment());
|
|
2022
|
+
const snapshotState = harness.snapshotState;
|
|
2023
|
+
const createAggregateWithId = harness.createAggregateWithId;
|
|
2024
|
+
const mutateVersionOnly = harness.mutateVersionOnly;
|
|
2025
|
+
const mutateChildCollection = harness.mutateChildCollection;
|
|
2026
|
+
const insertsAreDuplicateChecked = harness.insertsAreDuplicateChecked !== false;
|
|
2027
|
+
const removesAreSupported = harness.removesAreSupported === true;
|
|
2028
|
+
const removesAreVersionChecked = removesAreSupported && harness.removesAreVersionChecked === true;
|
|
2029
|
+
const load = (repository, id) => loadAggregateOrFail(repository, id, "the adapter did not commit or reconstitute the aggregate");
|
|
2030
|
+
async function seed(environment) {
|
|
2031
|
+
const aggregate = harness.createAggregate();
|
|
2032
|
+
harness.mutate(aggregate);
|
|
2033
|
+
await environment.run(async ({ repository }) => {
|
|
2034
|
+
repository.add(aggregate);
|
|
2035
|
+
});
|
|
2036
|
+
return aggregate;
|
|
2037
|
+
}
|
|
2038
|
+
const reload = (environment, id) => environment.run(({ repository }) => load(repository, id));
|
|
2039
|
+
const eventIds = (events) => sortedCommittedEventIds(events);
|
|
2040
|
+
const pendingEventIds = (events) => mintedPendingEventIds(events, "the harness must record pending events before persistence");
|
|
2041
|
+
const tests = [
|
|
2042
|
+
{
|
|
2043
|
+
name: "add flushes a new aggregate and its exact event batch atomically",
|
|
2044
|
+
run: inEnvironment(async (environment) => {
|
|
2045
|
+
const aggregate = harness.createAggregate();
|
|
2046
|
+
harness.mutate(aggregate);
|
|
2047
|
+
const registeredEvents = [...aggregate.pendingEvents];
|
|
2048
|
+
await environment.run(async ({ repository }) => {
|
|
2049
|
+
repository.add(aggregate);
|
|
2050
|
+
});
|
|
2051
|
+
const reloaded = await reload(environment, aggregate.id);
|
|
2052
|
+
assertEqual(reloaded.version, aggregate.version, "add must store the version registered by the Unit of Work");
|
|
2053
|
+
if (snapshotState) assert(deepEqual(snapshotState.call(harness, reloaded), snapshotState.call(harness, aggregate)), "add must store the adapter's complete persistence projection");
|
|
2054
|
+
const outbox = await environment.committedOutboxEvents();
|
|
2055
|
+
assert(deepEqual(eventIds(outbox), pendingEventIds(registeredEvents).sort()), "the outbox must contain exactly the batch registered by add");
|
|
2056
|
+
assertEqual(aggregate.pendingEvents.length, 0, "only a committed add acknowledges its registered event batch");
|
|
2057
|
+
})
|
|
2058
|
+
},
|
|
2059
|
+
{
|
|
2060
|
+
name: "committed outbox envelopes carry exact position facts",
|
|
2061
|
+
run: inEnvironment(async (environment) => {
|
|
2062
|
+
const aggregate = harness.createAggregate();
|
|
2063
|
+
harness.mutate(aggregate);
|
|
2064
|
+
harness.mutate(aggregate);
|
|
2065
|
+
const batchSize = aggregate.pendingEvents.length;
|
|
2066
|
+
const committedVersion = aggregate.version;
|
|
2067
|
+
await environment.run(async ({ repository }) => {
|
|
2068
|
+
repository.add(aggregate);
|
|
2069
|
+
});
|
|
2070
|
+
const positions = (await environment.committedOutboxEvents()).map(({ position }) => position).sort((a, b) => a.commitSequence - b.commitSequence);
|
|
2071
|
+
assertEqual(positions.length, batchSize, "every registered event must commit exactly one envelope");
|
|
2072
|
+
positions.forEach((position, index) => {
|
|
2073
|
+
assertEqual(position.aggregateVersion, committedVersion, "every envelope must carry the version the commit persisted");
|
|
2074
|
+
assertEqual(position.commitSequence, index, "commitSequence must be gapless and zero-based over the batch");
|
|
2075
|
+
assertEqual(position.commitSize, batchSize, "commitSize must equal the exact batch length");
|
|
2076
|
+
});
|
|
2077
|
+
})
|
|
2078
|
+
},
|
|
2079
|
+
{
|
|
2080
|
+
name: "MANDATORY stale update: writer B conflicts after writer A commits and persists nothing",
|
|
2081
|
+
run: inEnvironment(async (environment) => {
|
|
2082
|
+
const seeded = await seed(environment);
|
|
2083
|
+
let loadedB;
|
|
2084
|
+
const bLoaded = new Promise((resolve) => {
|
|
2085
|
+
loadedB = resolve;
|
|
2086
|
+
});
|
|
2087
|
+
let releaseB;
|
|
2088
|
+
const bMayFlush = new Promise((resolve) => {
|
|
2089
|
+
releaseB = resolve;
|
|
2090
|
+
});
|
|
2091
|
+
const writerB = environment.run(async ({ repository }) => {
|
|
2092
|
+
const stale = await load(repository, seeded.id);
|
|
2093
|
+
loadedB();
|
|
2094
|
+
await bMayFlush;
|
|
2095
|
+
harness.mutate(stale);
|
|
2096
|
+
repository.update(stale);
|
|
2097
|
+
});
|
|
2098
|
+
await bLoaded;
|
|
2099
|
+
const committedA = await environment.run(async ({ repository }) => {
|
|
2100
|
+
const current = await load(repository, seeded.id);
|
|
2101
|
+
harness.mutate(current);
|
|
2102
|
+
repository.update(current);
|
|
2103
|
+
return current;
|
|
2104
|
+
});
|
|
2105
|
+
const outboxAfterA = await environment.committedOutboxEvents();
|
|
2106
|
+
releaseB();
|
|
2107
|
+
const rejection = await captureRejection(writerB);
|
|
2108
|
+
assertChainContainsKitError(rejection, ["CONCURRENCY_CONFLICT"], `stale update must reject with ConcurrencyConflictError; got ${describeError(rejection)}`);
|
|
2109
|
+
const final = await reload(environment, seeded.id);
|
|
2110
|
+
assertEqual(final.version, committedA.version, "the stale writer must not replace writer A's version");
|
|
2111
|
+
if (snapshotState) assert(deepEqual(snapshotState.call(harness, final), snapshotState.call(harness, committedA)), "the stale writer must not replace writer A's state");
|
|
2112
|
+
assert(deepEqual(eventIds(await environment.committedOutboxEvents()), eventIds(outboxAfterA)), "a rejected stale flush must add no outbox records");
|
|
2113
|
+
})
|
|
2114
|
+
},
|
|
2115
|
+
{
|
|
2116
|
+
name: "rollback acknowledges nothing and commits neither state nor outbox",
|
|
2117
|
+
run: inEnvironment(async (environment) => {
|
|
2118
|
+
const aggregate = harness.createAggregate();
|
|
2119
|
+
harness.mutate(aggregate);
|
|
2120
|
+
const pending = [...aggregate.pendingEvents];
|
|
2121
|
+
await captureRejection(environment.run(async ({ repository }) => {
|
|
2122
|
+
repository.add(aggregate);
|
|
2123
|
+
throw new Error("rollback probe");
|
|
2124
|
+
}));
|
|
2125
|
+
assert(await environment.run(({ repository }) => repository.findById(aggregate.id)) === void 0, "a rolled-back add must leave no row");
|
|
2126
|
+
assertEqual((await environment.committedOutboxEvents()).length, 0, "a rolled-back add must leave no outbox record");
|
|
2127
|
+
assert(deepEqual(aggregate.pendingEvents, pending), "rollback must acknowledge none of the registered event batch");
|
|
2128
|
+
})
|
|
2129
|
+
},
|
|
2130
|
+
{
|
|
2131
|
+
name: "outbox failure rolls the already-flushed aggregate write back",
|
|
2132
|
+
run: inEnvironment(async (environment) => {
|
|
2133
|
+
const aggregate = harness.createAggregate();
|
|
2134
|
+
harness.mutate(aggregate);
|
|
2135
|
+
const pending = [...aggregate.pendingEvents];
|
|
2136
|
+
environment.failNextOutboxWrite(/* @__PURE__ */ new Error("outbox failure probe"));
|
|
2137
|
+
assert(await captureRejection(environment.run(async ({ repository }) => {
|
|
2138
|
+
repository.add(aggregate);
|
|
2139
|
+
})) !== void 0, "the outbox failure must reject");
|
|
2140
|
+
assert(await environment.run(({ repository }) => repository.findById(aggregate.id)) === void 0, "state flush must roll back when the outbox write fails");
|
|
2141
|
+
assertEqual((await environment.committedOutboxEvents()).length, 0, "failed outbox write must commit no envelope");
|
|
2142
|
+
assert(deepEqual(aggregate.pendingEvents, pending), "failed commit must acknowledge none of the event batch");
|
|
2143
|
+
})
|
|
2144
|
+
},
|
|
2145
|
+
{
|
|
2146
|
+
name: "identity map returns one instance for repeated loads in one Unit of Work",
|
|
2147
|
+
run: inEnvironment(async (environment) => {
|
|
2148
|
+
const seeded = await seed(environment);
|
|
2149
|
+
await environment.run(async ({ repository }) => {
|
|
2150
|
+
const first = await repository.findById(seeded.id);
|
|
2151
|
+
const second = await repository.findById(seeded.id);
|
|
2152
|
+
assert(first !== void 0 && first === second, "repeated reads must return the same tracked aggregate instance");
|
|
2153
|
+
});
|
|
2154
|
+
})
|
|
2155
|
+
},
|
|
2156
|
+
{
|
|
2157
|
+
name: "an unchanged explicit update is safe and emits no event",
|
|
2158
|
+
run: inEnvironment(async (environment) => {
|
|
2159
|
+
const seeded = await seed(environment);
|
|
2160
|
+
const before = await environment.committedOutboxEvents();
|
|
2161
|
+
await environment.run(async ({ repository }) => {
|
|
2162
|
+
const aggregate = await load(repository, seeded.id);
|
|
2163
|
+
repository.update(aggregate);
|
|
2164
|
+
});
|
|
2165
|
+
assert(deepEqual(eventIds(await environment.committedOutboxEvents()), eventIds(before)), "an unchanged update must not manufacture an outbox event");
|
|
2166
|
+
})
|
|
2167
|
+
}
|
|
2168
|
+
];
|
|
2169
|
+
tests.push(gatedContractTest({
|
|
2170
|
+
capability: createAggregateWithId ? "insertsAreDuplicateChecked" : "createAggregateWithId",
|
|
2171
|
+
satisfiedBy: Boolean(createAggregateWithId) && insertsAreDuplicateChecked
|
|
2172
|
+
}, {
|
|
2173
|
+
name: "duplicate add rejects and preserves the existing aggregate",
|
|
2174
|
+
run: inEnvironment(async (environment) => {
|
|
2175
|
+
assert(createAggregateWithId !== void 0, "capability gate");
|
|
2176
|
+
const seeded = await seed(environment);
|
|
2177
|
+
const duplicate = createAggregateWithId.call(harness, seeded.id);
|
|
2178
|
+
harness.mutate(duplicate);
|
|
2179
|
+
harness.mutate(duplicate);
|
|
2180
|
+
const rejection = await captureRejection(environment.run(async ({ repository }) => {
|
|
2181
|
+
repository.add(duplicate);
|
|
2182
|
+
}));
|
|
2183
|
+
assertChainContainsKitError(rejection, ["DUPLICATE_AGGREGATE"], `duplicate add must reject with (or wrap) DuplicateAggregateError; map your driver's unique-violation signal instead of a retryable conflict; got ${describeError(rejection)}`);
|
|
2184
|
+
const final = await reload(environment, seeded.id);
|
|
2185
|
+
assertEqual(final.version, seeded.version, "the existing row must be untouched by the rejected duplicate add; a duplicate check firing after the write clobbers it");
|
|
2186
|
+
if (snapshotState) assert(deepEqual(snapshotState.call(harness, final), snapshotState.call(harness, seeded)), "the existing row's state must be untouched by the rejected duplicate add");
|
|
2187
|
+
})
|
|
2188
|
+
}));
|
|
2189
|
+
tests.push(gatedContractTest({
|
|
2190
|
+
capability: "mutateVersionOnly",
|
|
2191
|
+
satisfiedBy: Boolean(mutateVersionOnly)
|
|
2192
|
+
}, {
|
|
2193
|
+
name: "version-only change still persists (skip-save must not desync the OCC baseline)",
|
|
2194
|
+
run: inEnvironment(async (environment) => {
|
|
2195
|
+
assert(mutateVersionOnly !== void 0, "capability gate");
|
|
2196
|
+
const seeded = await seed(environment);
|
|
2197
|
+
const outboxBefore = await environment.committedOutboxEvents();
|
|
2198
|
+
await environment.run(async ({ repository }) => {
|
|
2199
|
+
const aggregate = await load(repository, seeded.id);
|
|
2200
|
+
mutateVersionOnly.call(harness, aggregate);
|
|
2201
|
+
repository.update(aggregate);
|
|
2202
|
+
});
|
|
2203
|
+
assertEqual((await reload(environment, seeded.id)).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
|
+
assert(deepEqual(eventIds(await environment.committedOutboxEvents()), eventIds(outboxBefore)), "state-only update must not create an outbox event");
|
|
2205
|
+
})
|
|
2206
|
+
}));
|
|
2207
|
+
tests.push(gatedContractTest({
|
|
2208
|
+
capability: "mutateChildCollection",
|
|
2209
|
+
satisfiedBy: Boolean(mutateChildCollection)
|
|
2210
|
+
}, {
|
|
2211
|
+
name: "nested collection changes survive the adapter change-set projection",
|
|
2212
|
+
run: inEnvironment(async (environment) => {
|
|
2213
|
+
assert(mutateChildCollection !== void 0, "capability gate");
|
|
2214
|
+
const seeded = await seed(environment);
|
|
2215
|
+
await environment.run(async ({ repository }) => {
|
|
2216
|
+
const aggregate = await load(repository, seeded.id);
|
|
2217
|
+
mutateChildCollection.call(harness, aggregate);
|
|
2218
|
+
repository.update(aggregate);
|
|
2219
|
+
});
|
|
2220
|
+
assertEqual((await reload(environment, seeded.id)).version, seeded.version + 1, "nested collection update must advance the persisted root version");
|
|
2221
|
+
})
|
|
2222
|
+
}));
|
|
2223
|
+
tests.push(gatedContractTest({
|
|
2224
|
+
capability: "removesAreSupported",
|
|
2225
|
+
satisfiedBy: removesAreSupported
|
|
2226
|
+
}, {
|
|
2227
|
+
name: "remove tombstones the identity and physically removes at commit",
|
|
2228
|
+
run: inEnvironment(async (environment) => {
|
|
2229
|
+
const seeded = await seed(environment);
|
|
2230
|
+
await environment.run(async ({ repository }) => {
|
|
2231
|
+
assert(repository.remove !== void 0, "remove capability gate");
|
|
2232
|
+
const aggregate = await load(repository, seeded.id);
|
|
2233
|
+
repository.remove(aggregate);
|
|
2234
|
+
assert(await repository.findById(seeded.id) === void 0, "a removed aggregate is immediately absent from the Unit of Work");
|
|
2235
|
+
});
|
|
2236
|
+
assert(await environment.run(({ repository }) => repository.findById(seeded.id)) === void 0, "remove must physically remove the aggregate after commit");
|
|
2237
|
+
})
|
|
2238
|
+
}));
|
|
2239
|
+
tests.push(gatedContractTest({
|
|
2240
|
+
capability: "removesAreVersionChecked",
|
|
2241
|
+
satisfiedBy: removesAreVersionChecked
|
|
2242
|
+
}, {
|
|
2243
|
+
name: "stale remove conflicts and cannot delete a concurrent update",
|
|
2244
|
+
run: inEnvironment(async (environment) => {
|
|
2245
|
+
const seeded = await seed(environment);
|
|
2246
|
+
let loaded;
|
|
2247
|
+
const staleLoaded = new Promise((resolve) => {
|
|
2248
|
+
loaded = resolve;
|
|
2249
|
+
});
|
|
2250
|
+
let release;
|
|
2251
|
+
const mayRemove = new Promise((resolve) => {
|
|
2252
|
+
release = resolve;
|
|
2253
|
+
});
|
|
2254
|
+
const staleRemove = environment.run(async ({ repository }) => {
|
|
2255
|
+
assert(repository.remove !== void 0, "remove capability gate");
|
|
2256
|
+
const stale = await load(repository, seeded.id);
|
|
2257
|
+
loaded();
|
|
2258
|
+
await mayRemove;
|
|
2259
|
+
repository.remove(stale);
|
|
2260
|
+
});
|
|
2261
|
+
await staleLoaded;
|
|
2262
|
+
await environment.run(async ({ repository }) => {
|
|
2263
|
+
const current = await load(repository, seeded.id);
|
|
2264
|
+
harness.mutate(current);
|
|
2265
|
+
repository.update(current);
|
|
2266
|
+
});
|
|
2267
|
+
release();
|
|
2268
|
+
const rejection = await captureRejection(staleRemove);
|
|
2269
|
+
assertChainContainsKitError(rejection, ["CONCURRENCY_CONFLICT"], `stale remove must reject with ConcurrencyConflictError; got ${describeError(rejection)}`);
|
|
2270
|
+
assert(await reload(environment, seeded.id) !== void 0, "stale remove must not delete the concurrent winner");
|
|
2271
|
+
})
|
|
2272
|
+
}));
|
|
2273
|
+
return tests;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
//#endregion
|
|
2277
|
+
//#region src/testing/snapshot-store-contract.ts
|
|
2278
|
+
const AT = /* @__PURE__ */ new Date("2026-01-05T10:20:30.456Z");
|
|
2279
|
+
function snapshot(version, state, schemaVersion) {
|
|
2280
|
+
return {
|
|
2281
|
+
state,
|
|
2282
|
+
version,
|
|
2283
|
+
snapshotAt: new Date(AT),
|
|
2284
|
+
...schemaVersion === void 0 ? {} : { schemaVersion }
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
const id = (value) => value;
|
|
2288
|
+
const address = (aggregateType, aggregateId) => ({
|
|
2289
|
+
aggregateType,
|
|
2290
|
+
aggregateId: id(aggregateId)
|
|
2291
|
+
});
|
|
2292
|
+
/**
|
|
2293
|
+
* The snapshot-store contract test suite: the proof that an adapter
|
|
2294
|
+
* delivers the round-trip and isolation semantics the
|
|
2295
|
+
* snapshot-plus-recent-events load path relies on. Store semantics are
|
|
2296
|
+
* an **adapter contract, not a kit guarantee**; this suite is how an
|
|
2297
|
+
* adapter demonstrates them.
|
|
2298
|
+
*
|
|
2299
|
+
* Framework-agnostic: bind with
|
|
2300
|
+
* `(test.skipped ? it.skip : it)(test.name, test.run)`.
|
|
2301
|
+
*/
|
|
2302
|
+
function createSnapshotStoreContractTests(harness) {
|
|
2303
|
+
const inEnv = bindContractEnvironment(() => harness.createEnvironment());
|
|
2304
|
+
return [
|
|
2305
|
+
{
|
|
2306
|
+
name: "an aggregate without a snapshot loads undefined",
|
|
2307
|
+
run: inEnv(async (env) => {
|
|
2308
|
+
assertEqual(await env.store.load(address("Order", "o-1")), void 0, "a fresh store must report no snapshot; the repository falls back to full replay");
|
|
2309
|
+
})
|
|
2310
|
+
},
|
|
2311
|
+
{
|
|
2312
|
+
name: "save/load round-trips state, version, snapshotAt (millisecond fidelity), and schemaVersion",
|
|
2313
|
+
run: inEnv(async (env) => {
|
|
2314
|
+
const stored = snapshot(42, {
|
|
2315
|
+
total: 7,
|
|
2316
|
+
items: [{
|
|
2317
|
+
sku: "a",
|
|
2318
|
+
qty: 2
|
|
2319
|
+
}],
|
|
2320
|
+
note: "hi"
|
|
2321
|
+
}, 3);
|
|
2322
|
+
await env.store.save(address("Order", "o-1"), stored);
|
|
2323
|
+
const loaded = await env.store.load(address("Order", "o-1"));
|
|
2324
|
+
assert(loaded !== void 0, "the saved snapshot must load");
|
|
2325
|
+
assert(deepEqual(loaded.state, stored.state), "the state must round-trip deep-equal as plain data");
|
|
2326
|
+
assertEqual(loaded.version, 42, "the aggregate version must round-trip");
|
|
2327
|
+
assert(loaded.snapshotAt instanceof Date, "snapshotAt must round-trip as a Date; rehydrate your storage format (ISO-8601 text, epoch ms) on load");
|
|
2328
|
+
assertEqual(loaded.snapshotAt.getTime(), AT.getTime(), "snapshotAt must survive with millisecond precision (store ISO-8601 text or epoch ms)");
|
|
2329
|
+
assertEqual(loaded.schemaVersion, 3, "schemaVersion must round-trip verbatim; the restore path compares it against the aggregate's declared schema");
|
|
2330
|
+
})
|
|
2331
|
+
},
|
|
2332
|
+
{
|
|
2333
|
+
name: "an absent schemaVersion round-trips as absent",
|
|
2334
|
+
run: inEnv(async (env) => {
|
|
2335
|
+
await env.store.save(address("Order", "o-1"), snapshot(1, {
|
|
2336
|
+
total: 0,
|
|
2337
|
+
items: []
|
|
2338
|
+
}));
|
|
2339
|
+
assertEqual((await env.store.load(address("Order", "o-1")))?.schemaVersion, void 0, "a snapshot stored without schemaVersion must not come back with a fabricated one; restore treats absence as schema 1");
|
|
2340
|
+
})
|
|
2341
|
+
},
|
|
2342
|
+
{
|
|
2343
|
+
name: "save replaces the previous snapshot: latest wins, no history",
|
|
2344
|
+
run: inEnv(async (env) => {
|
|
2345
|
+
await env.store.save(address("Order", "o-1"), snapshot(10, {
|
|
2346
|
+
total: 1,
|
|
2347
|
+
items: []
|
|
2348
|
+
}));
|
|
2349
|
+
await env.store.save(address("Order", "o-1"), snapshot(20, {
|
|
2350
|
+
total: 2,
|
|
2351
|
+
items: []
|
|
2352
|
+
}));
|
|
2353
|
+
const loaded = await env.store.load(address("Order", "o-1"));
|
|
2354
|
+
assert(loaded?.version === 20 && loaded.state.total === 2, "load must return the latest snapshot only");
|
|
2355
|
+
})
|
|
2356
|
+
},
|
|
2357
|
+
{
|
|
2358
|
+
name: "snapshots are isolated per aggregate type AND per aggregate id",
|
|
2359
|
+
run: inEnv(async (env) => {
|
|
2360
|
+
await env.store.save(address("Order", "x-1"), snapshot(1, {
|
|
2361
|
+
total: 1,
|
|
2362
|
+
items: []
|
|
2363
|
+
}));
|
|
2364
|
+
await env.store.save(address("Invoice", "x-1"), snapshot(2, {
|
|
2365
|
+
total: 2,
|
|
2366
|
+
items: []
|
|
2367
|
+
}));
|
|
2368
|
+
await env.store.save(address("Order", "x-2"), snapshot(3, {
|
|
2369
|
+
total: 3,
|
|
2370
|
+
items: []
|
|
2371
|
+
}));
|
|
2372
|
+
const [orderX1, invoiceX1, orderX2] = await Promise.all([
|
|
2373
|
+
env.store.load(address("Order", "x-1")),
|
|
2374
|
+
env.store.load(address("Invoice", "x-1")),
|
|
2375
|
+
env.store.load(address("Order", "x-2"))
|
|
2376
|
+
]);
|
|
2377
|
+
assert(orderX1?.version === 1 && invoiceX1?.version === 2 && orderX2?.version === 3, "the key is the (aggregateType, aggregateId) pair; neither half may bleed into the other");
|
|
2378
|
+
})
|
|
2379
|
+
},
|
|
2380
|
+
{
|
|
2381
|
+
name: "delete removes exactly the addressed snapshot and tolerates unknown keys",
|
|
2382
|
+
run: inEnv(async (env) => {
|
|
2383
|
+
await env.store.save(address("Order", "o-1"), snapshot(1, {
|
|
2384
|
+
total: 1,
|
|
2385
|
+
items: []
|
|
2386
|
+
}));
|
|
2387
|
+
await env.store.save(address("Order", "o-2"), snapshot(2, {
|
|
2388
|
+
total: 2,
|
|
2389
|
+
items: []
|
|
2390
|
+
}));
|
|
2391
|
+
await env.store.delete(address("Order", "o-1"));
|
|
2392
|
+
await env.store.delete(address("Order", "never-saved"));
|
|
2393
|
+
assertEqual(await env.store.load(address("Order", "o-1")), void 0, "the deleted snapshot must be gone (schema-migration fallback and erasure both rely on it)");
|
|
2394
|
+
assertEqual((await env.store.load(address("Order", "o-2")))?.version, 2, "a sibling snapshot must survive the delete");
|
|
2395
|
+
})
|
|
2396
|
+
},
|
|
2397
|
+
{
|
|
2398
|
+
name: "loads are detached copies and saves capture the input: later mutations touch nothing",
|
|
2399
|
+
run: inEnv(async (env) => {
|
|
2400
|
+
const input = snapshot(5, {
|
|
2401
|
+
total: 5,
|
|
2402
|
+
items: [{
|
|
2403
|
+
sku: "a",
|
|
2404
|
+
qty: 1
|
|
2405
|
+
}]
|
|
2406
|
+
});
|
|
2407
|
+
await env.store.save(address("Order", "o-1"), input);
|
|
2408
|
+
input.state.items.push({
|
|
2409
|
+
sku: "hacked",
|
|
2410
|
+
qty: 99
|
|
2411
|
+
});
|
|
2412
|
+
const loaded = await env.store.load(address("Order", "o-1"));
|
|
2413
|
+
assert(loaded !== void 0, "expected the saved snapshot");
|
|
2414
|
+
assertEqual(loaded.state.items.length, 1, "save must capture the snapshot by value, not hold the caller's reference");
|
|
2415
|
+
loaded.state.total = 999;
|
|
2416
|
+
assertEqual((await env.store.load(address("Order", "o-1")))?.state.total, 5, "load must hand out a detached copy, never live internal state");
|
|
2417
|
+
})
|
|
2418
|
+
}
|
|
2419
|
+
];
|
|
1207
2420
|
}
|
|
1208
|
-
__name(createRepositoryContractTests, "createRepositoryContractTests");
|
|
1209
2421
|
|
|
1210
|
-
|
|
1211
|
-
|
|
2422
|
+
//#endregion
|
|
2423
|
+
export { createCommandOutboxContractTests, createDeadlineStoreContractTests, createEsRepositoryContractTests, createEventStoreContractTests, createIdempotencyStoreContractTests, createOutboxContractTests, createProjectionCheckpointStoreContractTests, createRepositoryContractTests, createSnapshotStoreContractTests };
|
|
1212
2424
|
//# sourceMappingURL=testing.js.map
|