@shirudo/ddd-kit 1.1.0 → 1.3.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 +21 -18
- package/dist/aggregate-BGdgvqKh.d.ts +716 -0
- package/dist/http.d.ts +2 -2
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +767 -656
- package/dist/index.js +775 -55
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +252 -0
- package/dist/testing.js +793 -0
- package/dist/testing.js.map +1 -0
- package/dist/utils.d.ts +3 -3
- package/dist/utils.js.map +1 -1
- package/package.json +6 -2
package/dist/testing.js
ADDED
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
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 ArrayBuffer]",
|
|
18
|
+
"[object SharedArrayBuffer]",
|
|
19
|
+
"[object DataView]"
|
|
20
|
+
]);
|
|
21
|
+
function intrinsicGetter(proto, prop) {
|
|
22
|
+
const get = Object.getOwnPropertyDescriptor(proto, prop)?.get;
|
|
23
|
+
if (!get) throw new Error(`missing intrinsic getter for ${prop}`);
|
|
24
|
+
return get;
|
|
25
|
+
}
|
|
26
|
+
__name(intrinsicGetter, "intrinsicGetter");
|
|
27
|
+
var dateGetTime = Date.prototype.getTime;
|
|
28
|
+
var mapSizeGet = intrinsicGetter(Map.prototype, "size");
|
|
29
|
+
var setSizeGet = intrinsicGetter(Set.prototype, "size");
|
|
30
|
+
var weakMapHas = WeakMap.prototype.has;
|
|
31
|
+
var weakSetHas = WeakSet.prototype.has;
|
|
32
|
+
var dataViewByteLengthGet = intrinsicGetter(DataView.prototype, "byteLength");
|
|
33
|
+
var arrayBufferByteLengthGet = intrinsicGetter(
|
|
34
|
+
ArrayBuffer.prototype,
|
|
35
|
+
"byteLength"
|
|
36
|
+
);
|
|
37
|
+
var regExpSourceGet = intrinsicGetter(RegExp.prototype, "source");
|
|
38
|
+
var booleanValueOf = Boolean.prototype.valueOf;
|
|
39
|
+
var numberValueOf = Number.prototype.valueOf;
|
|
40
|
+
var stringValueOf = String.prototype.valueOf;
|
|
41
|
+
var PROBE_KEY = {};
|
|
42
|
+
function hasBrand(obj, tag) {
|
|
43
|
+
try {
|
|
44
|
+
switch (tag) {
|
|
45
|
+
case "[object Date]":
|
|
46
|
+
dateGetTime.call(obj);
|
|
47
|
+
return true;
|
|
48
|
+
case "[object RegExp]":
|
|
49
|
+
regExpSourceGet.call(obj);
|
|
50
|
+
return true;
|
|
51
|
+
case "[object Map]":
|
|
52
|
+
mapSizeGet.call(obj);
|
|
53
|
+
return true;
|
|
54
|
+
case "[object Set]":
|
|
55
|
+
setSizeGet.call(obj);
|
|
56
|
+
return true;
|
|
57
|
+
case "[object WeakMap]":
|
|
58
|
+
weakMapHas.call(obj, PROBE_KEY);
|
|
59
|
+
return true;
|
|
60
|
+
case "[object WeakSet]":
|
|
61
|
+
weakSetHas.call(obj, PROBE_KEY);
|
|
62
|
+
return true;
|
|
63
|
+
case "[object DataView]":
|
|
64
|
+
dataViewByteLengthGet.call(obj);
|
|
65
|
+
return true;
|
|
66
|
+
case "[object ArrayBuffer]":
|
|
67
|
+
arrayBufferByteLengthGet.call(obj);
|
|
68
|
+
return true;
|
|
69
|
+
case "[object Boolean]":
|
|
70
|
+
booleanValueOf.call(obj);
|
|
71
|
+
return true;
|
|
72
|
+
case "[object Number]":
|
|
73
|
+
numberValueOf.call(obj);
|
|
74
|
+
return true;
|
|
75
|
+
case "[object String]":
|
|
76
|
+
stringValueOf.call(obj);
|
|
77
|
+
return true;
|
|
78
|
+
default:
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
__name(hasBrand, "hasBrand");
|
|
86
|
+
function isBuiltInObject(obj, tag) {
|
|
87
|
+
if (ArrayBuffer.isView(obj)) return true;
|
|
88
|
+
if (tag.endsWith("Array]")) return false;
|
|
89
|
+
return BUILT_IN_TAGS.has(tag) && hasBrand(obj, tag);
|
|
90
|
+
}
|
|
91
|
+
__name(isBuiltInObject, "isBuiltInObject");
|
|
92
|
+
|
|
93
|
+
// src/utils/array/deep-equal.ts
|
|
94
|
+
var objProto = Object.prototype;
|
|
95
|
+
var objToString = objProto.toString;
|
|
96
|
+
var objHasOwn = objProto.hasOwnProperty;
|
|
97
|
+
function sameValueZero(a, b) {
|
|
98
|
+
return a === b || Number.isNaN(a) && Number.isNaN(b);
|
|
99
|
+
}
|
|
100
|
+
__name(sameValueZero, "sameValueZero");
|
|
101
|
+
function deepEqual(a, b) {
|
|
102
|
+
return deepEqualInner(a, b, /* @__PURE__ */ new WeakMap());
|
|
103
|
+
}
|
|
104
|
+
__name(deepEqual, "deepEqual");
|
|
105
|
+
function deepEqualInner(a, b, visited) {
|
|
106
|
+
if (a === b) return true;
|
|
107
|
+
const typeA = typeof a;
|
|
108
|
+
const typeB = typeof b;
|
|
109
|
+
if (typeA !== "object" || a === null || typeB !== "object" || b === null) {
|
|
110
|
+
if (typeA === "number" && typeB === "number") {
|
|
111
|
+
return Number.isNaN(a) && Number.isNaN(b);
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const objA = a;
|
|
116
|
+
const objB = b;
|
|
117
|
+
let cachedBs = visited.get(objA);
|
|
118
|
+
if (cachedBs?.has(objB)) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
if (!cachedBs) {
|
|
122
|
+
cachedBs = /* @__PURE__ */ new WeakSet();
|
|
123
|
+
visited.set(objA, cachedBs);
|
|
124
|
+
}
|
|
125
|
+
cachedBs.add(objB);
|
|
126
|
+
if (ArrayBuffer.isView(objA) || ArrayBuffer.isView(objB)) {
|
|
127
|
+
if (!ArrayBuffer.isView(objA) || !ArrayBuffer.isView(objB)) return false;
|
|
128
|
+
const tagA2 = objToString.call(objA);
|
|
129
|
+
const tagB2 = objToString.call(objB);
|
|
130
|
+
if (tagA2 !== tagB2) return false;
|
|
131
|
+
if (tagA2 === "[object DataView]") {
|
|
132
|
+
const viewA = objA;
|
|
133
|
+
const viewB = objB;
|
|
134
|
+
if (viewA.byteLength !== viewB.byteLength) return false;
|
|
135
|
+
const len2 = viewA.byteLength;
|
|
136
|
+
for (let i = 0; i < len2; i++) {
|
|
137
|
+
if (viewA.getUint8(i) !== viewB.getUint8(i)) return false;
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
const arrA = objA;
|
|
142
|
+
const arrB = objB;
|
|
143
|
+
const len = arrA.length;
|
|
144
|
+
if (len !== arrB.length) return false;
|
|
145
|
+
for (let i = 0; i < len; i++) {
|
|
146
|
+
if (!sameValueZero(arrA[i], arrB[i])) return false;
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
if (Array.isArray(objA) || Array.isArray(objB)) {
|
|
151
|
+
if (!Array.isArray(objA) || !Array.isArray(objB)) return false;
|
|
152
|
+
const arrA = objA;
|
|
153
|
+
const arrB = objB;
|
|
154
|
+
const len = arrA.length;
|
|
155
|
+
if (len !== arrB.length) return false;
|
|
156
|
+
for (let i = 0; i < len; i++) {
|
|
157
|
+
if (!deepEqualInner(arrA[i], arrB[i], visited)) return false;
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
const tagA = objToString.call(objA);
|
|
162
|
+
const tagB = objToString.call(objB);
|
|
163
|
+
if (tagA !== tagB) return false;
|
|
164
|
+
const builtInA = isBuiltInObject(objA, tagA);
|
|
165
|
+
const builtInB = isBuiltInObject(objB, tagB);
|
|
166
|
+
if (builtInA !== builtInB) return false;
|
|
167
|
+
if (!builtInA) {
|
|
168
|
+
return comparePlainObjects(objA, objB, visited);
|
|
169
|
+
}
|
|
170
|
+
switch (tagA) {
|
|
171
|
+
case "[object Map]": {
|
|
172
|
+
const mapA = objA;
|
|
173
|
+
const mapB = objB;
|
|
174
|
+
if (mapA.size !== mapB.size) return false;
|
|
175
|
+
for (const [key, valA] of mapA) {
|
|
176
|
+
if (!mapB.has(key)) return false;
|
|
177
|
+
const valB = mapB.get(key);
|
|
178
|
+
if (!deepEqualInner(valA, valB, visited)) return false;
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
case "[object Set]": {
|
|
183
|
+
const setA = objA;
|
|
184
|
+
const setB = objB;
|
|
185
|
+
if (setA.size !== setB.size) return false;
|
|
186
|
+
for (const value of setA) {
|
|
187
|
+
if (!setB.has(value)) return false;
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
case "[object Date]": {
|
|
192
|
+
return sameValueZero(objA.getTime(), objB.getTime());
|
|
193
|
+
}
|
|
194
|
+
case "[object RegExp]": {
|
|
195
|
+
const regA = objA;
|
|
196
|
+
const regB = objB;
|
|
197
|
+
return regA.source === regB.source && regA.flags === regB.flags;
|
|
198
|
+
}
|
|
199
|
+
case "[object Boolean]":
|
|
200
|
+
case "[object Number]":
|
|
201
|
+
case "[object String]": {
|
|
202
|
+
return sameValueZero(
|
|
203
|
+
objA.valueOf(),
|
|
204
|
+
objB.valueOf()
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
default: {
|
|
208
|
+
return objA === objB;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
__name(deepEqualInner, "deepEqualInner");
|
|
213
|
+
function comparePlainObjects(objA, objB, visited) {
|
|
214
|
+
const recA = objA;
|
|
215
|
+
const recB = objB;
|
|
216
|
+
const stringKeysA = Object.keys(objA);
|
|
217
|
+
const stringKeysB = Object.keys(objB);
|
|
218
|
+
if (stringKeysA.length !== stringKeysB.length) return false;
|
|
219
|
+
const symbolKeysA = Object.getOwnPropertySymbols(objA);
|
|
220
|
+
const symbolKeysB = Object.getOwnPropertySymbols(objB);
|
|
221
|
+
if (symbolKeysA.length !== symbolKeysB.length) return false;
|
|
222
|
+
const symbolKeysBSet = new Set(symbolKeysB);
|
|
223
|
+
for (const key of stringKeysA) {
|
|
224
|
+
if (!objHasOwn.call(objB, key)) return false;
|
|
225
|
+
}
|
|
226
|
+
for (const key of symbolKeysA) {
|
|
227
|
+
if (!symbolKeysBSet.has(key)) return false;
|
|
228
|
+
}
|
|
229
|
+
for (const key of stringKeysA) {
|
|
230
|
+
if (!deepEqualInner(recA[key], recB[key], visited)) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (const key of symbolKeysA) {
|
|
235
|
+
if (!deepEqualInner(recA[key], recB[key], visited)) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
__name(comparePlainObjects, "comparePlainObjects");
|
|
242
|
+
|
|
243
|
+
// src/testing/repository-contract.ts
|
|
244
|
+
function createRepositoryContractTests(harness) {
|
|
245
|
+
async function withEnvironment(body) {
|
|
246
|
+
const env = await harness.createEnvironment();
|
|
247
|
+
let bodyFailed = false;
|
|
248
|
+
let bodyError;
|
|
249
|
+
try {
|
|
250
|
+
await body(env);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
bodyFailed = true;
|
|
253
|
+
bodyError = error;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
await env.teardown?.();
|
|
257
|
+
} catch (teardownError) {
|
|
258
|
+
if (!bodyFailed) {
|
|
259
|
+
throw teardownError;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (bodyFailed) {
|
|
263
|
+
throw bodyError;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
__name(withEnvironment, "withEnvironment");
|
|
267
|
+
async function loadOrFail(repository, id) {
|
|
268
|
+
const loaded = await repository.getById(id);
|
|
269
|
+
assert(
|
|
270
|
+
loaded !== null,
|
|
271
|
+
`getById(${String(id)}) returned null for an aggregate that must exist - broken hydration or a write that did not commit`
|
|
272
|
+
);
|
|
273
|
+
return loaded;
|
|
274
|
+
}
|
|
275
|
+
__name(loadOrFail, "loadOrFail");
|
|
276
|
+
async function seed(env) {
|
|
277
|
+
const aggregate = harness.createAggregate();
|
|
278
|
+
harness.mutate(aggregate);
|
|
279
|
+
await env.run(async ({ repository }) => {
|
|
280
|
+
await repository.save(aggregate);
|
|
281
|
+
});
|
|
282
|
+
return aggregate;
|
|
283
|
+
}
|
|
284
|
+
__name(seed, "seed");
|
|
285
|
+
async function reload(env, id) {
|
|
286
|
+
return env.run(({ repository }) => loadOrFail(repository, id));
|
|
287
|
+
}
|
|
288
|
+
__name(reload, "reload");
|
|
289
|
+
function captureRejection(promise) {
|
|
290
|
+
return promise.then(
|
|
291
|
+
() => void 0,
|
|
292
|
+
(error) => error
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
__name(captureRejection, "captureRejection");
|
|
296
|
+
const eventIds = /* @__PURE__ */ __name((events) => events.map((event) => event.eventId).sort(), "eventIds");
|
|
297
|
+
const snapshotState = harness.snapshotState;
|
|
298
|
+
const mutateVersionOnly = harness.mutateVersionOnly;
|
|
299
|
+
const mutateChildCollection = harness.mutateChildCollection;
|
|
300
|
+
const createAggregateWithId = harness.createAggregateWithId;
|
|
301
|
+
const deletesAreVersionChecked = harness.deletesAreVersionChecked === true;
|
|
302
|
+
const insertsAreDuplicateChecked = harness.insertsAreDuplicateChecked !== false;
|
|
303
|
+
function skippedTest(name, capability) {
|
|
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");
|
|
315
|
+
const tests = [
|
|
316
|
+
{
|
|
317
|
+
name: "MANDATORY two-writer conflict: the stale writer throws ConcurrencyConflictError and persists nothing",
|
|
318
|
+
run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
|
|
319
|
+
const seeded = await seed(env);
|
|
320
|
+
const seedEvents = await env.committedOutboxEvents();
|
|
321
|
+
const seedEventIds = new Set(seedEvents.map((e) => e.eventId));
|
|
322
|
+
const staleB = await reload(env, seeded.id);
|
|
323
|
+
const committedA = await env.run(async ({ repository }) => {
|
|
324
|
+
const a = await loadOrFail(repository, seeded.id);
|
|
325
|
+
harness.mutate(a);
|
|
326
|
+
await repository.save(a);
|
|
327
|
+
return a;
|
|
328
|
+
});
|
|
329
|
+
const outboxAfterA = await env.committedOutboxEvents();
|
|
330
|
+
assert(
|
|
331
|
+
outboxAfterA.length > seedEvents.length,
|
|
332
|
+
"writer A's events must reach the outbox on commit"
|
|
333
|
+
);
|
|
334
|
+
const newSinceSeed = outboxAfterA.filter(
|
|
335
|
+
(event) => !seedEventIds.has(event.eventId)
|
|
336
|
+
);
|
|
337
|
+
assert(
|
|
338
|
+
newSinceSeed.length > 0 && newSinceSeed.every(
|
|
339
|
+
(event) => event.aggregateVersion === committedA.version
|
|
340
|
+
),
|
|
341
|
+
`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).`
|
|
342
|
+
);
|
|
343
|
+
harness.mutate(staleB);
|
|
344
|
+
const rejection = await captureRejection(
|
|
345
|
+
env.run(async ({ repository }) => {
|
|
346
|
+
await repository.save(staleB);
|
|
347
|
+
})
|
|
348
|
+
);
|
|
349
|
+
assert(
|
|
350
|
+
rejection !== void 0,
|
|
351
|
+
"the second writer's commit must reject - it committed on a stale version instead (OCC predicate missing?)"
|
|
352
|
+
);
|
|
353
|
+
assert(
|
|
354
|
+
chainContainsErrorNamed(rejection, "ConcurrencyConflictError"),
|
|
355
|
+
`the second writer's rejection must be (or wrap, via the cause chain) ConcurrencyConflictError; got: ${describeError(rejection)}`
|
|
356
|
+
);
|
|
357
|
+
const final = await reload(env, seeded.id);
|
|
358
|
+
assertEqual(
|
|
359
|
+
final.version,
|
|
360
|
+
committedA.version,
|
|
361
|
+
"the persisted version must equal writer A's committed version"
|
|
362
|
+
);
|
|
363
|
+
if (snapshotState) {
|
|
364
|
+
assert(
|
|
365
|
+
deepEqual(
|
|
366
|
+
snapshotState.call(harness, final),
|
|
367
|
+
snapshotState.call(harness, committedA)
|
|
368
|
+
),
|
|
369
|
+
"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"
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
const outboxFinal = await env.committedOutboxEvents();
|
|
373
|
+
assert(
|
|
374
|
+
deepEqual(eventIds(outboxFinal), eventIds(outboxAfterA)),
|
|
375
|
+
"the outbox must contain exactly the winning writer's events (compared by eventId) - nothing from the stale writer, nothing replaced"
|
|
376
|
+
);
|
|
377
|
+
}), "run")
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
name: "insert routing: a never-persisted aggregate INSERTs even after pre-save mutations",
|
|
381
|
+
run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
|
|
382
|
+
const aggregate = harness.createAggregate();
|
|
383
|
+
assert(
|
|
384
|
+
aggregate.persistedVersion === void 0,
|
|
385
|
+
"harness contract: createAggregate() must return a never-persisted aggregate (persistedVersion === undefined)"
|
|
386
|
+
);
|
|
387
|
+
harness.mutate(aggregate);
|
|
388
|
+
harness.mutate(aggregate);
|
|
389
|
+
await env.run(async ({ repository }) => {
|
|
390
|
+
await repository.save(aggregate);
|
|
391
|
+
});
|
|
392
|
+
const loaded = await reload(env, aggregate.id);
|
|
393
|
+
assertEqual(
|
|
394
|
+
loaded.version,
|
|
395
|
+
aggregate.version,
|
|
396
|
+
"the INSERT must persist the in-memory version (route on persistedVersion === undefined, not version === 0)"
|
|
397
|
+
);
|
|
398
|
+
}), "run")
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: "update writes the in-memory version and predicates on persistedVersion",
|
|
402
|
+
run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
|
|
403
|
+
const seeded = await seed(env);
|
|
404
|
+
const baseline = seeded.version;
|
|
405
|
+
await env.run(async ({ repository }) => {
|
|
406
|
+
const loaded = await loadOrFail(repository, seeded.id);
|
|
407
|
+
harness.mutate(loaded);
|
|
408
|
+
harness.mutate(loaded);
|
|
409
|
+
await repository.save(loaded);
|
|
410
|
+
});
|
|
411
|
+
const final = await reload(env, seeded.id);
|
|
412
|
+
assertEqual(
|
|
413
|
+
final.version,
|
|
414
|
+
baseline + 2,
|
|
415
|
+
"two mutations must persist as baseline + 2 (version is a mutation sequence)"
|
|
416
|
+
);
|
|
417
|
+
assertEqual(
|
|
418
|
+
final.persistedVersion,
|
|
419
|
+
final.version,
|
|
420
|
+
"a reloaded aggregate's persistedVersion must equal its version"
|
|
421
|
+
);
|
|
422
|
+
}), "run")
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
name: "rollback persists nothing: state, version, and outbox untouched",
|
|
426
|
+
run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
|
|
427
|
+
const seeded = await seed(env);
|
|
428
|
+
const versionBefore = seeded.version;
|
|
429
|
+
const outboxBefore = (await env.committedOutboxEvents()).length;
|
|
430
|
+
const probe = new Error("contract rollback probe");
|
|
431
|
+
const rejection = await captureRejection(
|
|
432
|
+
env.run(async ({ repository }) => {
|
|
433
|
+
const loaded = await loadOrFail(repository, seeded.id);
|
|
434
|
+
harness.mutate(loaded);
|
|
435
|
+
await repository.save(loaded);
|
|
436
|
+
throw probe;
|
|
437
|
+
})
|
|
438
|
+
);
|
|
439
|
+
assert(
|
|
440
|
+
rejection !== void 0,
|
|
441
|
+
"a throwing unit of work must reject"
|
|
442
|
+
);
|
|
443
|
+
const final = await reload(env, seeded.id);
|
|
444
|
+
assertEqual(
|
|
445
|
+
final.version,
|
|
446
|
+
versionBefore,
|
|
447
|
+
"a rolled-back write must not change the persisted version"
|
|
448
|
+
);
|
|
449
|
+
assertEqual(
|
|
450
|
+
(await env.committedOutboxEvents()).length,
|
|
451
|
+
outboxBefore,
|
|
452
|
+
"a rolled-back transaction must not leave events in the outbox"
|
|
453
|
+
);
|
|
454
|
+
}), "run")
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
name: "identity map: two getById calls in one unit of work return the same instance",
|
|
458
|
+
run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
|
|
459
|
+
const seeded = await seed(env);
|
|
460
|
+
await env.run(async ({ repository }) => {
|
|
461
|
+
const first = await repository.getById(seeded.id);
|
|
462
|
+
const second = await repository.getById(seeded.id);
|
|
463
|
+
assert(
|
|
464
|
+
first !== null && first === second,
|
|
465
|
+
"repeated loads within one unit of work must return the SAME instance (identity map) - distinct instances double-harvest events"
|
|
466
|
+
);
|
|
467
|
+
});
|
|
468
|
+
}), "run")
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
name: "delete: getById returns null in the same unit of work and after the commit",
|
|
472
|
+
run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
|
|
473
|
+
const seeded = await seed(env);
|
|
474
|
+
await env.run(async ({ repository }) => {
|
|
475
|
+
const loaded = await loadOrFail(repository, seeded.id);
|
|
476
|
+
await repository.delete(loaded);
|
|
477
|
+
const probe = await repository.getById(seeded.id);
|
|
478
|
+
assert(
|
|
479
|
+
probe === null,
|
|
480
|
+
"after delete, getById in the SAME unit of work must return null (isDeleted check), even if the physical delete is deferred"
|
|
481
|
+
);
|
|
482
|
+
});
|
|
483
|
+
await env.run(async ({ repository }) => {
|
|
484
|
+
const probe = await repository.getById(seeded.id);
|
|
485
|
+
assert(
|
|
486
|
+
probe === null,
|
|
487
|
+
"after the deleting unit of work committed, the aggregate must be gone"
|
|
488
|
+
);
|
|
489
|
+
});
|
|
490
|
+
}), "run")
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
name: "deletion is final: saving the deleted aggregate in the same unit of work throws AggregateDeletedError",
|
|
494
|
+
run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
|
|
495
|
+
const seeded = await seed(env);
|
|
496
|
+
const rejection = await captureRejection(
|
|
497
|
+
env.run(async ({ repository }) => {
|
|
498
|
+
const loaded = await loadOrFail(repository, seeded.id);
|
|
499
|
+
harness.mutate(loaded);
|
|
500
|
+
await repository.delete(loaded);
|
|
501
|
+
await repository.save(loaded);
|
|
502
|
+
})
|
|
503
|
+
);
|
|
504
|
+
assert(
|
|
505
|
+
chainContainsErrorNamed(rejection, "AggregateDeletedError"),
|
|
506
|
+
`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.`
|
|
507
|
+
);
|
|
508
|
+
}), "run")
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
name: "events are cleared after a committed unit of work and kept after a rollback",
|
|
512
|
+
run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
|
|
513
|
+
const committed = harness.createAggregate();
|
|
514
|
+
harness.mutate(committed);
|
|
515
|
+
assert(
|
|
516
|
+
committed.pendingEvents.length > 0,
|
|
517
|
+
"harness contract: mutate() must record at least one domain event"
|
|
518
|
+
);
|
|
519
|
+
await env.run(async ({ repository }) => {
|
|
520
|
+
await repository.save(committed);
|
|
521
|
+
});
|
|
522
|
+
assertEqual(
|
|
523
|
+
committed.pendingEvents.length,
|
|
524
|
+
0,
|
|
525
|
+
"pending events must be cleared after a successful commit"
|
|
526
|
+
);
|
|
527
|
+
const rolledBack = harness.createAggregate();
|
|
528
|
+
harness.mutate(rolledBack);
|
|
529
|
+
const pendingBefore = rolledBack.pendingEvents.length;
|
|
530
|
+
await captureRejection(
|
|
531
|
+
env.run(async ({ repository }) => {
|
|
532
|
+
await repository.save(rolledBack);
|
|
533
|
+
throw new Error("contract rollback probe");
|
|
534
|
+
})
|
|
535
|
+
);
|
|
536
|
+
assertEqual(
|
|
537
|
+
rolledBack.pendingEvents.length,
|
|
538
|
+
pendingBefore,
|
|
539
|
+
"pending events must survive a rollback (so a fresh load + retry can re-emit them)"
|
|
540
|
+
);
|
|
541
|
+
}), "run")
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
name: "persistedVersion syncs only after a successful commit",
|
|
545
|
+
run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
|
|
546
|
+
const aggregate = harness.createAggregate();
|
|
547
|
+
harness.mutate(aggregate);
|
|
548
|
+
await captureRejection(
|
|
549
|
+
env.run(async ({ repository }) => {
|
|
550
|
+
await repository.save(aggregate);
|
|
551
|
+
throw new Error("contract rollback probe");
|
|
552
|
+
})
|
|
553
|
+
);
|
|
554
|
+
assert(
|
|
555
|
+
aggregate.persistedVersion === void 0,
|
|
556
|
+
"a rolled-back first save must leave persistedVersion undefined (the aggregate is still unpersisted)"
|
|
557
|
+
);
|
|
558
|
+
await env.run(async ({ repository }) => {
|
|
559
|
+
await repository.save(aggregate);
|
|
560
|
+
});
|
|
561
|
+
assertEqual(
|
|
562
|
+
aggregate.persistedVersion,
|
|
563
|
+
aggregate.version,
|
|
564
|
+
"after a successful commit, persistedVersion must equal version (markPersisted ran)"
|
|
565
|
+
);
|
|
566
|
+
}), "run")
|
|
567
|
+
}
|
|
568
|
+
];
|
|
569
|
+
tests.push(
|
|
570
|
+
mutateVersionOnly ? {
|
|
571
|
+
name: "version-only change still persists (skip-save must not desync the OCC baseline)",
|
|
572
|
+
run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
|
|
573
|
+
const seeded = await seed(env);
|
|
574
|
+
const baseline = seeded.version;
|
|
575
|
+
await env.run(async ({ repository }) => {
|
|
576
|
+
const loaded = await loadOrFail(repository, seeded.id);
|
|
577
|
+
mutateVersionOnly.call(harness, loaded);
|
|
578
|
+
await repository.save(loaded);
|
|
579
|
+
});
|
|
580
|
+
const final = await reload(env, seeded.id);
|
|
581
|
+
assertEqual(
|
|
582
|
+
final.version,
|
|
583
|
+
baseline + 1,
|
|
584
|
+
"a version-only change (empty changedKeys, bumped version) must still be persisted - skipping it desyncs persistedVersion and produces false ConcurrencyConflictErrors later"
|
|
585
|
+
);
|
|
586
|
+
}), "run")
|
|
587
|
+
} : skippedTest(
|
|
588
|
+
"version-only change still persists (skip-save must not desync the OCC baseline)",
|
|
589
|
+
"mutateVersionOnly"
|
|
590
|
+
)
|
|
591
|
+
);
|
|
592
|
+
tests.push(
|
|
593
|
+
mutateChildCollection ? {
|
|
594
|
+
name: "a child-collection-only change bumps the persisted root version",
|
|
595
|
+
run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
|
|
596
|
+
const seeded = await seed(env);
|
|
597
|
+
const baseline = seeded.version;
|
|
598
|
+
await env.run(async ({ repository }) => {
|
|
599
|
+
const loaded = await loadOrFail(repository, seeded.id);
|
|
600
|
+
mutateChildCollection.call(harness, loaded);
|
|
601
|
+
await repository.save(loaded);
|
|
602
|
+
});
|
|
603
|
+
const final = await reload(env, seeded.id);
|
|
604
|
+
assert(
|
|
605
|
+
final.version > baseline,
|
|
606
|
+
"a child-collection-only change must advance the persisted ROOT version - otherwise concurrent writers interleave with collection writes undetected"
|
|
607
|
+
);
|
|
608
|
+
}), "run")
|
|
609
|
+
} : skippedTest(
|
|
610
|
+
"a child-collection-only change bumps the persisted root version",
|
|
611
|
+
"mutateChildCollection"
|
|
612
|
+
)
|
|
613
|
+
);
|
|
614
|
+
tests.push(
|
|
615
|
+
createAggregateWithId ? {
|
|
616
|
+
name: "deletion is final across instances: a re-created aggregate with the same id cannot be saved",
|
|
617
|
+
run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
|
|
618
|
+
const seeded = await seed(env);
|
|
619
|
+
const rejection = await captureRejection(
|
|
620
|
+
env.run(async ({ repository }) => {
|
|
621
|
+
const loaded = await loadOrFail(repository, seeded.id);
|
|
622
|
+
await repository.delete(loaded);
|
|
623
|
+
const resurrected = createAggregateWithId.call(
|
|
624
|
+
harness,
|
|
625
|
+
seeded.id
|
|
626
|
+
);
|
|
627
|
+
harness.mutate(resurrected);
|
|
628
|
+
await repository.save(resurrected);
|
|
629
|
+
})
|
|
630
|
+
);
|
|
631
|
+
assert(
|
|
632
|
+
chainContainsErrorNamed(rejection, "AggregateDeletedError"),
|
|
633
|
+
`saving a re-created instance of a deleted aggregate must reject with (or wrap) AggregateDeletedError; got: ${describeError(rejection)}`
|
|
634
|
+
);
|
|
635
|
+
}), "run")
|
|
636
|
+
} : skippedTest(
|
|
637
|
+
"deletion is final across instances: a re-created aggregate with the same id cannot be saved",
|
|
638
|
+
"createAggregateWithId"
|
|
639
|
+
)
|
|
640
|
+
);
|
|
641
|
+
tests.push(
|
|
642
|
+
createAggregateWithId && insertsAreDuplicateChecked ? {
|
|
643
|
+
name: "duplicate insert: a second never-persisted aggregate with an existing id throws DuplicateAggregateError",
|
|
644
|
+
run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
|
|
645
|
+
const seeded = await seed(env);
|
|
646
|
+
const duplicate = createAggregateWithId.call(
|
|
647
|
+
harness,
|
|
648
|
+
seeded.id
|
|
649
|
+
);
|
|
650
|
+
harness.mutate(duplicate);
|
|
651
|
+
harness.mutate(duplicate);
|
|
652
|
+
const rejection = await captureRejection(
|
|
653
|
+
env.run(async ({ repository }) => {
|
|
654
|
+
await repository.save(duplicate);
|
|
655
|
+
})
|
|
656
|
+
);
|
|
657
|
+
assert(
|
|
658
|
+
chainContainsErrorNamed(rejection, "DuplicateAggregateError"),
|
|
659
|
+
`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)}`
|
|
660
|
+
);
|
|
661
|
+
const final = await reload(env, seeded.id);
|
|
662
|
+
assertEqual(
|
|
663
|
+
final.version,
|
|
664
|
+
seeded.version,
|
|
665
|
+
"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"
|
|
666
|
+
);
|
|
667
|
+
if (snapshotState) {
|
|
668
|
+
assert(
|
|
669
|
+
deepEqual(
|
|
670
|
+
snapshotState.call(harness, final),
|
|
671
|
+
snapshotState.call(harness, seeded)
|
|
672
|
+
),
|
|
673
|
+
"the existing row's STATE must be untouched by the rejected duplicate insert"
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
}), "run")
|
|
677
|
+
} : skippedTest(
|
|
678
|
+
"duplicate insert: a second never-persisted aggregate with an existing id throws DuplicateAggregateError",
|
|
679
|
+
// Name the capability that is actually missing: the
|
|
680
|
+
// mechanical one (cannot build the duplicate) or the
|
|
681
|
+
// semantic opt-out (deliberately upserting adapter).
|
|
682
|
+
createAggregateWithId ? "insertsAreDuplicateChecked" : "createAggregateWithId"
|
|
683
|
+
)
|
|
684
|
+
);
|
|
685
|
+
tests.push(
|
|
686
|
+
deletesAreVersionChecked ? {
|
|
687
|
+
name: "stale delete conflicts: deleting from a stale instance throws ConcurrencyConflictError",
|
|
688
|
+
run: /* @__PURE__ */ __name(() => withEnvironment(async (env) => {
|
|
689
|
+
const seeded = await seed(env);
|
|
690
|
+
const staleB = await reload(env, seeded.id);
|
|
691
|
+
const versionAfterA = await env.run(
|
|
692
|
+
async ({ repository }) => {
|
|
693
|
+
const a = await loadOrFail(repository, seeded.id);
|
|
694
|
+
harness.mutate(a);
|
|
695
|
+
await repository.save(a);
|
|
696
|
+
return a.version;
|
|
697
|
+
}
|
|
698
|
+
);
|
|
699
|
+
const rejection = await captureRejection(
|
|
700
|
+
env.run(async ({ repository }) => {
|
|
701
|
+
await repository.delete(staleB);
|
|
702
|
+
})
|
|
703
|
+
);
|
|
704
|
+
assert(
|
|
705
|
+
chainContainsErrorNamed(rejection, "ConcurrencyConflictError"),
|
|
706
|
+
`a stale delete must reject with (or wrap) ConcurrencyConflictError; got: ${describeError(rejection)} - an unpredicated DELETE silently destroys the concurrent writer's update`
|
|
707
|
+
);
|
|
708
|
+
const final = await env.run(
|
|
709
|
+
({ repository }) => repository.getById(seeded.id)
|
|
710
|
+
);
|
|
711
|
+
assert(
|
|
712
|
+
final !== null,
|
|
713
|
+
"the row must still exist after the stale delete was rejected - the predicate must PREVENT the destructive delete, not merely report it"
|
|
714
|
+
);
|
|
715
|
+
assertEqual(
|
|
716
|
+
final.version,
|
|
717
|
+
versionAfterA,
|
|
718
|
+
"the surviving row must carry writer A's version"
|
|
719
|
+
);
|
|
720
|
+
}), "run")
|
|
721
|
+
} : skippedTest(
|
|
722
|
+
"stale delete conflicts: deleting from a stale instance throws ConcurrencyConflictError",
|
|
723
|
+
"deletesAreVersionChecked"
|
|
724
|
+
)
|
|
725
|
+
);
|
|
726
|
+
return tests;
|
|
727
|
+
}
|
|
728
|
+
__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
|
+
|
|
791
|
+
export { createRepositoryContractTests };
|
|
792
|
+
//# sourceMappingURL=testing.js.map
|
|
793
|
+
//# sourceMappingURL=testing.js.map
|