@shirudo/ddd-kit 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/dist/{aggregate-CePTINEt.d.ts → aggregate-DFi6HlEh.d.ts} +113 -64
- package/dist/index.d.ts +876 -196
- package/dist/index.js +2027 -396
- package/dist/index.js.map +1 -1
- package/dist/presentation.js +1 -1
- package/dist/presentation.js.map +1 -1
- package/dist/testing.d.ts +124 -2
- package/dist/testing.js +535 -116
- package/dist/testing.js.map +1 -1
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +160 -18
- package/dist/utils.js.map +1 -1
- package/package.json +3 -3
package/dist/testing.js
CHANGED
|
@@ -14,6 +14,7 @@ var BUILT_IN_TAGS = /* @__PURE__ */ new Set([
|
|
|
14
14
|
"[object Boolean]",
|
|
15
15
|
"[object Number]",
|
|
16
16
|
"[object String]",
|
|
17
|
+
"[object BigInt]",
|
|
17
18
|
"[object ArrayBuffer]",
|
|
18
19
|
"[object SharedArrayBuffer]",
|
|
19
20
|
"[object DataView]"
|
|
@@ -34,11 +35,113 @@ var arrayBufferByteLengthGet = intrinsicGetter(
|
|
|
34
35
|
ArrayBuffer.prototype,
|
|
35
36
|
"byteLength"
|
|
36
37
|
);
|
|
38
|
+
var sharedArrayBufferByteLengthGet = typeof SharedArrayBuffer === "undefined" ? void 0 : intrinsicGetter(SharedArrayBuffer.prototype, "byteLength");
|
|
37
39
|
var regExpSourceGet = intrinsicGetter(RegExp.prototype, "source");
|
|
38
40
|
var booleanValueOf = Boolean.prototype.valueOf;
|
|
39
41
|
var numberValueOf = Number.prototype.valueOf;
|
|
40
42
|
var stringValueOf = String.prototype.valueOf;
|
|
43
|
+
var bigIntValueOf = BigInt.prototype.valueOf;
|
|
44
|
+
var functionToString = Function.prototype.toString;
|
|
41
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");
|
|
42
145
|
function hasBrand(obj, tag) {
|
|
43
146
|
try {
|
|
44
147
|
switch (tag) {
|
|
@@ -66,6 +169,10 @@ function hasBrand(obj, tag) {
|
|
|
66
169
|
case "[object ArrayBuffer]":
|
|
67
170
|
arrayBufferByteLengthGet.call(obj);
|
|
68
171
|
return true;
|
|
172
|
+
case "[object SharedArrayBuffer]":
|
|
173
|
+
if (!sharedArrayBufferByteLengthGet) return false;
|
|
174
|
+
sharedArrayBufferByteLengthGet.call(obj);
|
|
175
|
+
return true;
|
|
69
176
|
case "[object Boolean]":
|
|
70
177
|
booleanValueOf.call(obj);
|
|
71
178
|
return true;
|
|
@@ -75,8 +182,17 @@ function hasBrand(obj, tag) {
|
|
|
75
182
|
case "[object String]":
|
|
76
183
|
stringValueOf.call(obj);
|
|
77
184
|
return true;
|
|
78
|
-
|
|
185
|
+
case "[object BigInt]":
|
|
186
|
+
bigIntValueOf.call(obj);
|
|
79
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;
|
|
80
196
|
}
|
|
81
197
|
} catch {
|
|
82
198
|
return false;
|
|
@@ -149,12 +265,17 @@ function deepEqualInner(a, b, visited) {
|
|
|
149
265
|
}
|
|
150
266
|
if (Array.isArray(objA) || Array.isArray(objB)) {
|
|
151
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;
|
|
152
272
|
const arrA = objA;
|
|
153
273
|
const arrB = objB;
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
}
|
|
158
279
|
}
|
|
159
280
|
return true;
|
|
160
281
|
}
|
|
@@ -198,7 +319,8 @@ function deepEqualInner(a, b, visited) {
|
|
|
198
319
|
}
|
|
199
320
|
case "[object Boolean]":
|
|
200
321
|
case "[object Number]":
|
|
201
|
-
case "[object String]":
|
|
322
|
+
case "[object String]":
|
|
323
|
+
case "[object BigInt]": {
|
|
202
324
|
return sameValueZero(
|
|
203
325
|
objA.valueOf(),
|
|
204
326
|
objB.valueOf()
|
|
@@ -213,8 +335,8 @@ __name(deepEqualInner, "deepEqualInner");
|
|
|
213
335
|
function comparePlainObjects(objA, objB, visited) {
|
|
214
336
|
const recA = objA;
|
|
215
337
|
const recB = objB;
|
|
216
|
-
const stringKeysA = Object.
|
|
217
|
-
const stringKeysB = Object.
|
|
338
|
+
const stringKeysA = Object.getOwnPropertyNames(objA);
|
|
339
|
+
const stringKeysB = Object.getOwnPropertyNames(objB);
|
|
218
340
|
if (stringKeysA.length !== stringKeysB.length) return false;
|
|
219
341
|
const symbolKeysA = Object.getOwnPropertySymbols(objA);
|
|
220
342
|
const symbolKeysB = Object.getOwnPropertySymbols(objB);
|
|
@@ -240,42 +362,133 @@ function comparePlainObjects(objA, objB, visited) {
|
|
|
240
362
|
}
|
|
241
363
|
__name(comparePlainObjects, "comparePlainObjects");
|
|
242
364
|
|
|
243
|
-
// src/testing/
|
|
244
|
-
function
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
365
|
+
// src/testing/contract-assertions.ts
|
|
366
|
+
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;
|
|
254
381
|
}
|
|
382
|
+
}
|
|
383
|
+
if (bodyFailed) {
|
|
384
|
+
throw bodyError;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
__name(runInContractEnvironment, "runInContractEnvironment");
|
|
388
|
+
function captureRejection(promise) {
|
|
389
|
+
return promise.then(
|
|
390
|
+
() => void 0,
|
|
391
|
+
(error) => error
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
__name(captureRejection, "captureRejection");
|
|
395
|
+
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;
|
|
402
|
+
}
|
|
403
|
+
__name(loadAggregateOrFail, "loadAggregateOrFail");
|
|
404
|
+
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
|
+
};
|
|
414
|
+
}
|
|
415
|
+
__name(skippedContractTest, "skippedContractTest");
|
|
416
|
+
function assert(condition, message) {
|
|
417
|
+
if (!condition) {
|
|
418
|
+
throw new Error(`Repository contract violated: ${message}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
__name(assert, "assert");
|
|
422
|
+
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
|
+
}
|
|
428
|
+
}
|
|
429
|
+
__name(assertEqual, "assertEqual");
|
|
430
|
+
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);
|
|
255
441
|
try {
|
|
256
|
-
|
|
257
|
-
} catch
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
442
|
+
current = current.cause;
|
|
443
|
+
} catch {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
__name(chainContainsErrorNamed, "chainContainsErrorNamed");
|
|
450
|
+
function chainContainsErrorNamedAnyOf(error, names) {
|
|
451
|
+
return names.some((name) => chainContainsErrorNamed(error, name));
|
|
452
|
+
}
|
|
453
|
+
__name(chainContainsErrorNamedAnyOf, "chainContainsErrorNamedAnyOf");
|
|
454
|
+
function errorMatchesName(candidate, name) {
|
|
455
|
+
try {
|
|
456
|
+
if (candidate.name === name) {
|
|
457
|
+
return true;
|
|
261
458
|
}
|
|
262
|
-
|
|
263
|
-
|
|
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);
|
|
264
468
|
}
|
|
469
|
+
} catch {
|
|
265
470
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
);
|
|
273
|
-
return loaded;
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
__name(errorMatchesName, "errorMatchesName");
|
|
474
|
+
function describeError(error) {
|
|
475
|
+
if (error instanceof Error) {
|
|
476
|
+
return `${error.name}: ${error.message}`;
|
|
274
477
|
}
|
|
275
|
-
|
|
478
|
+
return String(error);
|
|
479
|
+
}
|
|
480
|
+
__name(describeError, "describeError");
|
|
481
|
+
|
|
482
|
+
// src/testing/es-repository-contract.ts
|
|
483
|
+
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");
|
|
276
490
|
async function seed(env) {
|
|
277
491
|
const aggregate = harness.createAggregate();
|
|
278
|
-
harness.mutate(aggregate);
|
|
279
492
|
await env.run(async ({ repository }) => {
|
|
280
493
|
await repository.save(aggregate);
|
|
281
494
|
});
|
|
@@ -286,13 +499,291 @@ function createRepositoryContractTests(harness) {
|
|
|
286
499
|
return env.run(({ repository }) => loadOrFail(repository, id));
|
|
287
500
|
}
|
|
288
501
|
__name(reload, "reload");
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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;
|
|
763
|
+
}
|
|
764
|
+
__name(createEsRepositoryContractTests, "createEsRepositoryContractTests");
|
|
765
|
+
|
|
766
|
+
// src/testing/repository-contract.ts
|
|
767
|
+
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;
|
|
294
781
|
}
|
|
295
|
-
__name(
|
|
782
|
+
__name(seed, "seed");
|
|
783
|
+
async function reload(env, id) {
|
|
784
|
+
return env.run(({ repository }) => loadOrFail(repository, id));
|
|
785
|
+
}
|
|
786
|
+
__name(reload, "reload");
|
|
296
787
|
const eventIds = /* @__PURE__ */ __name((events) => events.map((event) => event.eventId).sort(), "eventIds");
|
|
297
788
|
const snapshotState = harness.snapshotState;
|
|
298
789
|
const mutateVersionOnly = harness.mutateVersionOnly;
|
|
@@ -300,18 +791,7 @@ function createRepositoryContractTests(harness) {
|
|
|
300
791
|
const createAggregateWithId = harness.createAggregateWithId;
|
|
301
792
|
const deletesAreVersionChecked = harness.deletesAreVersionChecked === true;
|
|
302
793
|
const insertsAreDuplicateChecked = harness.insertsAreDuplicateChecked !== false;
|
|
303
|
-
|
|
304
|
-
return {
|
|
305
|
-
name,
|
|
306
|
-
skipped: { capability },
|
|
307
|
-
run: /* @__PURE__ */ __name(async () => {
|
|
308
|
-
throw new Error(
|
|
309
|
-
`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.`
|
|
310
|
-
);
|
|
311
|
-
}, "run")
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
__name(skippedTest, "skippedTest");
|
|
794
|
+
const skippedTest = /* @__PURE__ */ __name((name, capability) => skippedContractTest(name, capability), "skippedTest");
|
|
315
795
|
const tests = [
|
|
316
796
|
{
|
|
317
797
|
name: "MANDATORY two-writer conflict: the stale writer throws ConcurrencyConflictError and persists nothing",
|
|
@@ -726,68 +1206,7 @@ function createRepositoryContractTests(harness) {
|
|
|
726
1206
|
return tests;
|
|
727
1207
|
}
|
|
728
1208
|
__name(createRepositoryContractTests, "createRepositoryContractTests");
|
|
729
|
-
function assert(condition, message) {
|
|
730
|
-
if (!condition) {
|
|
731
|
-
throw new Error(`Repository contract violated: ${message}`);
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
__name(assert, "assert");
|
|
735
|
-
function assertEqual(actual, expected, message) {
|
|
736
|
-
if (actual !== expected) {
|
|
737
|
-
throw new Error(
|
|
738
|
-
`Repository contract violated: ${message} (expected ${String(expected)}, got ${String(actual)})`
|
|
739
|
-
);
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
__name(assertEqual, "assertEqual");
|
|
743
|
-
function chainContainsErrorNamed(error, name) {
|
|
744
|
-
const seen = /* @__PURE__ */ new Set();
|
|
745
|
-
let current = error;
|
|
746
|
-
while (current !== null && current !== void 0 && !seen.has(current)) {
|
|
747
|
-
if (typeof current !== "object") {
|
|
748
|
-
return false;
|
|
749
|
-
}
|
|
750
|
-
if (errorMatchesName(current, name)) {
|
|
751
|
-
return true;
|
|
752
|
-
}
|
|
753
|
-
seen.add(current);
|
|
754
|
-
try {
|
|
755
|
-
current = current.cause;
|
|
756
|
-
} catch {
|
|
757
|
-
return false;
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
return false;
|
|
761
|
-
}
|
|
762
|
-
__name(chainContainsErrorNamed, "chainContainsErrorNamed");
|
|
763
|
-
function errorMatchesName(candidate, name) {
|
|
764
|
-
try {
|
|
765
|
-
if (candidate.name === name) {
|
|
766
|
-
return true;
|
|
767
|
-
}
|
|
768
|
-
} catch {
|
|
769
|
-
}
|
|
770
|
-
try {
|
|
771
|
-
let proto = Object.getPrototypeOf(candidate);
|
|
772
|
-
for (let depth = 0; proto !== null && depth < 20; depth++) {
|
|
773
|
-
if (proto.constructor?.name === name) {
|
|
774
|
-
return true;
|
|
775
|
-
}
|
|
776
|
-
proto = Object.getPrototypeOf(proto);
|
|
777
|
-
}
|
|
778
|
-
} catch {
|
|
779
|
-
}
|
|
780
|
-
return false;
|
|
781
|
-
}
|
|
782
|
-
__name(errorMatchesName, "errorMatchesName");
|
|
783
|
-
function describeError(error) {
|
|
784
|
-
if (error instanceof Error) {
|
|
785
|
-
return `${error.name}: ${error.message}`;
|
|
786
|
-
}
|
|
787
|
-
return String(error);
|
|
788
|
-
}
|
|
789
|
-
__name(describeError, "describeError");
|
|
790
1209
|
|
|
791
|
-
export { createRepositoryContractTests };
|
|
1210
|
+
export { createEsRepositoryContractTests, createRepositoryContractTests };
|
|
792
1211
|
//# sourceMappingURL=testing.js.map
|
|
793
1212
|
//# sourceMappingURL=testing.js.map
|