@shirudo/ddd-kit 2.2.0 → 3.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/testing.js CHANGED
@@ -1,1212 +1,2424 @@
1
- var __defProp = Object.defineProperty;
2
- var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
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
- // src/utils/array/is-built-in.ts
5
- var BUILT_IN_TAGS = /* @__PURE__ */ new Set([
6
- "[object Date]",
7
- "[object RegExp]",
8
- "[object Map]",
9
- "[object Set]",
10
- "[object WeakMap]",
11
- "[object WeakSet]",
12
- "[object Promise]",
13
- "[object Error]",
14
- "[object Boolean]",
15
- "[object Number]",
16
- "[object String]",
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
- const env = await createEnvironment();
368
- let bodyFailed = false;
369
- let bodyError;
370
- try {
371
- await body(env);
372
- } catch (error) {
373
- bodyFailed = true;
374
- bodyError = error;
375
- }
376
- try {
377
- await env.teardown?.();
378
- } catch (teardownError) {
379
- if (!bodyFailed) {
380
- throw teardownError;
381
- }
382
- }
383
- if (bodyFailed) {
384
- throw bodyError;
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
- __name(runInContractEnvironment, "runInContractEnvironment");
43
+ /** Resolves to the rejection reason, or `undefined` when the promise resolved. */
388
44
  function captureRejection(promise) {
389
- return promise.then(
390
- () => void 0,
391
- (error) => error
392
- );
45
+ return promise.then(() => void 0, (error) => error);
393
46
  }
394
- __name(captureRejection, "captureRejection");
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
- const loaded = await repository.getById(id);
397
- assert(
398
- loaded !== null,
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
- __name(loadAggregateOrFail, "loadAggregateOrFail");
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
- return {
406
- name,
407
- skipped: { capability },
408
- run: /* @__PURE__ */ __name(async () => {
409
- throw new Error(
410
- `Repository 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 one closes a real OCC hole.`
411
- );
412
- }, "run")
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
- if (!condition) {
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
- if (actual !== expected) {
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
- __name(assertEqual, "assertEqual");
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
- const seen = /* @__PURE__ */ new Set();
432
- let current = error;
433
- while (current !== null && current !== void 0 && !seen.has(current)) {
434
- if (typeof current !== "object") {
435
- return false;
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
- __name(chainContainsErrorNamed, "chainContainsErrorNamed");
450
- function chainContainsErrorNamedAnyOf(error, names) {
451
- return names.some((name) => chainContainsErrorNamed(error, name));
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
- try {
456
- if (candidate.name === name) {
457
- return true;
458
- }
459
- } catch {
460
- }
461
- try {
462
- let proto = Object.getPrototypeOf(candidate);
463
- for (let depth = 0; proto !== null && depth < 20; depth++) {
464
- if (proto.constructor?.name === name) {
465
- return true;
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
- if (error instanceof Error) {
476
- return `${error.name}: ${error.message}`;
477
- }
478
- return String(error);
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
- // src/testing/es-repository-contract.ts
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
- const withEnvironment = /* @__PURE__ */ __name((body) => runInContractEnvironment(() => harness.createEnvironment(), body), "withEnvironment");
485
- const loadOrFail = /* @__PURE__ */ __name((repository, id) => loadAggregateOrFail(
486
- repository,
487
- id,
488
- "broken replay read or a write that did not commit"
489
- ), "loadOrFail");
490
- async function seed(env) {
491
- const aggregate = harness.createAggregate();
492
- await env.run(async ({ repository }) => {
493
- await repository.save(aggregate);
494
- });
495
- return aggregate;
496
- }
497
- __name(seed, "seed");
498
- async function reload(env, id) {
499
- return env.run(({ repository }) => loadOrFail(repository, id));
500
- }
501
- __name(reload, "reload");
502
- const orderedIds = /* @__PURE__ */ __name((events) => events.map((event) => event.eventId), "orderedIds");
503
- const sortedIds = /* @__PURE__ */ __name((events) => orderedIds(events).sort(), "sortedIds");
504
- const snapshotState = harness.snapshotState;
505
- const createAggregateWithId = harness.createAggregateWithId;
506
- const skippedTest = /* @__PURE__ */ __name((name, capability) => skippedContractTest(name, capability), "skippedTest");
507
- const tests = [
508
- {
509
- name: "MANDATORY two-writer conflict: the stale writer's append throws ConcurrencyConflictError and the stream is untouched",
510
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
511
- const seeded = await seed(env);
512
- const seedStream = await env.committedStreamEvents(seeded.id);
513
- assert(
514
- seedStream.length > 0,
515
- "seeding must have appended the creation event to the stream"
516
- );
517
- const staleB = await reload(env, seeded.id);
518
- const committedA = await env.run(async ({ repository }) => {
519
- const a = await loadOrFail(repository, seeded.id);
520
- harness.mutate(a);
521
- await repository.save(a);
522
- return a;
523
- });
524
- const streamAfterA = await env.committedStreamEvents(seeded.id);
525
- assert(
526
- streamAfterA.length === seedStream.length + 1,
527
- "writer A's event must reach the stream on commit"
528
- );
529
- harness.mutate(staleB);
530
- harness.mutate(staleB);
531
- const rejection = await captureRejection(
532
- env.run(async ({ repository }) => {
533
- await repository.save(staleB);
534
- })
535
- );
536
- assert(
537
- rejection !== void 0,
538
- "the second writer's commit must reject; it appended on a stale expectedVersion instead (append guard missing?)"
539
- );
540
- assert(
541
- chainContainsErrorNamed(rejection, "ConcurrencyConflictError"),
542
- `the second writer's rejection must be (or wrap, via the cause chain) ConcurrencyConflictError; got: ${describeError(rejection)}`
543
- );
544
- const finalStream = await env.committedStreamEvents(seeded.id);
545
- assert(
546
- deepEqual(orderedIds(finalStream), orderedIds(streamAfterA)),
547
- "the stream must contain exactly the winning writer's events in order: a rejected append must leave the stream untouched"
548
- );
549
- const final = await reload(env, seeded.id);
550
- assertEqual(
551
- final.version,
552
- committedA.version,
553
- "the reloaded version must equal writer A's committed version (version IS the event count)"
554
- );
555
- if (snapshotState) {
556
- assert(
557
- deepEqual(
558
- snapshotState.call(harness, final),
559
- snapshotState.call(harness, committedA)
560
- ),
561
- "the reloaded state must fold to writer A's state. Suspects: a partial append survived the rejection, or your snapshotState projection is not roundtrip-stable"
562
- );
563
- }
564
- const outbox = await env.committedOutboxEvents();
565
- assert(
566
- deepEqual(sortedIds(outbox), sortedIds(streamAfterA)),
567
- "the outbox must contain exactly the committed events (compared by eventId); nothing from the stale writer"
568
- );
569
- }), "run")
570
- },
571
- {
572
- name: "replay equality: a reloaded aggregate folds the committed stream in emission order",
573
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
574
- const aggregate = harness.createAggregate();
575
- harness.mutate(aggregate);
576
- harness.mutate(aggregate);
577
- const emittedIds = orderedIds(aggregate.pendingEvents);
578
- assertEqual(
579
- emittedIds.length,
580
- 3,
581
- "harness contract: createAggregate applies ONE creation event and mutate applies ONE event each"
582
- );
583
- await env.run(async ({ repository }) => {
584
- await repository.save(aggregate);
585
- });
586
- const stream = await env.committedStreamEvents(aggregate.id);
587
- assert(
588
- deepEqual(orderedIds(stream), emittedIds),
589
- "the committed stream must contain exactly the emitted events in emission order; reordering breaks every consumer's fold"
590
- );
591
- assertEqual(
592
- aggregate.pendingEvents.length,
593
- 0,
594
- "pending events must be cleared after a successful commit (markPersisted ran)"
595
- );
596
- assertEqual(
597
- aggregate.persistedVersion,
598
- aggregate.version,
599
- "after a successful commit, persistedVersion must equal version"
600
- );
601
- const reloaded = await reload(env, aggregate.id);
602
- assertEqual(
603
- reloaded.version,
604
- aggregate.version,
605
- "the reloaded version must equal the event count"
606
- );
607
- assertEqual(
608
- reloaded.persistedVersion,
609
- reloaded.version,
610
- "a reloaded aggregate's persistedVersion must equal its version"
611
- );
612
- assertEqual(
613
- reloaded.pendingEvents.length,
614
- 0,
615
- "a reloaded aggregate must not carry pending events (replay is not re-recording)"
616
- );
617
- if (snapshotState) {
618
- assert(
619
- deepEqual(
620
- snapshotState.call(harness, reloaded),
621
- snapshotState.call(harness, aggregate)
622
- ),
623
- "the reloaded aggregate must fold to the same state as the in-memory instance. Suspects: fold order (readStream must return emission order), or a snapshotState projection that is not roundtrip-stable"
624
- );
625
- }
626
- }), "run")
627
- },
628
- {
629
- name: "getById returns null for a stream that does not exist",
630
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
631
- const never = harness.createAggregate();
632
- const probe = await env.run(
633
- ({ repository }) => repository.getById(never.id)
634
- );
635
- assert(
636
- probe === null,
637
- "getById of a never-persisted id must return null (empty stream = no aggregate)"
638
- );
639
- }), "run")
640
- },
641
- {
642
- name: "identity map: two getById calls in one unit of work return the same instance",
643
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
644
- const seeded = await seed(env);
645
- await env.run(async ({ repository }) => {
646
- const first = await repository.getById(seeded.id);
647
- const second = await repository.getById(seeded.id);
648
- assert(
649
- first !== null && first === second,
650
- "repeated loads within one unit of work must return the SAME instance (identity map); distinct instances double-harvest events"
651
- );
652
- });
653
- }), "run")
654
- },
655
- {
656
- name: "rollback persists nothing: stream and outbox untouched, pending events survive, first save can be retried",
657
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
658
- const aggregate = harness.createAggregate();
659
- harness.mutate(aggregate);
660
- const pendingBefore = aggregate.pendingEvents.length;
661
- await captureRejection(
662
- env.run(async ({ repository }) => {
663
- await repository.save(aggregate);
664
- throw new Error("contract rollback probe");
665
- })
666
- );
667
- assertEqual(
668
- (await env.committedStreamEvents(aggregate.id)).length,
669
- 0,
670
- "a rolled-back transaction must not leave events in the stream"
671
- );
672
- assertEqual(
673
- (await env.committedOutboxEvents()).length,
674
- 0,
675
- "a rolled-back transaction must not leave events in the outbox"
676
- );
677
- assertEqual(
678
- aggregate.pendingEvents.length,
679
- pendingBefore,
680
- "pending events must survive a rollback (so the first save can be retried)"
681
- );
682
- assert(
683
- aggregate.persistedVersion === void 0,
684
- "a rolled-back first save must leave persistedVersion undefined (the stream does not exist)"
685
- );
686
- await env.run(async ({ repository }) => {
687
- await repository.save(aggregate);
688
- });
689
- assertEqual(
690
- (await env.committedStreamEvents(aggregate.id)).length,
691
- pendingBefore,
692
- "the retried first save must append the full pending history"
693
- );
694
- assertEqual(
695
- aggregate.persistedVersion,
696
- aggregate.version,
697
- "after the successful retry, persistedVersion must equal version"
698
- );
699
- }), "run")
700
- },
701
- {
702
- name: "readStream honors fromVersion: the snapshot catch-up read returns exactly the events after the position",
703
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
704
- const aggregate = harness.createAggregate();
705
- harness.mutate(aggregate);
706
- harness.mutate(aggregate);
707
- await env.run(async ({ repository }) => {
708
- await repository.save(aggregate);
709
- });
710
- const full = await env.committedStreamEvents(aggregate.id);
711
- assertEqual(full.length, 3, "seeding must have committed 3 events");
712
- const afterOne = await env.committedStreamEvents(aggregate.id, 1);
713
- assert(
714
- deepEqual(orderedIds(afterOne), orderedIds(full.slice(1))),
715
- "fromVersion=1 must return exactly the events after stream position 1, in order; restoreFromSnapshotWithEvents replays exactly this window"
716
- );
717
- const afterAll = await env.committedStreamEvents(
718
- aggregate.id,
719
- full.length
720
- );
721
- assertEqual(
722
- afterAll.length,
723
- 0,
724
- "fromVersion at the stream head must return no events"
725
- );
726
- }), "run")
727
- }
728
- ];
729
- tests.push(
730
- createAggregateWithId ? {
731
- name: "duplicate create: two creators racing on one stream; the second append conflicts and the stream is untouched",
732
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
733
- const seeded = await seed(env);
734
- const seedStream = await env.committedStreamEvents(seeded.id);
735
- const duplicate = createAggregateWithId.call(
736
- harness,
737
- seeded.id
738
- );
739
- const rejection = await captureRejection(
740
- env.run(async ({ repository }) => {
741
- await repository.save(duplicate);
742
- })
743
- );
744
- assert(
745
- chainContainsErrorNamedAnyOf(rejection, [
746
- "ConcurrencyConflictError",
747
- "DuplicateAggregateError"
748
- ]),
749
- `the duplicate creator's append (expectedVersion 0 on an existing stream) must reject with (or wrap) ConcurrencyConflictError or DuplicateAggregateError; got: ${describeError(rejection)}`
750
- );
751
- const finalStream = await env.committedStreamEvents(seeded.id);
752
- assert(
753
- deepEqual(orderedIds(finalStream), orderedIds(seedStream)),
754
- "the existing stream must be untouched by the rejected duplicate create"
755
- );
756
- }), "run")
757
- } : skippedTest(
758
- "duplicate create: two creators racing on one stream; the second append conflicts and the stream is untouched",
759
- "createAggregateWithId"
760
- )
761
- );
762
- return tests;
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
- // src/testing/repository-contract.ts
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
- const withEnvironment = /* @__PURE__ */ __name((body) => runInContractEnvironment(() => harness.createEnvironment(), body), "withEnvironment");
769
- const loadOrFail = /* @__PURE__ */ __name((repository, id) => loadAggregateOrFail(
770
- repository,
771
- id,
772
- "broken hydration or a write that did not commit"
773
- ), "loadOrFail");
774
- async function seed(env) {
775
- const aggregate = harness.createAggregate();
776
- harness.mutate(aggregate);
777
- await env.run(async ({ repository }) => {
778
- await repository.save(aggregate);
779
- });
780
- return aggregate;
781
- }
782
- __name(seed, "seed");
783
- async function reload(env, id) {
784
- return env.run(({ repository }) => loadOrFail(repository, id));
785
- }
786
- __name(reload, "reload");
787
- const eventIds = /* @__PURE__ */ __name((events) => events.map((event) => event.eventId).sort(), "eventIds");
788
- const snapshotState = harness.snapshotState;
789
- const mutateVersionOnly = harness.mutateVersionOnly;
790
- const mutateChildCollection = harness.mutateChildCollection;
791
- const createAggregateWithId = harness.createAggregateWithId;
792
- const deletesAreVersionChecked = harness.deletesAreVersionChecked === true;
793
- const insertsAreDuplicateChecked = harness.insertsAreDuplicateChecked !== false;
794
- const skippedTest = /* @__PURE__ */ __name((name, capability) => skippedContractTest(name, capability), "skippedTest");
795
- const tests = [
796
- {
797
- name: "MANDATORY two-writer conflict: the stale writer throws ConcurrencyConflictError and persists nothing",
798
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
799
- const seeded = await seed(env);
800
- const seedEvents = await env.committedOutboxEvents();
801
- const seedEventIds = new Set(seedEvents.map((e) => e.eventId));
802
- const staleB = await reload(env, seeded.id);
803
- const committedA = await env.run(async ({ repository }) => {
804
- const a = await loadOrFail(repository, seeded.id);
805
- harness.mutate(a);
806
- await repository.save(a);
807
- return a;
808
- });
809
- const outboxAfterA = await env.committedOutboxEvents();
810
- assert(
811
- outboxAfterA.length > seedEvents.length,
812
- "writer A's events must reach the outbox on commit"
813
- );
814
- const newSinceSeed = outboxAfterA.filter(
815
- (event) => !seedEventIds.has(event.eventId)
816
- );
817
- assert(
818
- newSinceSeed.length > 0 && newSinceSeed.every(
819
- (event) => event.aggregateVersion === committedA.version
820
- ),
821
- `writer A's committed outbox events must carry aggregateVersion === ${committedA.version} (A's commit version). Suspect #1: your outbox read-back (committedOutboxEvents) reconstructs events from an explicit column list and drops or string-types the aggregateVersion field. Suspect #2: a hand-rolled orchestration that does not stamp aggregateVersion = aggregate.version at harvest (withCommit does this automatically).`
822
- );
823
- harness.mutate(staleB);
824
- const rejection = await captureRejection(
825
- env.run(async ({ repository }) => {
826
- await repository.save(staleB);
827
- })
828
- );
829
- assert(
830
- rejection !== void 0,
831
- "the second writer's commit must reject - it committed on a stale version instead (OCC predicate missing?)"
832
- );
833
- assert(
834
- chainContainsErrorNamed(rejection, "ConcurrencyConflictError"),
835
- `the second writer's rejection must be (or wrap, via the cause chain) ConcurrencyConflictError; got: ${describeError(rejection)}`
836
- );
837
- const final = await reload(env, seeded.id);
838
- assertEqual(
839
- final.version,
840
- committedA.version,
841
- "the persisted version must equal writer A's committed version"
842
- );
843
- if (snapshotState) {
844
- assert(
845
- deepEqual(
846
- snapshotState.call(harness, final),
847
- snapshotState.call(harness, committedA)
848
- ),
849
- "the persisted STATE must equal writer A's. Two suspects: (a) a predicate that guards only the version write lets the stale writer's state survive; (b) your snapshotState projection is not roundtrip-stable (date precision, undefined-valued keys, decimal representation) - see its JSDoc"
850
- );
851
- }
852
- const outboxFinal = await env.committedOutboxEvents();
853
- assert(
854
- deepEqual(eventIds(outboxFinal), eventIds(outboxAfterA)),
855
- "the outbox must contain exactly the winning writer's events (compared by eventId) - nothing from the stale writer, nothing replaced"
856
- );
857
- }), "run")
858
- },
859
- {
860
- name: "insert routing: a never-persisted aggregate INSERTs even after pre-save mutations",
861
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
862
- const aggregate = harness.createAggregate();
863
- assert(
864
- aggregate.persistedVersion === void 0,
865
- "harness contract: createAggregate() must return a never-persisted aggregate (persistedVersion === undefined)"
866
- );
867
- harness.mutate(aggregate);
868
- harness.mutate(aggregate);
869
- await env.run(async ({ repository }) => {
870
- await repository.save(aggregate);
871
- });
872
- const loaded = await reload(env, aggregate.id);
873
- assertEqual(
874
- loaded.version,
875
- aggregate.version,
876
- "the INSERT must persist the in-memory version (route on persistedVersion === undefined, not version === 0)"
877
- );
878
- }), "run")
879
- },
880
- {
881
- name: "update writes the in-memory version and predicates on persistedVersion",
882
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
883
- const seeded = await seed(env);
884
- const baseline = seeded.version;
885
- await env.run(async ({ repository }) => {
886
- const loaded = await loadOrFail(repository, seeded.id);
887
- harness.mutate(loaded);
888
- harness.mutate(loaded);
889
- await repository.save(loaded);
890
- });
891
- const final = await reload(env, seeded.id);
892
- assertEqual(
893
- final.version,
894
- baseline + 2,
895
- "two mutations must persist as baseline + 2 (version is a mutation sequence)"
896
- );
897
- assertEqual(
898
- final.persistedVersion,
899
- final.version,
900
- "a reloaded aggregate's persistedVersion must equal its version"
901
- );
902
- }), "run")
903
- },
904
- {
905
- name: "rollback persists nothing: state, version, and outbox untouched",
906
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
907
- const seeded = await seed(env);
908
- const versionBefore = seeded.version;
909
- const outboxBefore = (await env.committedOutboxEvents()).length;
910
- const probe = new Error("contract rollback probe");
911
- const rejection = await captureRejection(
912
- env.run(async ({ repository }) => {
913
- const loaded = await loadOrFail(repository, seeded.id);
914
- harness.mutate(loaded);
915
- await repository.save(loaded);
916
- throw probe;
917
- })
918
- );
919
- assert(
920
- rejection !== void 0,
921
- "a throwing unit of work must reject"
922
- );
923
- const final = await reload(env, seeded.id);
924
- assertEqual(
925
- final.version,
926
- versionBefore,
927
- "a rolled-back write must not change the persisted version"
928
- );
929
- assertEqual(
930
- (await env.committedOutboxEvents()).length,
931
- outboxBefore,
932
- "a rolled-back transaction must not leave events in the outbox"
933
- );
934
- }), "run")
935
- },
936
- {
937
- name: "identity map: two getById calls in one unit of work return the same instance",
938
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
939
- const seeded = await seed(env);
940
- await env.run(async ({ repository }) => {
941
- const first = await repository.getById(seeded.id);
942
- const second = await repository.getById(seeded.id);
943
- assert(
944
- first !== null && first === second,
945
- "repeated loads within one unit of work must return the SAME instance (identity map) - distinct instances double-harvest events"
946
- );
947
- });
948
- }), "run")
949
- },
950
- {
951
- name: "delete: getById returns null in the same unit of work and after the commit",
952
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
953
- const seeded = await seed(env);
954
- await env.run(async ({ repository }) => {
955
- const loaded = await loadOrFail(repository, seeded.id);
956
- await repository.delete(loaded);
957
- const probe = await repository.getById(seeded.id);
958
- assert(
959
- probe === null,
960
- "after delete, getById in the SAME unit of work must return null (isDeleted check), even if the physical delete is deferred"
961
- );
962
- });
963
- await env.run(async ({ repository }) => {
964
- const probe = await repository.getById(seeded.id);
965
- assert(
966
- probe === null,
967
- "after the deleting unit of work committed, the aggregate must be gone"
968
- );
969
- });
970
- }), "run")
971
- },
972
- {
973
- name: "deletion is final: saving the deleted aggregate in the same unit of work throws AggregateDeletedError",
974
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
975
- const seeded = await seed(env);
976
- const rejection = await captureRejection(
977
- env.run(async ({ repository }) => {
978
- const loaded = await loadOrFail(repository, seeded.id);
979
- harness.mutate(loaded);
980
- await repository.delete(loaded);
981
- await repository.save(loaded);
982
- })
983
- );
984
- assert(
985
- chainContainsErrorNamed(rejection, "AggregateDeletedError"),
986
- `save-after-delete must reject with (or wrap) AggregateDeletedError; got: ${describeError(rejection)}. If you see ConcurrencyConflictError here instead, your save() probably enrolls AFTER the row write - enroll first.`
987
- );
988
- }), "run")
989
- },
990
- {
991
- name: "events are cleared after a committed unit of work and kept after a rollback",
992
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
993
- const committed = harness.createAggregate();
994
- harness.mutate(committed);
995
- assert(
996
- committed.pendingEvents.length > 0,
997
- "harness contract: mutate() must record at least one domain event"
998
- );
999
- await env.run(async ({ repository }) => {
1000
- await repository.save(committed);
1001
- });
1002
- assertEqual(
1003
- committed.pendingEvents.length,
1004
- 0,
1005
- "pending events must be cleared after a successful commit"
1006
- );
1007
- const rolledBack = harness.createAggregate();
1008
- harness.mutate(rolledBack);
1009
- const pendingBefore = rolledBack.pendingEvents.length;
1010
- await captureRejection(
1011
- env.run(async ({ repository }) => {
1012
- await repository.save(rolledBack);
1013
- throw new Error("contract rollback probe");
1014
- })
1015
- );
1016
- assertEqual(
1017
- rolledBack.pendingEvents.length,
1018
- pendingBefore,
1019
- "pending events must survive a rollback (so a fresh load + retry can re-emit them)"
1020
- );
1021
- }), "run")
1022
- },
1023
- {
1024
- name: "persistedVersion syncs only after a successful commit",
1025
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
1026
- const aggregate = harness.createAggregate();
1027
- harness.mutate(aggregate);
1028
- await captureRejection(
1029
- env.run(async ({ repository }) => {
1030
- await repository.save(aggregate);
1031
- throw new Error("contract rollback probe");
1032
- })
1033
- );
1034
- assert(
1035
- aggregate.persistedVersion === void 0,
1036
- "a rolled-back first save must leave persistedVersion undefined (the aggregate is still unpersisted)"
1037
- );
1038
- await env.run(async ({ repository }) => {
1039
- await repository.save(aggregate);
1040
- });
1041
- assertEqual(
1042
- aggregate.persistedVersion,
1043
- aggregate.version,
1044
- "after a successful commit, persistedVersion must equal version (markPersisted ran)"
1045
- );
1046
- }), "run")
1047
- }
1048
- ];
1049
- tests.push(
1050
- mutateVersionOnly ? {
1051
- name: "version-only change still persists (skip-save must not desync the OCC baseline)",
1052
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
1053
- const seeded = await seed(env);
1054
- const baseline = seeded.version;
1055
- await env.run(async ({ repository }) => {
1056
- const loaded = await loadOrFail(repository, seeded.id);
1057
- mutateVersionOnly.call(harness, loaded);
1058
- await repository.save(loaded);
1059
- });
1060
- const final = await reload(env, seeded.id);
1061
- assertEqual(
1062
- final.version,
1063
- baseline + 1,
1064
- "a version-only change (empty changedKeys, bumped version) must still be persisted - skipping it desyncs persistedVersion and produces false ConcurrencyConflictErrors later"
1065
- );
1066
- }), "run")
1067
- } : skippedTest(
1068
- "version-only change still persists (skip-save must not desync the OCC baseline)",
1069
- "mutateVersionOnly"
1070
- )
1071
- );
1072
- tests.push(
1073
- mutateChildCollection ? {
1074
- name: "a child-collection-only change bumps the persisted root version",
1075
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
1076
- const seeded = await seed(env);
1077
- const baseline = seeded.version;
1078
- await env.run(async ({ repository }) => {
1079
- const loaded = await loadOrFail(repository, seeded.id);
1080
- mutateChildCollection.call(harness, loaded);
1081
- await repository.save(loaded);
1082
- });
1083
- const final = await reload(env, seeded.id);
1084
- assert(
1085
- final.version > baseline,
1086
- "a child-collection-only change must advance the persisted ROOT version - otherwise concurrent writers interleave with collection writes undetected"
1087
- );
1088
- }), "run")
1089
- } : skippedTest(
1090
- "a child-collection-only change bumps the persisted root version",
1091
- "mutateChildCollection"
1092
- )
1093
- );
1094
- tests.push(
1095
- createAggregateWithId ? {
1096
- name: "deletion is final across instances: a re-created aggregate with the same id cannot be saved",
1097
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
1098
- const seeded = await seed(env);
1099
- const rejection = await captureRejection(
1100
- env.run(async ({ repository }) => {
1101
- const loaded = await loadOrFail(repository, seeded.id);
1102
- await repository.delete(loaded);
1103
- const resurrected = createAggregateWithId.call(
1104
- harness,
1105
- seeded.id
1106
- );
1107
- harness.mutate(resurrected);
1108
- await repository.save(resurrected);
1109
- })
1110
- );
1111
- assert(
1112
- chainContainsErrorNamed(rejection, "AggregateDeletedError"),
1113
- `saving a re-created instance of a deleted aggregate must reject with (or wrap) AggregateDeletedError; got: ${describeError(rejection)}`
1114
- );
1115
- }), "run")
1116
- } : skippedTest(
1117
- "deletion is final across instances: a re-created aggregate with the same id cannot be saved",
1118
- "createAggregateWithId"
1119
- )
1120
- );
1121
- tests.push(
1122
- createAggregateWithId && insertsAreDuplicateChecked ? {
1123
- name: "duplicate insert: a second never-persisted aggregate with an existing id throws DuplicateAggregateError",
1124
- run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
1125
- const seeded = await seed(env);
1126
- const duplicate = createAggregateWithId.call(
1127
- harness,
1128
- seeded.id
1129
- );
1130
- harness.mutate(duplicate);
1131
- harness.mutate(duplicate);
1132
- const rejection = await captureRejection(
1133
- env.run(async ({ repository }) => {
1134
- await repository.save(duplicate);
1135
- })
1136
- );
1137
- assert(
1138
- chainContainsErrorNamed(rejection, "DuplicateAggregateError"),
1139
- `inserting a second aggregate with an existing id must reject with (or wrap) DuplicateAggregateError - map your driver's unique-violation signal (Postgres 23505, MySQL 1062, SQLite SQLITE_CONSTRAINT_UNIQUE) instead of letting the raw driver error escape; got: ${describeError(rejection)}`
1140
- );
1141
- const final = await reload(env, seeded.id);
1142
- assertEqual(
1143
- final.version,
1144
- seeded.version,
1145
- "the existing row must be untouched by the rejected duplicate insert - a duplicate check that fires AFTER the write (or outside the transaction) clobbers the existing row"
1146
- );
1147
- if (snapshotState) {
1148
- assert(
1149
- deepEqual(
1150
- snapshotState.call(harness, final),
1151
- snapshotState.call(harness, seeded)
1152
- ),
1153
- "the existing row's STATE must be untouched by the rejected duplicate insert"
1154
- );
1155
- }
1156
- }), "run")
1157
- } : skippedTest(
1158
- "duplicate insert: a second never-persisted aggregate with an existing id throws DuplicateAggregateError",
1159
- // Name the capability that is actually missing: the
1160
- // mechanical one (cannot build the duplicate) or the
1161
- // semantic opt-out (deliberately upserting adapter).
1162
- createAggregateWithId ? "insertsAreDuplicateChecked" : "createAggregateWithId"
1163
- )
1164
- );
1165
- tests.push(
1166
- deletesAreVersionChecked ? {
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
- export { createEsRepositoryContractTests, createRepositoryContractTests };
1211
- //# sourceMappingURL=testing.js.map
2422
+ //#endregion
2423
+ export { createCommandOutboxContractTests, createDeadlineStoreContractTests, createEsRepositoryContractTests, createEventStoreContractTests, createIdempotencyStoreContractTests, createOutboxContractTests, createProjectionCheckpointStoreContractTests, createRepositoryContractTests, createSnapshotStoreContractTests };
1212
2424
  //# sourceMappingURL=testing.js.map