@entifix/testing-unit 0.1.1 → 0.1.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/README.md +3 -0
- package/dist/contracts/entity-repository.contract.d.ts +14 -3
- package/dist/contracts/entity-repository.contract.d.ts.map +1 -1
- package/dist/contracts/entity-repository.contract.js +128 -21
- package/dist/contracts/entity-repository.contract.js.map +1 -1
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.d.ts.map +1 -1
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/index.js.map +1 -1
- package/dist/contracts/saga-store.contract.d.ts +13 -0
- package/dist/contracts/saga-store.contract.d.ts.map +1 -0
- package/dist/contracts/saga-store.contract.js +142 -0
- package/dist/contracts/saga-store.contract.js.map +1 -0
- package/dist/doubles/entity-repository.d.ts.map +1 -1
- package/dist/doubles/entity-repository.js +54 -20
- package/dist/doubles/entity-repository.js.map +1 -1
- package/dist/doubles/transaction-ports.d.ts +12 -1
- package/dist/doubles/transaction-ports.d.ts.map +1 -1
- package/dist/doubles/transaction-ports.js +68 -0
- package/dist/doubles/transaction-ports.js.map +1 -1
- package/dist/drivers/fake-mongo.d.ts.map +1 -1
- package/dist/drivers/fake-mongo.js +37 -4
- package/dist/drivers/fake-mongo.js.map +1 -1
- package/package.json +5 -7
package/README.md
CHANGED
|
@@ -49,6 +49,9 @@ describeEntityRepositoryContract('in-memory fake', { makeRepository: seed => mak
|
|
|
49
49
|
describeEntityRepositoryContract('mongo adapter over a fake driver', { makeRepository: seed => makeMongoRepository(fakeDb, Widget) });
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
A read-only adapter runs `describeEntityRepositoryReadContract` alone;
|
|
53
|
+
`describeEntityRepositoryContract` is it and `describeEntityRepositoryWriteContract`.
|
|
54
|
+
|
|
52
55
|
This is what keeps a fake from quietly becoming a more forgiving version of the
|
|
53
56
|
thing it stands in for. `@entifix/testing-integration` will later run
|
|
54
57
|
the same suites against real infrastructure.
|
|
@@ -13,8 +13,10 @@ export declare class ContractWidget implements Entity {
|
|
|
13
13
|
set name(value: string | undefined);
|
|
14
14
|
get size(): number | undefined;
|
|
15
15
|
set size(value: number | undefined);
|
|
16
|
+
get tags(): readonly string[];
|
|
17
|
+
set tags(value: readonly string[]);
|
|
16
18
|
}
|
|
17
|
-
export declare const makeContractWidget: (id: EntityId, name: string, size: number) => ContractWidget;
|
|
19
|
+
export declare const makeContractWidget: (id: EntityId, name: string, size: number, tags?: readonly string[]) => ContractWidget;
|
|
18
20
|
export interface EntityRepositoryContractOptions {
|
|
19
21
|
/**
|
|
20
22
|
* Builds a repository over `seed`. Called fresh for each test, so no state
|
|
@@ -22,9 +24,18 @@ export interface EntityRepositoryContractOptions {
|
|
|
22
24
|
*/
|
|
23
25
|
makeRepository(seed: ContractWidget[]): EntityRepository | Promise<EntityRepository>;
|
|
24
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The reads every {@link EntityRepository} must answer alike: `load` with its
|
|
29
|
+
* paging, filtering and sorting, and `get`. A read-only adapter — one over
|
|
30
|
+
* static files, say — runs this half alone, since it refuses `save` and
|
|
31
|
+
* `delete` by design.
|
|
32
|
+
*/
|
|
33
|
+
export declare const describeEntityRepositoryReadContract: (name: string, { makeRepository }: EntityRepositoryContractOptions) => void;
|
|
34
|
+
/** The writes every {@link EntityRepository} that accepts them must honour. */
|
|
35
|
+
export declare const describeEntityRepositoryWriteContract: (name: string, { makeRepository }: EntityRepositoryContractOptions) => void;
|
|
25
36
|
/**
|
|
26
37
|
* The behaviour every {@link EntityRepository} must exhibit, regardless of what
|
|
27
|
-
* backs it.
|
|
38
|
+
* backs it: both halves.
|
|
28
39
|
*
|
|
29
40
|
* Run it against the in-memory fake *and* against each real adapter. That is
|
|
30
41
|
* what stops a fake from drifting into a more forgiving version of the thing it
|
|
@@ -32,5 +43,5 @@ export interface EntityRepositoryContractOptions {
|
|
|
32
43
|
* not. `entifix-ts-testing-integration` will run this same suite against real
|
|
33
44
|
* infrastructure later.
|
|
34
45
|
*/
|
|
35
|
-
export declare const describeEntityRepositoryContract: (name: string,
|
|
46
|
+
export declare const describeEntityRepositoryContract: (name: string, options: EntityRepositoryContractOptions) => void;
|
|
36
47
|
//# sourceMappingURL=entity-repository.contract.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entity-repository.contract.d.ts","sourceRoot":"","sources":["../../src/contracts/entity-repository.contract.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,EAAY,KAAK,MAAM,EAAU,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAC;AAM7E;;;;GAIG;AACH,qBACa,cAAe,YAAW,MAAM;;
|
|
1
|
+
{"version":3,"file":"entity-repository.contract.d.ts","sourceRoot":"","sources":["../../src/contracts/entity-repository.contract.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,EAAY,KAAK,MAAM,EAAU,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAC;AAM7E;;;;GAIG;AACH,qBACa,cAAe,YAAW,MAAM;;IAM3C,IACI,EAAE,IAAI,QAAQ,CAEjB;IACD,IAAI,EAAE,CAAC,KAAK,EAAE,QAAQ,EAErB;IAED,IACI,IAAI,IAAI,MAAM,GAAG,SAAS,CAE7B;IACD,IAAI,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAEjC;IAED,IACI,IAAI,IAAI,MAAM,GAAG,SAAS,CAE7B;IACD,IAAI,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAEjC;IAED,IACI,IAAI,IAAI,SAAS,MAAM,EAAE,CAE5B;IACD,IAAI,IAAI,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,EAEhC;CACF;AAED,eAAO,MAAM,kBAAkB,GAC7B,IAAI,QAAQ,EACZ,MAAM,MAAM,EACZ,MAAM,MAAM,EACZ,OAAM,SAAS,MAAM,EAAO,KAC3B,cAOF,CAAC;AAEF,MAAM,WAAW,+BAA+B;IAC9C;;;OAGG;IACH,cAAc,CACZ,IAAI,EAAE,cAAc,EAAE,GACrB,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;CACjD;AAED;;;;;GAKG;AACH,eAAO,MAAM,oCAAoC,GAC/C,MAAM,MAAM,EACZ,oBAAoB,+BAA+B,KAClD,IAsLF,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,qCAAqC,GAChD,MAAM,MAAM,EACZ,oBAAoB,+BAA+B,KAClD,IA8FF,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,gCAAgC,GAC3C,MAAM,MAAM,EACZ,SAAS,+BAA+B,KACvC,IAGF,CAAC"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { _ as _class_private_field_loose_base } from "@swc/helpers/_/_class_private_field_loose_base";
|
|
2
2
|
import { _ as _class_private_field_loose_key } from "@swc/helpers/_/_class_private_field_loose_key";
|
|
3
3
|
import { _ as _apply_decs_2203_r } from "@swc/helpers/_/_apply_decs_2203_r";
|
|
4
|
-
var _dec, _initClass, _dec1, _dec2, _dec3, _initProto;
|
|
4
|
+
var _dec, _initClass, _dec1, _dec2, _dec3, _dec4, _initProto;
|
|
5
5
|
import { accessor, entity } from '@entifix/core';
|
|
6
6
|
import { Exit } from 'effect';
|
|
7
7
|
import { describe, expect, it } from 'vitest';
|
|
@@ -14,9 +14,11 @@ _dec = entity({
|
|
|
14
14
|
type: 'string'
|
|
15
15
|
}), _dec3 = accessor({
|
|
16
16
|
type: 'number'
|
|
17
|
+
}), _dec4 = accessor({
|
|
18
|
+
type: 'scalarCollection'
|
|
17
19
|
});
|
|
18
20
|
let _ContractWidget;
|
|
19
|
-
var _id = /*#__PURE__*/ _class_private_field_loose_key("_id"), _name = /*#__PURE__*/ _class_private_field_loose_key("_name"), _size = /*#__PURE__*/ _class_private_field_loose_key("_size");
|
|
21
|
+
var _id = /*#__PURE__*/ _class_private_field_loose_key("_id"), _name = /*#__PURE__*/ _class_private_field_loose_key("_name"), _size = /*#__PURE__*/ _class_private_field_loose_key("_size"), _tags = /*#__PURE__*/ _class_private_field_loose_key("_tags");
|
|
20
22
|
let ContractWidget = class ContractWidget {
|
|
21
23
|
get id() {
|
|
22
24
|
return _class_private_field_loose_base(this, _id)[_id];
|
|
@@ -36,6 +38,12 @@ let ContractWidget = class ContractWidget {
|
|
|
36
38
|
set size(value) {
|
|
37
39
|
_class_private_field_loose_base(this, _size)[_size] = value;
|
|
38
40
|
}
|
|
41
|
+
get tags() {
|
|
42
|
+
return _class_private_field_loose_base(this, _tags)[_tags];
|
|
43
|
+
}
|
|
44
|
+
set tags(value) {
|
|
45
|
+
_class_private_field_loose_base(this, _tags)[_tags] = value;
|
|
46
|
+
}
|
|
39
47
|
constructor(){
|
|
40
48
|
Object.defineProperty(this, _id, {
|
|
41
49
|
writable: true,
|
|
@@ -49,7 +57,11 @@ let ContractWidget = class ContractWidget {
|
|
|
49
57
|
writable: true,
|
|
50
58
|
value: void 0
|
|
51
59
|
});
|
|
52
|
-
|
|
60
|
+
Object.defineProperty(this, _tags, {
|
|
61
|
+
writable: true,
|
|
62
|
+
value: void 0
|
|
63
|
+
});
|
|
64
|
+
_class_private_field_loose_base(this, _tags)[_tags] = (_initProto(this), []);
|
|
53
65
|
}
|
|
54
66
|
};
|
|
55
67
|
({ e: [_initProto], c: [_ContractWidget, _initClass] } = _apply_decs_2203_r(ContractWidget, [
|
|
@@ -67,29 +79,31 @@ let ContractWidget = class ContractWidget {
|
|
|
67
79
|
_dec3,
|
|
68
80
|
3,
|
|
69
81
|
"size"
|
|
82
|
+
],
|
|
83
|
+
[
|
|
84
|
+
_dec4,
|
|
85
|
+
3,
|
|
86
|
+
"tags"
|
|
70
87
|
]
|
|
71
88
|
], [
|
|
72
89
|
_dec
|
|
73
90
|
]));
|
|
74
91
|
_initClass();
|
|
75
|
-
export const makeContractWidget = (id, name, size)=>{
|
|
92
|
+
export const makeContractWidget = (id, name, size, tags = [])=>{
|
|
76
93
|
const widget = new _ContractWidget();
|
|
77
94
|
widget.id = id;
|
|
78
95
|
widget.name = name;
|
|
79
96
|
widget.size = size;
|
|
97
|
+
widget.tags = tags;
|
|
80
98
|
return widget;
|
|
81
99
|
};
|
|
82
100
|
/**
|
|
83
|
-
* The
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
* not. `entifix-ts-testing-integration` will run this same suite against real
|
|
90
|
-
* infrastructure later.
|
|
91
|
-
*/ export const describeEntityRepositoryContract = (name, { makeRepository })=>{
|
|
92
|
-
describe(`EntityRepository contract: ${name}`, ()=>{
|
|
101
|
+
* The reads every {@link EntityRepository} must answer alike: `load` with its
|
|
102
|
+
* paging, filtering and sorting, and `get`. A read-only adapter — one over
|
|
103
|
+
* static files, say — runs this half alone, since it refuses `save` and
|
|
104
|
+
* `delete` by design.
|
|
105
|
+
*/ export const describeEntityRepositoryReadContract = (name, { makeRepository })=>{
|
|
106
|
+
describe(`EntityRepository read contract: ${name}`, ()=>{
|
|
93
107
|
const seeded = ()=>[
|
|
94
108
|
makeContractWidget('w-1', 'Alpha', 10),
|
|
95
109
|
makeContractWidget('w-2', 'Beta', 20),
|
|
@@ -98,6 +112,18 @@ export const makeContractWidget = (id, name, size)=>{
|
|
|
98
112
|
const withRepository = async (use, seed = seeded())=>{
|
|
99
113
|
await use(await makeRepository(seed));
|
|
100
114
|
};
|
|
115
|
+
it('keeps a record unchanged when a read instance is mutated without a save', async ()=>{
|
|
116
|
+
await withRepository(async (repository)=>{
|
|
117
|
+
const read = await runRepository(repository.get('w-1'));
|
|
118
|
+
read.name = 'Mutated';
|
|
119
|
+
const again = await runRepository(repository.get('w-1'));
|
|
120
|
+
expect(again.name).toBe('Alpha');
|
|
121
|
+
const page = await runRepository(repository.load({}));
|
|
122
|
+
page.items[0].name = 'Mutated';
|
|
123
|
+
const reloaded = await runRepository(repository.load({}));
|
|
124
|
+
expect(reloaded.items[0].name).toBe('Alpha');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
101
127
|
it('loads every stored entity with its total', async ()=>{
|
|
102
128
|
await withRepository(async (repository)=>{
|
|
103
129
|
const page = await runRepository(repository.load({}));
|
|
@@ -183,6 +209,52 @@ export const makeContractWidget = (id, name, size)=>{
|
|
|
183
209
|
]);
|
|
184
210
|
});
|
|
185
211
|
});
|
|
212
|
+
it('matches `in` and `nin` against any element of an array member', async ()=>{
|
|
213
|
+
await withRepository(async (repository)=>{
|
|
214
|
+
const tagged = await runRepository(repository.load({
|
|
215
|
+
filtering: [
|
|
216
|
+
{
|
|
217
|
+
property: 'tags',
|
|
218
|
+
operator: 'in',
|
|
219
|
+
values: [
|
|
220
|
+
'blue',
|
|
221
|
+
'red'
|
|
222
|
+
]
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
}));
|
|
226
|
+
expect(tagged.items.map((item)=>item.id)).toEqual([
|
|
227
|
+
'w-1',
|
|
228
|
+
'w-3'
|
|
229
|
+
]);
|
|
230
|
+
const untagged = await runRepository(repository.load({
|
|
231
|
+
filtering: [
|
|
232
|
+
{
|
|
233
|
+
property: 'tags',
|
|
234
|
+
operator: 'nin',
|
|
235
|
+
values: [
|
|
236
|
+
'blue',
|
|
237
|
+
'red'
|
|
238
|
+
]
|
|
239
|
+
}
|
|
240
|
+
]
|
|
241
|
+
}));
|
|
242
|
+
expect(untagged.items.map((item)=>item.id)).toEqual([
|
|
243
|
+
'w-2'
|
|
244
|
+
]);
|
|
245
|
+
}, [
|
|
246
|
+
makeContractWidget('w-1', 'Alpha', 10, [
|
|
247
|
+
'blue',
|
|
248
|
+
'green'
|
|
249
|
+
]),
|
|
250
|
+
makeContractWidget('w-2', 'Beta', 20, [
|
|
251
|
+
'green'
|
|
252
|
+
]),
|
|
253
|
+
makeContractWidget('w-3', 'Gamma', 30, [
|
|
254
|
+
'red'
|
|
255
|
+
])
|
|
256
|
+
]);
|
|
257
|
+
});
|
|
186
258
|
it('sorts descending when asked to', async ()=>{
|
|
187
259
|
await withRepository(async (repository)=>{
|
|
188
260
|
const page = await runRepository(repository.load({
|
|
@@ -215,6 +287,35 @@ export const makeContractWidget = (id, name, size)=>{
|
|
|
215
287
|
expect(Exit.isFailure(exit)).toBe(true);
|
|
216
288
|
});
|
|
217
289
|
});
|
|
290
|
+
it('loads an empty page from an empty store', async ()=>{
|
|
291
|
+
await withRepository(async (repository)=>{
|
|
292
|
+
const page = await runRepository(repository.load({}));
|
|
293
|
+
expect(page.items).toEqual([]);
|
|
294
|
+
expect(page.total).toBe(0);
|
|
295
|
+
}, []);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
/** The writes every {@link EntityRepository} that accepts them must honour. */ export const describeEntityRepositoryWriteContract = (name, { makeRepository })=>{
|
|
300
|
+
describe(`EntityRepository write contract: ${name}`, ()=>{
|
|
301
|
+
const seeded = ()=>[
|
|
302
|
+
makeContractWidget('w-1', 'Alpha', 10),
|
|
303
|
+
makeContractWidget('w-2', 'Beta', 20),
|
|
304
|
+
makeContractWidget('w-3', 'Gamma', 30)
|
|
305
|
+
];
|
|
306
|
+
const withRepository = async (use, seed = seeded())=>{
|
|
307
|
+
await use(await makeRepository(seed));
|
|
308
|
+
};
|
|
309
|
+
it('keeps a saved record unchanged when the saved instance is mutated afterwards', async ()=>{
|
|
310
|
+
await withRepository(async (repository)=>{
|
|
311
|
+
const widget = makeContractWidget('w-9', 'Delta', 40);
|
|
312
|
+
const returned = await runRepository(repository.save(widget));
|
|
313
|
+
widget.name = 'Mutated';
|
|
314
|
+
returned.name = 'Mutated too';
|
|
315
|
+
const stored = await runRepository(repository.get('w-9'));
|
|
316
|
+
expect(stored.name).toBe('Delta');
|
|
317
|
+
});
|
|
318
|
+
});
|
|
218
319
|
it('mints an id when saving an entity that has none', async ()=>{
|
|
219
320
|
await withRepository(async (repository)=>{
|
|
220
321
|
const created = await runRepository(repository.save(makeContractWidget(undefined, 'Delta', 40)));
|
|
@@ -259,15 +360,21 @@ export const makeContractWidget = (id, name, size)=>{
|
|
|
259
360
|
expect(page.total).toBe(3);
|
|
260
361
|
});
|
|
261
362
|
});
|
|
262
|
-
it('loads an empty page from an empty store', async ()=>{
|
|
263
|
-
await withRepository(async (repository)=>{
|
|
264
|
-
const page = await runRepository(repository.load({}));
|
|
265
|
-
expect(page.items).toEqual([]);
|
|
266
|
-
expect(page.total).toBe(0);
|
|
267
|
-
}, []);
|
|
268
|
-
});
|
|
269
363
|
});
|
|
270
364
|
};
|
|
365
|
+
/**
|
|
366
|
+
* The behaviour every {@link EntityRepository} must exhibit, regardless of what
|
|
367
|
+
* backs it: both halves.
|
|
368
|
+
*
|
|
369
|
+
* Run it against the in-memory fake *and* against each real adapter. That is
|
|
370
|
+
* what stops a fake from drifting into a more forgiving version of the thing it
|
|
371
|
+
* stands in for — the failure mode where a suite is green and production is
|
|
372
|
+
* not. `entifix-ts-testing-integration` will run this same suite against real
|
|
373
|
+
* infrastructure later.
|
|
374
|
+
*/ export const describeEntityRepositoryContract = (name, options)=>{
|
|
375
|
+
describeEntityRepositoryReadContract(name, options);
|
|
376
|
+
describeEntityRepositoryWriteContract(name, options);
|
|
377
|
+
};
|
|
271
378
|
export { _ContractWidget as ContractWidget };
|
|
272
379
|
|
|
273
380
|
//# sourceMappingURL=entity-repository.contract.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/contracts/entity-repository.contract.ts"],"sourcesContent":["import type { EntityRepository } from '@entifix/business';\nimport { accessor, type Entity, entity, type EntityId } from '@entifix/core';\nimport { Exit } from 'effect';\nimport { describe, expect, it } from 'vitest';\n\nimport { runRepository, runRepositoryExit } from '../effect/run.js';\n\n/**\n * The entity every implementation of the contract is exercised against. It is\n * declared here rather than by each caller so the suite controls exactly which\n * fields exist.\n */\n@entity({ key: 'contract-widget' })\nexport class ContractWidget implements Entity {\n #id?: EntityId;\n #name?: string;\n #size?: number;\n\n @accessor({ type: 'id' })\n get id(): EntityId {\n return this.#id;\n }\n set id(value: EntityId) {\n this.#id = value;\n }\n\n @accessor({ type: 'string' })\n get name(): string | undefined {\n return this.#name;\n }\n set name(value: string | undefined) {\n this.#name = value;\n }\n\n @accessor({ type: 'number' })\n get size(): number | undefined {\n return this.#size;\n }\n set size(value: number | undefined) {\n this.#size = value;\n }\n}\n\nexport const makeContractWidget = (\n id: EntityId,\n name: string,\n size: number,\n): ContractWidget => {\n const widget = new ContractWidget();\n widget.id = id;\n widget.name = name;\n widget.size = size;\n return widget;\n};\n\nexport interface EntityRepositoryContractOptions {\n /**\n * Builds a repository over `seed`. Called fresh for each test, so no state\n * leaks between them.\n */\n makeRepository(\n seed: ContractWidget[],\n ): EntityRepository | Promise<EntityRepository>;\n}\n\n/**\n * The behaviour every {@link EntityRepository} must exhibit, regardless of what\n * backs it.\n *\n * Run it against the in-memory fake *and* against each real adapter. That is\n * what stops a fake from drifting into a more forgiving version of the thing it\n * stands in for — the failure mode where a suite is green and production is\n * not. `entifix-ts-testing-integration` will run this same suite against real\n * infrastructure later.\n */\nexport const describeEntityRepositoryContract = (\n name: string,\n { makeRepository }: EntityRepositoryContractOptions,\n): void => {\n describe(`EntityRepository contract: ${name}`, () => {\n const seeded = () => [\n makeContractWidget('w-1', 'Alpha', 10),\n makeContractWidget('w-2', 'Beta', 20),\n makeContractWidget('w-3', 'Gamma', 30),\n ];\n\n const withRepository = async (\n use: (repository: EntityRepository) => Promise<void>,\n seed: ContractWidget[] = seeded(),\n ) => {\n await use(await makeRepository(seed));\n };\n\n it('loads every stored entity with its total', async () => {\n await withRepository(async repository => {\n const page = await runRepository(repository.load<ContractWidget>({}));\n\n expect(page.total).toBe(3);\n expect(page.items.map(item => item.name)).toEqual([\n 'Alpha',\n 'Beta',\n 'Gamma',\n ]);\n });\n });\n\n it('echoes the request it served on the page', async () => {\n await withRepository(async repository => {\n const request = { page: 1, pageSize: 2 };\n const page = await runRepository(\n repository.load<ContractWidget>(request),\n );\n\n expect(page.request).toEqual(request);\n });\n });\n\n it('pages with a 1-based page number', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({ page: 2, pageSize: 2 }),\n );\n\n expect(page.items).toHaveLength(1);\n expect(page.items[0]?.name).toBe('Gamma');\n // The total counts matches, not the page.\n expect(page.total).toBe(3);\n });\n });\n\n it('filters by an equality operator', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n filtering: [{ property: 'name', operator: 'eq', value: 'Beta' }],\n }),\n );\n\n expect(page.items).toHaveLength(1);\n expect(page.items[0]?.id).toBe('w-2');\n expect(page.total).toBe(1);\n });\n });\n\n it('filters by a range operator', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n filtering: [\n { property: 'size', operator: 'between', start: 15, end: 35 },\n ],\n }),\n );\n\n expect(page.items.map(item => item.id)).toEqual(['w-2', 'w-3']);\n });\n });\n\n it('matches `like` case-insensitively on a substring', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n filtering: [{ property: 'name', operator: 'like', value: 'et' }],\n }),\n );\n\n expect(page.items.map(item => item.id)).toEqual(['w-2']);\n });\n });\n\n it('sorts descending when asked to', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n sorting: [{ 0: { property: 'size', type: 'desc' } }],\n }),\n );\n\n expect(page.items.map(item => item.size)).toEqual([30, 20, 10]);\n });\n });\n\n it('reads a single entity by id', async () => {\n await withRepository(async repository => {\n const found = await runRepository(\n repository.get<ContractWidget>('w-2'),\n );\n\n expect(found.id).toBe('w-2');\n expect(found.name).toBe('Beta');\n });\n });\n\n it('fails rather than resolving undefined for an unknown id', async () => {\n await withRepository(async repository => {\n const exit = await runRepositoryExit(\n repository.get<ContractWidget>('missing'),\n );\n\n expect(Exit.isFailure(exit)).toBe(true);\n });\n });\n\n it('mints an id when saving an entity that has none', async () => {\n await withRepository(async repository => {\n const created = await runRepository(\n repository.save<ContractWidget>(\n makeContractWidget(undefined, 'Delta', 40),\n ),\n );\n\n expect(created.id).toBeDefined();\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.total).toBe(4);\n });\n });\n\n it('overwrites in place when saving an entity that already has an id', async () => {\n await withRepository(async repository => {\n await runRepository(\n repository.save<ContractWidget>(\n makeContractWidget('w-2', 'Renamed', 25),\n ),\n );\n\n const found = await runRepository(\n repository.get<ContractWidget>('w-2'),\n );\n expect(found.name).toBe('Renamed');\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.total).toBe(3);\n });\n });\n\n it('deletes by id', async () => {\n await withRepository(async repository => {\n await runRepository(repository.delete<ContractWidget>('w-1'));\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.items.map(item => item.id)).toEqual(['w-2', 'w-3']);\n });\n });\n\n it('deletes by entity', async () => {\n await withRepository(async repository => {\n await runRepository(\n repository.delete<ContractWidget>(\n makeContractWidget('w-3', 'Gamma', 30),\n ),\n );\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.items.map(item => item.id)).toEqual(['w-1', 'w-2']);\n });\n });\n\n it('treats deleting an unknown id as a no-op', async () => {\n await withRepository(async repository => {\n await runRepository(repository.delete<ContractWidget>('missing'));\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.total).toBe(3);\n });\n });\n\n it('loads an empty page from an empty store', async () => {\n await withRepository(async repository => {\n const page = await runRepository(repository.load<ContractWidget>({}));\n\n expect(page.items).toEqual([]);\n expect(page.total).toBe(0);\n }, []);\n });\n });\n};\n"],"names":["accessor","entity","Exit","describe","expect","it","runRepository","runRepositoryExit","key","type","ContractWidget","id","value","name","size","makeContractWidget","widget","describeEntityRepositoryContract","makeRepository","seeded","withRepository","use","seed","repository","page","load","total","toBe","items","map","item","toEqual","request","pageSize","toHaveLength","filtering","property","operator","start","end","sorting","found","get","exit","isFailure","created","save","undefined","toBeDefined","delete"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/contracts/entity-repository.contract.ts"],"sourcesContent":["import type { EntityRepository } from '@entifix/business';\nimport { accessor, type Entity, entity, type EntityId } from '@entifix/core';\nimport { Exit } from 'effect';\nimport { describe, expect, it } from 'vitest';\n\nimport { runRepository, runRepositoryExit } from '../effect/run.js';\n\n/**\n * The entity every implementation of the contract is exercised against. It is\n * declared here rather than by each caller so the suite controls exactly which\n * fields exist.\n */\n@entity({ key: 'contract-widget' })\nexport class ContractWidget implements Entity {\n #id?: EntityId;\n #name?: string;\n #size?: number;\n #tags: readonly string[] = [];\n\n @accessor({ type: 'id' })\n get id(): EntityId {\n return this.#id;\n }\n set id(value: EntityId) {\n this.#id = value;\n }\n\n @accessor({ type: 'string' })\n get name(): string | undefined {\n return this.#name;\n }\n set name(value: string | undefined) {\n this.#name = value;\n }\n\n @accessor({ type: 'number' })\n get size(): number | undefined {\n return this.#size;\n }\n set size(value: number | undefined) {\n this.#size = value;\n }\n\n @accessor({ type: 'scalarCollection' })\n get tags(): readonly string[] {\n return this.#tags;\n }\n set tags(value: readonly string[]) {\n this.#tags = value;\n }\n}\n\nexport const makeContractWidget = (\n id: EntityId,\n name: string,\n size: number,\n tags: readonly string[] = [],\n): ContractWidget => {\n const widget = new ContractWidget();\n widget.id = id;\n widget.name = name;\n widget.size = size;\n widget.tags = tags;\n return widget;\n};\n\nexport interface EntityRepositoryContractOptions {\n /**\n * Builds a repository over `seed`. Called fresh for each test, so no state\n * leaks between them.\n */\n makeRepository(\n seed: ContractWidget[],\n ): EntityRepository | Promise<EntityRepository>;\n}\n\n/**\n * The reads every {@link EntityRepository} must answer alike: `load` with its\n * paging, filtering and sorting, and `get`. A read-only adapter — one over\n * static files, say — runs this half alone, since it refuses `save` and\n * `delete` by design.\n */\nexport const describeEntityRepositoryReadContract = (\n name: string,\n { makeRepository }: EntityRepositoryContractOptions,\n): void => {\n describe(`EntityRepository read contract: ${name}`, () => {\n const seeded = () => [\n makeContractWidget('w-1', 'Alpha', 10),\n makeContractWidget('w-2', 'Beta', 20),\n makeContractWidget('w-3', 'Gamma', 30),\n ];\n\n const withRepository = async (\n use: (repository: EntityRepository) => Promise<void>,\n seed: ContractWidget[] = seeded(),\n ) => {\n await use(await makeRepository(seed));\n };\n\n it('keeps a record unchanged when a read instance is mutated without a save', async () => {\n await withRepository(async repository => {\n const read = await runRepository(repository.get<ContractWidget>('w-1'));\n read.name = 'Mutated';\n\n const again = await runRepository(\n repository.get<ContractWidget>('w-1'),\n );\n expect(again.name).toBe('Alpha');\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n page.items[0]!.name = 'Mutated';\n const reloaded = await runRepository(\n repository.load<ContractWidget>({}),\n );\n expect(reloaded.items[0]!.name).toBe('Alpha');\n });\n });\n\n it('loads every stored entity with its total', async () => {\n await withRepository(async repository => {\n const page = await runRepository(repository.load<ContractWidget>({}));\n\n expect(page.total).toBe(3);\n expect(page.items.map(item => item.name)).toEqual([\n 'Alpha',\n 'Beta',\n 'Gamma',\n ]);\n });\n });\n\n it('echoes the request it served on the page', async () => {\n await withRepository(async repository => {\n const request = { page: 1, pageSize: 2 };\n const page = await runRepository(\n repository.load<ContractWidget>(request),\n );\n\n expect(page.request).toEqual(request);\n });\n });\n\n it('pages with a 1-based page number', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({ page: 2, pageSize: 2 }),\n );\n\n expect(page.items).toHaveLength(1);\n expect(page.items[0]?.name).toBe('Gamma');\n // The total counts matches, not the page.\n expect(page.total).toBe(3);\n });\n });\n\n it('filters by an equality operator', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n filtering: [{ property: 'name', operator: 'eq', value: 'Beta' }],\n }),\n );\n\n expect(page.items).toHaveLength(1);\n expect(page.items[0]?.id).toBe('w-2');\n expect(page.total).toBe(1);\n });\n });\n\n it('filters by a range operator', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n filtering: [\n { property: 'size', operator: 'between', start: 15, end: 35 },\n ],\n }),\n );\n\n expect(page.items.map(item => item.id)).toEqual(['w-2', 'w-3']);\n });\n });\n\n it('matches `like` case-insensitively on a substring', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n filtering: [{ property: 'name', operator: 'like', value: 'et' }],\n }),\n );\n\n expect(page.items.map(item => item.id)).toEqual(['w-2']);\n });\n });\n\n it('matches `in` and `nin` against any element of an array member', async () => {\n await withRepository(\n async repository => {\n const tagged = await runRepository(\n repository.load<ContractWidget>({\n filtering: [\n { property: 'tags', operator: 'in', values: ['blue', 'red'] },\n ],\n }),\n );\n expect(tagged.items.map(item => item.id)).toEqual(['w-1', 'w-3']);\n\n const untagged = await runRepository(\n repository.load<ContractWidget>({\n filtering: [\n { property: 'tags', operator: 'nin', values: ['blue', 'red'] },\n ],\n }),\n );\n expect(untagged.items.map(item => item.id)).toEqual(['w-2']);\n },\n [\n makeContractWidget('w-1', 'Alpha', 10, ['blue', 'green']),\n makeContractWidget('w-2', 'Beta', 20, ['green']),\n makeContractWidget('w-3', 'Gamma', 30, ['red']),\n ],\n );\n });\n\n it('sorts descending when asked to', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n sorting: [{ 0: { property: 'size', type: 'desc' } }],\n }),\n );\n\n expect(page.items.map(item => item.size)).toEqual([30, 20, 10]);\n });\n });\n\n it('reads a single entity by id', async () => {\n await withRepository(async repository => {\n const found = await runRepository(\n repository.get<ContractWidget>('w-2'),\n );\n\n expect(found.id).toBe('w-2');\n expect(found.name).toBe('Beta');\n });\n });\n\n it('fails rather than resolving undefined for an unknown id', async () => {\n await withRepository(async repository => {\n const exit = await runRepositoryExit(\n repository.get<ContractWidget>('missing'),\n );\n\n expect(Exit.isFailure(exit)).toBe(true);\n });\n });\n\n it('loads an empty page from an empty store', async () => {\n await withRepository(async repository => {\n const page = await runRepository(repository.load<ContractWidget>({}));\n\n expect(page.items).toEqual([]);\n expect(page.total).toBe(0);\n }, []);\n });\n });\n};\n\n/** The writes every {@link EntityRepository} that accepts them must honour. */\nexport const describeEntityRepositoryWriteContract = (\n name: string,\n { makeRepository }: EntityRepositoryContractOptions,\n): void => {\n describe(`EntityRepository write contract: ${name}`, () => {\n const seeded = () => [\n makeContractWidget('w-1', 'Alpha', 10),\n makeContractWidget('w-2', 'Beta', 20),\n makeContractWidget('w-3', 'Gamma', 30),\n ];\n\n const withRepository = async (\n use: (repository: EntityRepository) => Promise<void>,\n seed: ContractWidget[] = seeded(),\n ) => {\n await use(await makeRepository(seed));\n };\n\n it('keeps a saved record unchanged when the saved instance is mutated afterwards', async () => {\n await withRepository(async repository => {\n const widget = makeContractWidget('w-9', 'Delta', 40);\n const returned = await runRepository(repository.save(widget));\n\n widget.name = 'Mutated';\n returned.name = 'Mutated too';\n\n const stored = await runRepository(\n repository.get<ContractWidget>('w-9'),\n );\n expect(stored.name).toBe('Delta');\n });\n });\n\n it('mints an id when saving an entity that has none', async () => {\n await withRepository(async repository => {\n const created = await runRepository(\n repository.save<ContractWidget>(\n makeContractWidget(undefined, 'Delta', 40),\n ),\n );\n\n expect(created.id).toBeDefined();\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.total).toBe(4);\n });\n });\n\n it('overwrites in place when saving an entity that already has an id', async () => {\n await withRepository(async repository => {\n await runRepository(\n repository.save<ContractWidget>(\n makeContractWidget('w-2', 'Renamed', 25),\n ),\n );\n\n const found = await runRepository(\n repository.get<ContractWidget>('w-2'),\n );\n expect(found.name).toBe('Renamed');\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.total).toBe(3);\n });\n });\n\n it('deletes by id', async () => {\n await withRepository(async repository => {\n await runRepository(repository.delete<ContractWidget>('w-1'));\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.items.map(item => item.id)).toEqual(['w-2', 'w-3']);\n });\n });\n\n it('deletes by entity', async () => {\n await withRepository(async repository => {\n await runRepository(\n repository.delete<ContractWidget>(\n makeContractWidget('w-3', 'Gamma', 30),\n ),\n );\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.items.map(item => item.id)).toEqual(['w-1', 'w-2']);\n });\n });\n\n it('treats deleting an unknown id as a no-op', async () => {\n await withRepository(async repository => {\n await runRepository(repository.delete<ContractWidget>('missing'));\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.total).toBe(3);\n });\n });\n });\n};\n\n/**\n * The behaviour every {@link EntityRepository} must exhibit, regardless of what\n * backs it: both halves.\n *\n * Run it against the in-memory fake *and* against each real adapter. That is\n * what stops a fake from drifting into a more forgiving version of the thing it\n * stands in for — the failure mode where a suite is green and production is\n * not. `entifix-ts-testing-integration` will run this same suite against real\n * infrastructure later.\n */\nexport const describeEntityRepositoryContract = (\n name: string,\n options: EntityRepositoryContractOptions,\n): void => {\n describeEntityRepositoryReadContract(name, options);\n describeEntityRepositoryWriteContract(name, options);\n};\n"],"names":["accessor","entity","Exit","describe","expect","it","runRepository","runRepositoryExit","key","type","ContractWidget","id","value","name","size","tags","makeContractWidget","widget","describeEntityRepositoryReadContract","makeRepository","seeded","withRepository","use","seed","repository","read","get","again","toBe","page","load","items","reloaded","total","map","item","toEqual","request","pageSize","toHaveLength","filtering","property","operator","start","end","tagged","values","untagged","sorting","found","exit","isFailure","describeEntityRepositoryWriteContract","returned","save","stored","created","undefined","toBeDefined","delete","describeEntityRepositoryContract","options"],"mappings":";;;;AACA,SAASA,QAAQ,EAAeC,MAAM,QAAuB,gBAAgB;AAC7E,SAASC,IAAI,QAAQ,SAAS;AAC9B,SAASC,QAAQ,EAAEC,MAAM,EAAEC,EAAE,QAAQ,SAAS;AAE9C,SAASC,aAAa,EAAEC,iBAAiB,QAAQ,mBAAmB;OAOnEN,OAAO;IAAEO,KAAK;AAAkB,YAO9BR,SAAS;IAAES,MAAM;AAAK,YAQtBT,SAAS;IAAES,MAAM;AAAS,YAQ1BT,SAAS;IAAES,MAAM;AAAS,YAQ1BT,SAAS;IAAES,MAAM;AAAmB;;IA7BrC,2DACA,+DACA,+DACA;AAJK,IAAA,AAAMC,iBAAN,MAAMA;IAMX,IACIC,KAAe;QACjB,OAAO,gCAAA,IAAI,EAAC,KAAA;IACd;IACA,IAAIA,GAAGC,KAAe,EAAE;QACtB,gCAAA,IAAI,EAAC,KAAA,OAAMA;IACb;IAEA,IACIC,OAA2B;QAC7B,OAAO,gCAAA,IAAI,EAAC,OAAA;IACd;IACA,IAAIA,KAAKD,KAAyB,EAAE;QAClC,gCAAA,IAAI,EAAC,OAAA,SAAQA;IACf;IAEA,IACIE,OAA2B;QAC7B,OAAO,gCAAA,IAAI,EAAC,OAAA;IACd;IACA,IAAIA,KAAKF,KAAyB,EAAE;QAClC,gCAAA,IAAI,EAAC,OAAA,SAAQA;IACf;IAEA,IACIG,OAA0B;QAC5B,OAAO,gCAAA,IAAI,EAAC,OAAA;IACd;IACA,IAAIA,KAAKH,KAAwB,EAAE;QACjC,gCAAA,IAAI,EAAC,OAAA,SAAQA;IACf;;QAnCA,4BAAA;;mBAAA,KAAA;;QACA,4BAAA;;mBAAA,KAAA;;QACA,4BAAA;;mBAAA,KAAA;;QACA,4BAAA;;mBAAA,KAAA;;8CAAA,OAAA,4BAA2B,EAAE;;AAiC/B;4EArCaF;;;;QAOPC;;;;;QAQAE;;;;;QAQAC;;;;;QAQAC;;;;;;AAQN,OAAO,MAAMC,qBAAqB,CAChCL,IACAE,MACAC,MACAC,OAA0B,EAAE;IAE5B,MAAME,SAAS,IAAIP;IACnBO,OAAON,EAAE,GAAGA;IACZM,OAAOJ,IAAI,GAAGA;IACdI,OAAOH,IAAI,GAAGA;IACdG,OAAOF,IAAI,GAAGA;IACd,OAAOE;AACT,EAAE;AAYF;;;;;CAKC,GACD,OAAO,MAAMC,uCAAuC,CAClDL,MACA,EAAEM,cAAc,EAAmC;IAEnDhB,SAAS,CAAC,gCAAgC,EAAEU,MAAM,EAAE;QAClD,MAAMO,SAAS,IAAM;gBACnBJ,mBAAmB,OAAO,SAAS;gBACnCA,mBAAmB,OAAO,QAAQ;gBAClCA,mBAAmB,OAAO,SAAS;aACpC;QAED,MAAMK,iBAAiB,OACrBC,KACAC,OAAyBH,QAAQ;YAEjC,MAAME,IAAI,MAAMH,eAAeI;QACjC;QAEAlB,GAAG,2EAA2E;YAC5E,MAAMgB,eAAe,OAAMG;gBACzB,MAAMC,OAAO,MAAMnB,cAAckB,WAAWE,GAAG,CAAiB;gBAChED,KAAKZ,IAAI,GAAG;gBAEZ,MAAMc,QAAQ,MAAMrB,cAClBkB,WAAWE,GAAG,CAAiB;gBAEjCtB,OAAOuB,MAAMd,IAAI,EAAEe,IAAI,CAAC;gBAExB,MAAMC,OAAO,MAAMvB,cAAckB,WAAWM,IAAI,CAAiB,CAAC;gBAClED,KAAKE,KAAK,CAAC,EAAE,CAAElB,IAAI,GAAG;gBACtB,MAAMmB,WAAW,MAAM1B,cACrBkB,WAAWM,IAAI,CAAiB,CAAC;gBAEnC1B,OAAO4B,SAASD,KAAK,CAAC,EAAE,CAAElB,IAAI,EAAEe,IAAI,CAAC;YACvC;QACF;QAEAvB,GAAG,4CAA4C;YAC7C,MAAMgB,eAAe,OAAMG;gBACzB,MAAMK,OAAO,MAAMvB,cAAckB,WAAWM,IAAI,CAAiB,CAAC;gBAElE1B,OAAOyB,KAAKI,KAAK,EAAEL,IAAI,CAAC;gBACxBxB,OAAOyB,KAAKE,KAAK,CAACG,GAAG,CAACC,CAAAA,OAAQA,KAAKtB,IAAI,GAAGuB,OAAO,CAAC;oBAChD;oBACA;oBACA;iBACD;YACH;QACF;QAEA/B,GAAG,4CAA4C;YAC7C,MAAMgB,eAAe,OAAMG;gBACzB,MAAMa,UAAU;oBAAER,MAAM;oBAAGS,UAAU;gBAAE;gBACvC,MAAMT,OAAO,MAAMvB,cACjBkB,WAAWM,IAAI,CAAiBO;gBAGlCjC,OAAOyB,KAAKQ,OAAO,EAAED,OAAO,CAACC;YAC/B;QACF;QAEAhC,GAAG,oCAAoC;YACrC,MAAMgB,eAAe,OAAMG;oBAMlBK;gBALP,MAAMA,OAAO,MAAMvB,cACjBkB,WAAWM,IAAI,CAAiB;oBAAED,MAAM;oBAAGS,UAAU;gBAAE;gBAGzDlC,OAAOyB,KAAKE,KAAK,EAAEQ,YAAY,CAAC;gBAChCnC,QAAOyB,eAAAA,KAAKE,KAAK,CAAC,EAAE,qBAAbF,aAAehB,IAAI,EAAEe,IAAI,CAAC;gBACjC,0CAA0C;gBAC1CxB,OAAOyB,KAAKI,KAAK,EAAEL,IAAI,CAAC;YAC1B;QACF;QAEAvB,GAAG,mCAAmC;YACpC,MAAMgB,eAAe,OAAMG;oBAQlBK;gBAPP,MAAMA,OAAO,MAAMvB,cACjBkB,WAAWM,IAAI,CAAiB;oBAC9BU,WAAW;wBAAC;4BAAEC,UAAU;4BAAQC,UAAU;4BAAM9B,OAAO;wBAAO;qBAAE;gBAClE;gBAGFR,OAAOyB,KAAKE,KAAK,EAAEQ,YAAY,CAAC;gBAChCnC,QAAOyB,eAAAA,KAAKE,KAAK,CAAC,EAAE,qBAAbF,aAAelB,EAAE,EAAEiB,IAAI,CAAC;gBAC/BxB,OAAOyB,KAAKI,KAAK,EAAEL,IAAI,CAAC;YAC1B;QACF;QAEAvB,GAAG,+BAA+B;YAChC,MAAMgB,eAAe,OAAMG;gBACzB,MAAMK,OAAO,MAAMvB,cACjBkB,WAAWM,IAAI,CAAiB;oBAC9BU,WAAW;wBACT;4BAAEC,UAAU;4BAAQC,UAAU;4BAAWC,OAAO;4BAAIC,KAAK;wBAAG;qBAC7D;gBACH;gBAGFxC,OAAOyB,KAAKE,KAAK,CAACG,GAAG,CAACC,CAAAA,OAAQA,KAAKxB,EAAE,GAAGyB,OAAO,CAAC;oBAAC;oBAAO;iBAAM;YAChE;QACF;QAEA/B,GAAG,oDAAoD;YACrD,MAAMgB,eAAe,OAAMG;gBACzB,MAAMK,OAAO,MAAMvB,cACjBkB,WAAWM,IAAI,CAAiB;oBAC9BU,WAAW;wBAAC;4BAAEC,UAAU;4BAAQC,UAAU;4BAAQ9B,OAAO;wBAAK;qBAAE;gBAClE;gBAGFR,OAAOyB,KAAKE,KAAK,CAACG,GAAG,CAACC,CAAAA,OAAQA,KAAKxB,EAAE,GAAGyB,OAAO,CAAC;oBAAC;iBAAM;YACzD;QACF;QAEA/B,GAAG,iEAAiE;YAClE,MAAMgB,eACJ,OAAMG;gBACJ,MAAMqB,SAAS,MAAMvC,cACnBkB,WAAWM,IAAI,CAAiB;oBAC9BU,WAAW;wBACT;4BAAEC,UAAU;4BAAQC,UAAU;4BAAMI,QAAQ;gCAAC;gCAAQ;6BAAM;wBAAC;qBAC7D;gBACH;gBAEF1C,OAAOyC,OAAOd,KAAK,CAACG,GAAG,CAACC,CAAAA,OAAQA,KAAKxB,EAAE,GAAGyB,OAAO,CAAC;oBAAC;oBAAO;iBAAM;gBAEhE,MAAMW,WAAW,MAAMzC,cACrBkB,WAAWM,IAAI,CAAiB;oBAC9BU,WAAW;wBACT;4BAAEC,UAAU;4BAAQC,UAAU;4BAAOI,QAAQ;gCAAC;gCAAQ;6BAAM;wBAAC;qBAC9D;gBACH;gBAEF1C,OAAO2C,SAAShB,KAAK,CAACG,GAAG,CAACC,CAAAA,OAAQA,KAAKxB,EAAE,GAAGyB,OAAO,CAAC;oBAAC;iBAAM;YAC7D,GACA;gBACEpB,mBAAmB,OAAO,SAAS,IAAI;oBAAC;oBAAQ;iBAAQ;gBACxDA,mBAAmB,OAAO,QAAQ,IAAI;oBAAC;iBAAQ;gBAC/CA,mBAAmB,OAAO,SAAS,IAAI;oBAAC;iBAAM;aAC/C;QAEL;QAEAX,GAAG,kCAAkC;YACnC,MAAMgB,eAAe,OAAMG;gBACzB,MAAMK,OAAO,MAAMvB,cACjBkB,WAAWM,IAAI,CAAiB;oBAC9BkB,SAAS;wBAAC;4BAAE,GAAG;gCAAEP,UAAU;gCAAQhC,MAAM;4BAAO;wBAAE;qBAAE;gBACtD;gBAGFL,OAAOyB,KAAKE,KAAK,CAACG,GAAG,CAACC,CAAAA,OAAQA,KAAKrB,IAAI,GAAGsB,OAAO,CAAC;oBAAC;oBAAI;oBAAI;iBAAG;YAChE;QACF;QAEA/B,GAAG,+BAA+B;YAChC,MAAMgB,eAAe,OAAMG;gBACzB,MAAMyB,QAAQ,MAAM3C,cAClBkB,WAAWE,GAAG,CAAiB;gBAGjCtB,OAAO6C,MAAMtC,EAAE,EAAEiB,IAAI,CAAC;gBACtBxB,OAAO6C,MAAMpC,IAAI,EAAEe,IAAI,CAAC;YAC1B;QACF;QAEAvB,GAAG,2DAA2D;YAC5D,MAAMgB,eAAe,OAAMG;gBACzB,MAAM0B,OAAO,MAAM3C,kBACjBiB,WAAWE,GAAG,CAAiB;gBAGjCtB,OAAOF,KAAKiD,SAAS,CAACD,OAAOtB,IAAI,CAAC;YACpC;QACF;QAEAvB,GAAG,2CAA2C;YAC5C,MAAMgB,eAAe,OAAMG;gBACzB,MAAMK,OAAO,MAAMvB,cAAckB,WAAWM,IAAI,CAAiB,CAAC;gBAElE1B,OAAOyB,KAAKE,KAAK,EAAEK,OAAO,CAAC,EAAE;gBAC7BhC,OAAOyB,KAAKI,KAAK,EAAEL,IAAI,CAAC;YAC1B,GAAG,EAAE;QACP;IACF;AACF,EAAE;AAEF,6EAA6E,GAC7E,OAAO,MAAMwB,wCAAwC,CACnDvC,MACA,EAAEM,cAAc,EAAmC;IAEnDhB,SAAS,CAAC,iCAAiC,EAAEU,MAAM,EAAE;QACnD,MAAMO,SAAS,IAAM;gBACnBJ,mBAAmB,OAAO,SAAS;gBACnCA,mBAAmB,OAAO,QAAQ;gBAClCA,mBAAmB,OAAO,SAAS;aACpC;QAED,MAAMK,iBAAiB,OACrBC,KACAC,OAAyBH,QAAQ;YAEjC,MAAME,IAAI,MAAMH,eAAeI;QACjC;QAEAlB,GAAG,gFAAgF;YACjF,MAAMgB,eAAe,OAAMG;gBACzB,MAAMP,SAASD,mBAAmB,OAAO,SAAS;gBAClD,MAAMqC,WAAW,MAAM/C,cAAckB,WAAW8B,IAAI,CAACrC;gBAErDA,OAAOJ,IAAI,GAAG;gBACdwC,SAASxC,IAAI,GAAG;gBAEhB,MAAM0C,SAAS,MAAMjD,cACnBkB,WAAWE,GAAG,CAAiB;gBAEjCtB,OAAOmD,OAAO1C,IAAI,EAAEe,IAAI,CAAC;YAC3B;QACF;QAEAvB,GAAG,mDAAmD;YACpD,MAAMgB,eAAe,OAAMG;gBACzB,MAAMgC,UAAU,MAAMlD,cACpBkB,WAAW8B,IAAI,CACbtC,mBAAmByC,WAAW,SAAS;gBAI3CrD,OAAOoD,QAAQ7C,EAAE,EAAE+C,WAAW;gBAE9B,MAAM7B,OAAO,MAAMvB,cAAckB,WAAWM,IAAI,CAAiB,CAAC;gBAClE1B,OAAOyB,KAAKI,KAAK,EAAEL,IAAI,CAAC;YAC1B;QACF;QAEAvB,GAAG,oEAAoE;YACrE,MAAMgB,eAAe,OAAMG;gBACzB,MAAMlB,cACJkB,WAAW8B,IAAI,CACbtC,mBAAmB,OAAO,WAAW;gBAIzC,MAAMiC,QAAQ,MAAM3C,cAClBkB,WAAWE,GAAG,CAAiB;gBAEjCtB,OAAO6C,MAAMpC,IAAI,EAAEe,IAAI,CAAC;gBAExB,MAAMC,OAAO,MAAMvB,cAAckB,WAAWM,IAAI,CAAiB,CAAC;gBAClE1B,OAAOyB,KAAKI,KAAK,EAAEL,IAAI,CAAC;YAC1B;QACF;QAEAvB,GAAG,iBAAiB;YAClB,MAAMgB,eAAe,OAAMG;gBACzB,MAAMlB,cAAckB,WAAWmC,MAAM,CAAiB;gBAEtD,MAAM9B,OAAO,MAAMvB,cAAckB,WAAWM,IAAI,CAAiB,CAAC;gBAClE1B,OAAOyB,KAAKE,KAAK,CAACG,GAAG,CAACC,CAAAA,OAAQA,KAAKxB,EAAE,GAAGyB,OAAO,CAAC;oBAAC;oBAAO;iBAAM;YAChE;QACF;QAEA/B,GAAG,qBAAqB;YACtB,MAAMgB,eAAe,OAAMG;gBACzB,MAAMlB,cACJkB,WAAWmC,MAAM,CACf3C,mBAAmB,OAAO,SAAS;gBAIvC,MAAMa,OAAO,MAAMvB,cAAckB,WAAWM,IAAI,CAAiB,CAAC;gBAClE1B,OAAOyB,KAAKE,KAAK,CAACG,GAAG,CAACC,CAAAA,OAAQA,KAAKxB,EAAE,GAAGyB,OAAO,CAAC;oBAAC;oBAAO;iBAAM;YAChE;QACF;QAEA/B,GAAG,4CAA4C;YAC7C,MAAMgB,eAAe,OAAMG;gBACzB,MAAMlB,cAAckB,WAAWmC,MAAM,CAAiB;gBAEtD,MAAM9B,OAAO,MAAMvB,cAAckB,WAAWM,IAAI,CAAiB,CAAC;gBAClE1B,OAAOyB,KAAKI,KAAK,EAAEL,IAAI,CAAC;YAC1B;QACF;IACF;AACF,EAAE;AAEF;;;;;;;;;CASC,GACD,OAAO,MAAMgC,mCAAmC,CAC9C/C,MACAgD;IAEA3C,qCAAqCL,MAAMgD;IAC3CT,sCAAsCvC,MAAMgD;AAC9C,EAAE;SApXF,AAAanD,mBAAAA,cAqCZ"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/contracts/index.ts"],"names":[],"mappings":"AAAA,cAAc,iCAAiC,CAAC;AAChD,cAAc,iCAAiC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/contracts/index.ts"],"names":[],"mappings":"AAAA,cAAc,iCAAiC,CAAC;AAChD,cAAc,0BAA0B,CAAC;AACzC,cAAc,iCAAiC,CAAC"}
|
package/dist/contracts/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/contracts/index.ts"],"sourcesContent":["export * from './entity-repository.contract.js';\nexport * from './transaction-ports.contract.js';\n"],"names":[],"mappings":"AAAA,cAAc,kCAAkC;AAChD,cAAc,kCAAkC"}
|
|
1
|
+
{"version":3,"sources":["../../src/contracts/index.ts"],"sourcesContent":["export * from './entity-repository.contract.js';\nexport * from './saga-store.contract.js';\nexport * from './transaction-ports.contract.js';\n"],"names":[],"mappings":"AAAA,cAAc,kCAAkC;AAChD,cAAc,2BAA2B;AACzC,cAAc,kCAAkC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SagaStore } from '@entifix/transactions';
|
|
2
|
+
/**
|
|
3
|
+
* What every {@link SagaStore} must guarantee to the saga engine: an instance
|
|
4
|
+
* that can be started twice without being overwritten, outcomes that append in
|
|
5
|
+
* arrival order, a compensation flag that lands on one step, and a resume claim
|
|
6
|
+
* that only ever takes an unfinished instance past its deadline.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately not asserted: two concurrent claims producing one winner. That
|
|
9
|
+
* is a property of the conditional write against a real server, and a contract
|
|
10
|
+
* run in one process cannot interleave the two.
|
|
11
|
+
*/
|
|
12
|
+
export declare const describeSagaStoreContract: (name: string, makeStore: () => SagaStore | Promise<SagaStore>) => void;
|
|
13
|
+
//# sourceMappingURL=saga-store.contract.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"saga-store.contract.d.ts","sourceRoot":"","sources":["../../src/contracts/saga-store.contract.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,SAAS,EACV,MAAM,uBAAuB,CAAC;AA6B/B;;;;;;;;;GASG;AACH,eAAO,MAAM,yBAAyB,GACpC,MAAM,MAAM,EACZ,WAAW,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,KAC9C,IAiHF,CAAC"}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
const run = (effect)=>Effect.runPromise(effect);
|
|
5
|
+
/** A threshold every instance is older than, so no spec waits on a clock. */ const ANY_AGE = -60000;
|
|
6
|
+
/** A threshold no instance just written is older than. */ const AN_HOUR = 3600000;
|
|
7
|
+
const instance = (sagaId, over = {})=>_extends({
|
|
8
|
+
sagaId,
|
|
9
|
+
definition: 'contract-flow',
|
|
10
|
+
state: 'RUNNING',
|
|
11
|
+
stepIndex: 0,
|
|
12
|
+
outcomes: [],
|
|
13
|
+
inputs: {},
|
|
14
|
+
resumeAttempts: 0,
|
|
15
|
+
createdAt: new Date().toISOString()
|
|
16
|
+
}, over);
|
|
17
|
+
const outcome = (stepId)=>({
|
|
18
|
+
stepId,
|
|
19
|
+
calls: []
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* What every {@link SagaStore} must guarantee to the saga engine: an instance
|
|
23
|
+
* that can be started twice without being overwritten, outcomes that append in
|
|
24
|
+
* arrival order, a compensation flag that lands on one step, and a resume claim
|
|
25
|
+
* that only ever takes an unfinished instance past its deadline.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately not asserted: two concurrent claims producing one winner. That
|
|
28
|
+
* is a property of the conditional write against a real server, and a contract
|
|
29
|
+
* run in one process cannot interleave the two.
|
|
30
|
+
*/ export const describeSagaStoreContract = (name, makeStore)=>{
|
|
31
|
+
describe(`SagaStore contract: ${name}`, ()=>{
|
|
32
|
+
it('starts an instance and reads it back, stamped', async ()=>{
|
|
33
|
+
const store = await makeStore();
|
|
34
|
+
await run(store.start(instance('s-1')));
|
|
35
|
+
const read = await run(store.get('s-1'));
|
|
36
|
+
expect(read).toMatchObject({
|
|
37
|
+
sagaId: 's-1',
|
|
38
|
+
state: 'RUNNING'
|
|
39
|
+
});
|
|
40
|
+
expect(read == null ? void 0 : read.updatedAt).toEqual(expect.any(String));
|
|
41
|
+
});
|
|
42
|
+
it('answers undefined for an instance it never started', async ()=>{
|
|
43
|
+
const store = await makeStore();
|
|
44
|
+
expect(await run(store.get('absent'))).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
// A coordinator restarted mid-flight re-enters `start` with an id it
|
|
47
|
+
// already wrote; what it had recorded must survive that.
|
|
48
|
+
it('does not overwrite an instance started twice', async ()=>{
|
|
49
|
+
const store = await makeStore();
|
|
50
|
+
await run(store.start(instance('s-1')));
|
|
51
|
+
await run(store.recordOutcome('s-1', outcome('reserve')));
|
|
52
|
+
await run(store.start(instance('s-1', {
|
|
53
|
+
stepIndex: 9
|
|
54
|
+
})));
|
|
55
|
+
const read = await run(store.get('s-1'));
|
|
56
|
+
expect(read == null ? void 0 : read.stepIndex).toBe(0);
|
|
57
|
+
expect(read == null ? void 0 : read.outcomes.map((entry)=>entry.stepId)).toEqual([
|
|
58
|
+
'reserve'
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
it('moves the step index and appends outcomes in arrival order', async ()=>{
|
|
62
|
+
const store = await makeStore();
|
|
63
|
+
await run(store.start(instance('s-1')));
|
|
64
|
+
await run(store.beginStep('s-1', 1));
|
|
65
|
+
await run(store.recordOutcome('s-1', outcome('reserve')));
|
|
66
|
+
await run(store.recordOutcome('s-1', outcome('charge')));
|
|
67
|
+
const read = await run(store.get('s-1'));
|
|
68
|
+
expect(read == null ? void 0 : read.stepIndex).toBe(1);
|
|
69
|
+
expect(read == null ? void 0 : read.outcomes.map((entry)=>entry.stepId)).toEqual([
|
|
70
|
+
'reserve',
|
|
71
|
+
'charge'
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
it('settles with a state and the error that caused it', async ()=>{
|
|
75
|
+
const store = await makeStore();
|
|
76
|
+
await run(store.start(instance('s-1')));
|
|
77
|
+
await run(store.settle('s-1', 'COMPENSATED', 'charge refused'));
|
|
78
|
+
expect(await run(store.get('s-1'))).toMatchObject({
|
|
79
|
+
state: 'COMPENSATED',
|
|
80
|
+
error: 'charge refused'
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
it('flags only the named step compensated', async ()=>{
|
|
84
|
+
const store = await makeStore();
|
|
85
|
+
await run(store.start(instance('s-1')));
|
|
86
|
+
await run(store.recordOutcome('s-1', outcome('reserve')));
|
|
87
|
+
await run(store.recordOutcome('s-1', outcome('charge')));
|
|
88
|
+
await run(store.markCompensated('s-1', 'reserve'));
|
|
89
|
+
const read = await run(store.get('s-1'));
|
|
90
|
+
expect(read == null ? void 0 : read.outcomes).toEqual([
|
|
91
|
+
expect.objectContaining({
|
|
92
|
+
stepId: 'reserve',
|
|
93
|
+
compensated: true
|
|
94
|
+
}),
|
|
95
|
+
expect.not.objectContaining({
|
|
96
|
+
compensated: true
|
|
97
|
+
})
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
// `COMPENSATING` is stuck too: a coordinator that died while unwinding is
|
|
101
|
+
// the state that leaves holds in place.
|
|
102
|
+
it('finds unfinished instances past the deadline, and nothing else', async ()=>{
|
|
103
|
+
const store = await makeStore();
|
|
104
|
+
await run(store.start(instance('running')));
|
|
105
|
+
await run(store.start(instance('unwinding', {
|
|
106
|
+
state: 'COMPENSATING'
|
|
107
|
+
})));
|
|
108
|
+
await run(store.start(instance('done', {
|
|
109
|
+
state: 'COMPLETED'
|
|
110
|
+
})));
|
|
111
|
+
const stale = await run(store.findStale(ANY_AGE));
|
|
112
|
+
expect(stale.map((entry)=>entry.sagaId).sort()).toEqual([
|
|
113
|
+
'running',
|
|
114
|
+
'unwinding'
|
|
115
|
+
]);
|
|
116
|
+
expect(await run(store.findStale(AN_HOUR))).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
it('claims a stale instance by counting the attempt', async ()=>{
|
|
119
|
+
var _this;
|
|
120
|
+
const store = await makeStore();
|
|
121
|
+
await run(store.start(instance('s-1')));
|
|
122
|
+
const claimed = await run(store.claimForResume('s-1', ANY_AGE));
|
|
123
|
+
expect(claimed).toMatchObject({
|
|
124
|
+
sagaId: 's-1',
|
|
125
|
+
resumeAttempts: 1
|
|
126
|
+
});
|
|
127
|
+
expect((_this = await run(store.get('s-1'))) == null ? void 0 : _this.resumeAttempts).toBe(1);
|
|
128
|
+
});
|
|
129
|
+
it('refuses to claim a settled or a fresh instance', async ()=>{
|
|
130
|
+
const store = await makeStore();
|
|
131
|
+
await run(store.start(instance('done', {
|
|
132
|
+
state: 'COMPLETED'
|
|
133
|
+
})));
|
|
134
|
+
await run(store.start(instance('fresh')));
|
|
135
|
+
expect(await run(store.claimForResume('done', ANY_AGE))).toBeUndefined();
|
|
136
|
+
expect(await run(store.claimForResume('fresh', AN_HOUR))).toBeUndefined();
|
|
137
|
+
expect(await run(store.claimForResume('absent', ANY_AGE))).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
//# sourceMappingURL=saga-store.contract.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/contracts/saga-store.contract.ts"],"sourcesContent":["import type {\n SagaInstance,\n SagaStepOutcome,\n SagaStore,\n} from '@entifix/transactions';\nimport { Effect } from 'effect';\nimport { describe, expect, it } from 'vitest';\n\nconst run = <TValue, TError>(effect: Effect.Effect<TValue, TError>) =>\n Effect.runPromise(effect);\n\n/** A threshold every instance is older than, so no spec waits on a clock. */\nconst ANY_AGE = -60_000;\n/** A threshold no instance just written is older than. */\nconst AN_HOUR = 3_600_000;\n\nconst instance = (\n sagaId: string,\n over: Partial<Omit<SagaInstance, 'updatedAt'>> = {},\n): Omit<SagaInstance, 'updatedAt'> => ({\n sagaId,\n definition: 'contract-flow',\n state: 'RUNNING',\n stepIndex: 0,\n outcomes: [],\n inputs: {},\n resumeAttempts: 0,\n createdAt: new Date().toISOString(),\n ...over,\n});\n\nconst outcome = (stepId: string): SagaStepOutcome => ({ stepId, calls: [] });\n\n/**\n * What every {@link SagaStore} must guarantee to the saga engine: an instance\n * that can be started twice without being overwritten, outcomes that append in\n * arrival order, a compensation flag that lands on one step, and a resume claim\n * that only ever takes an unfinished instance past its deadline.\n *\n * Deliberately not asserted: two concurrent claims producing one winner. That\n * is a property of the conditional write against a real server, and a contract\n * run in one process cannot interleave the two.\n */\nexport const describeSagaStoreContract = (\n name: string,\n makeStore: () => SagaStore | Promise<SagaStore>,\n): void => {\n describe(`SagaStore contract: ${name}`, () => {\n it('starts an instance and reads it back, stamped', async () => {\n const store = await makeStore();\n\n await run(store.start(instance('s-1')));\n const read = await run(store.get('s-1'));\n\n expect(read).toMatchObject({ sagaId: 's-1', state: 'RUNNING' });\n expect(read?.updatedAt).toEqual(expect.any(String));\n });\n\n it('answers undefined for an instance it never started', async () => {\n const store = await makeStore();\n\n expect(await run(store.get('absent'))).toBeUndefined();\n });\n\n // A coordinator restarted mid-flight re-enters `start` with an id it\n // already wrote; what it had recorded must survive that.\n it('does not overwrite an instance started twice', async () => {\n const store = await makeStore();\n await run(store.start(instance('s-1')));\n await run(store.recordOutcome('s-1', outcome('reserve')));\n\n await run(store.start(instance('s-1', { stepIndex: 9 })));\n\n const read = await run(store.get('s-1'));\n expect(read?.stepIndex).toBe(0);\n expect(read?.outcomes.map(entry => entry.stepId)).toEqual(['reserve']);\n });\n\n it('moves the step index and appends outcomes in arrival order', async () => {\n const store = await makeStore();\n await run(store.start(instance('s-1')));\n\n await run(store.beginStep('s-1', 1));\n await run(store.recordOutcome('s-1', outcome('reserve')));\n await run(store.recordOutcome('s-1', outcome('charge')));\n\n const read = await run(store.get('s-1'));\n expect(read?.stepIndex).toBe(1);\n expect(read?.outcomes.map(entry => entry.stepId)).toEqual([\n 'reserve',\n 'charge',\n ]);\n });\n\n it('settles with a state and the error that caused it', async () => {\n const store = await makeStore();\n await run(store.start(instance('s-1')));\n\n await run(store.settle('s-1', 'COMPENSATED', 'charge refused'));\n\n expect(await run(store.get('s-1'))).toMatchObject({\n state: 'COMPENSATED',\n error: 'charge refused',\n });\n });\n\n it('flags only the named step compensated', async () => {\n const store = await makeStore();\n await run(store.start(instance('s-1')));\n await run(store.recordOutcome('s-1', outcome('reserve')));\n await run(store.recordOutcome('s-1', outcome('charge')));\n\n await run(store.markCompensated('s-1', 'reserve'));\n\n const read = await run(store.get('s-1'));\n expect(read?.outcomes).toEqual([\n expect.objectContaining({ stepId: 'reserve', compensated: true }),\n expect.not.objectContaining({ compensated: true }),\n ]);\n });\n\n // `COMPENSATING` is stuck too: a coordinator that died while unwinding is\n // the state that leaves holds in place.\n it('finds unfinished instances past the deadline, and nothing else', async () => {\n const store = await makeStore();\n await run(store.start(instance('running')));\n await run(store.start(instance('unwinding', { state: 'COMPENSATING' })));\n await run(store.start(instance('done', { state: 'COMPLETED' })));\n\n const stale = await run(store.findStale(ANY_AGE));\n expect(stale.map(entry => entry.sagaId).sort()).toEqual([\n 'running',\n 'unwinding',\n ]);\n expect(await run(store.findStale(AN_HOUR))).toEqual([]);\n });\n\n it('claims a stale instance by counting the attempt', async () => {\n const store = await makeStore();\n await run(store.start(instance('s-1')));\n\n const claimed = await run(store.claimForResume('s-1', ANY_AGE));\n\n expect(claimed).toMatchObject({ sagaId: 's-1', resumeAttempts: 1 });\n expect((await run(store.get('s-1')))?.resumeAttempts).toBe(1);\n });\n\n it('refuses to claim a settled or a fresh instance', async () => {\n const store = await makeStore();\n await run(store.start(instance('done', { state: 'COMPLETED' })));\n await run(store.start(instance('fresh')));\n\n expect(await run(store.claimForResume('done', ANY_AGE))).toBeUndefined();\n expect(await run(store.claimForResume('fresh', AN_HOUR))).toBeUndefined();\n expect(\n await run(store.claimForResume('absent', ANY_AGE)),\n ).toBeUndefined();\n });\n });\n};\n"],"names":["Effect","describe","expect","it","run","effect","runPromise","ANY_AGE","AN_HOUR","instance","sagaId","over","definition","state","stepIndex","outcomes","inputs","resumeAttempts","createdAt","Date","toISOString","outcome","stepId","calls","describeSagaStoreContract","name","makeStore","store","start","read","get","toMatchObject","updatedAt","toEqual","any","String","toBeUndefined","recordOutcome","toBe","map","entry","beginStep","settle","error","markCompensated","objectContaining","compensated","not","stale","findStale","sort","claimed","claimForResume"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entity-repository.d.ts","sourceRoot":"","sources":["../../src/doubles/entity-repository.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"entity-repository.d.ts","sourceRoot":"","sources":["../../src/doubles/entity-repository.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,MAAM,EAaZ,MAAM,eAAe,CAAC;AA0KvB,MAAM,WAAW,wBAAyB,SAAQ,gBAAgB;IAChE,uDAAuD;IACvD,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;IACzB,4DAA4D;IAC5D,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAC5B;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CAAC;CACrC;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,4BAA4B,GACvC,OAAM,MAAM,EAAO,KAClB,wBAqGF,CAAC"}
|