@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
|
@@ -1,8 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { EntifixConnError } from '@entifix/core';
|
|
1
|
+
import { deserializeSingleEntity, EntifixConnError, EntityCollectionLink, EntityLink, extractMetaAccessors, serializeEntity } from '@entifix/core';
|
|
3
2
|
import { Effect } from 'effect';
|
|
4
3
|
/** Escapes a string so `like`/`nlike` match literally, as the Mongo adapter does. */ const escapeRegex = (value)=>value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
5
4
|
const isFilterGroup = (node)=>!('property' in node) && (node.operator === 'and' || node.operator === 'or');
|
|
5
|
+
/**
|
|
6
|
+
* A member as the Mongo adapter stores it, and so as its queries see it: a
|
|
7
|
+
* link is its target's id, a collection link the list of their ids. Without
|
|
8
|
+
* this no operator can compare a link, since `eq` against an id is never
|
|
9
|
+
* `true` for an object.
|
|
10
|
+
*/ const storedValue = (value)=>{
|
|
11
|
+
if (value instanceof EntityLink) return value.id;
|
|
12
|
+
if (value instanceof EntityCollectionLink) return value.ids;
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
/** `$in` semantics: a scalar matches a value, an array when any element does. */ const matchesAny = (actual, values)=>Array.isArray(actual) ? actual.some((element)=>values.includes(element)) : values.includes(actual);
|
|
6
16
|
const compareValues = (left, right)=>{
|
|
7
17
|
if (left === right) return 0;
|
|
8
18
|
// Mongo sorts missing values below present ones.
|
|
@@ -20,7 +30,7 @@ const matchesNode = (item, node)=>{
|
|
|
20
30
|
if (isFilterGroup(node)) {
|
|
21
31
|
return node.operator === 'or' ? node.values.some((child)=>matchesNode(item, child)) : node.values.every((child)=>matchesNode(item, child));
|
|
22
32
|
}
|
|
23
|
-
const actual = item[String(node.property)];
|
|
33
|
+
const actual = storedValue(item[String(node.property)]);
|
|
24
34
|
switch(node.operator){
|
|
25
35
|
case 'eq':
|
|
26
36
|
return actual === node.value;
|
|
@@ -35,9 +45,9 @@ const matchesNode = (item, node)=>{
|
|
|
35
45
|
case 'lte':
|
|
36
46
|
return compareValues(actual, node.value) <= 0;
|
|
37
47
|
case 'in':
|
|
38
|
-
return node.values
|
|
48
|
+
return matchesAny(actual, node.values);
|
|
39
49
|
case 'nin':
|
|
40
|
-
return !node.values
|
|
50
|
+
return !matchesAny(actual, node.values);
|
|
41
51
|
case 'between':
|
|
42
52
|
return compareValues(actual, node.start) >= 0 && compareValues(actual, node.end) <= 0;
|
|
43
53
|
case 'nbetween':
|
|
@@ -80,6 +90,24 @@ const applySorting = (items, sorting)=>{
|
|
|
80
90
|
return 0;
|
|
81
91
|
});
|
|
82
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* A copy of `entity` that shares no state with it.
|
|
95
|
+
*
|
|
96
|
+
* Through the entity's own mapping — serialize, then deserialize — because that
|
|
97
|
+
* is what a real adapter does to every record on the way in and out: a caller
|
|
98
|
+
* can never hold the instance the store keeps, and a member the mapping drops
|
|
99
|
+
* (`hidden`, `readonly`) does not survive a save here either. A class that
|
|
100
|
+
* declares no accessors has no mapping to go through, so it is copied member by
|
|
101
|
+
* member instead; plain spec fixtures are the case this keeps working.
|
|
102
|
+
*/ const copyOf = (entity)=>{
|
|
103
|
+
const entityConstructor = entity.constructor;
|
|
104
|
+
if (extractMetaAccessors(entityConstructor).length === 0) {
|
|
105
|
+
return Effect.succeed(Object.assign(Object.create(Object.getPrototypeOf(entity)), entity));
|
|
106
|
+
}
|
|
107
|
+
// `undefined` only comes back for `null`/`undefined` input, and
|
|
108
|
+
// `serializeEntity` always hands over an object.
|
|
109
|
+
return deserializeSingleEntity(entityConstructor, serializeEntity(entityConstructor, entity));
|
|
110
|
+
};
|
|
83
111
|
/**
|
|
84
112
|
* In-memory {@link EntityRepository}: the default double for anything that
|
|
85
113
|
* depends on persistence.
|
|
@@ -107,30 +135,36 @@ const applySorting = (items, sorting)=>{
|
|
|
107
135
|
const page = (_request_page = request.page) != null ? _request_page : 1;
|
|
108
136
|
const pageSize = (_request_pageSize = request.pageSize) != null ? _request_pageSize : 10;
|
|
109
137
|
const start = (page - 1) * pageSize;
|
|
110
|
-
return Effect.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
138
|
+
return Effect.forEach(matched.slice(start, start + pageSize), copyOf).pipe(Effect.map((copies)=>({
|
|
139
|
+
items: copies,
|
|
140
|
+
total: matched.length,
|
|
141
|
+
request
|
|
142
|
+
})));
|
|
115
143
|
});
|
|
116
144
|
const get = (id)=>guard(()=>{
|
|
117
145
|
const found = items.find((item)=>item.id === id);
|
|
118
146
|
return found === undefined ? Effect.fail(new EntifixConnError('Entity not found', undefined, {
|
|
119
147
|
id
|
|
120
|
-
})) :
|
|
148
|
+
})) : copyOf(found);
|
|
121
149
|
});
|
|
122
150
|
const save = (entity)=>guard(()=>{
|
|
123
151
|
var _entity_id;
|
|
124
152
|
// A create arrives without an id, so the store mints one — same rule the
|
|
125
|
-
// Mongo adapter follows.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
153
|
+
// Mongo adapter follows. The Web Crypto global rather than `node:crypto`,
|
|
154
|
+
// because a browser bundle cannot resolve a `node:` import and an example
|
|
155
|
+
// with no backend runs this repository in the page.
|
|
156
|
+
entity.id = (_entity_id = entity.id) != null ? _entity_id : globalThis.crypto.randomUUID();
|
|
157
|
+
// The store keeps its own copy and hands back another, so neither the
|
|
158
|
+
// caller's instance nor the returned one can change the record without a
|
|
159
|
+
// second save — which a real adapter guarantees by construction.
|
|
160
|
+
return copyOf(entity).pipe(Effect.tap((stored)=>{
|
|
161
|
+
const index = items.findIndex((item)=>item.id === stored.id);
|
|
162
|
+
if (index === -1) {
|
|
163
|
+
items.push(stored);
|
|
164
|
+
} else {
|
|
165
|
+
items[index] = stored;
|
|
166
|
+
}
|
|
167
|
+
}), Effect.flatMap(copyOf));
|
|
134
168
|
});
|
|
135
169
|
const remove = (entityOrId)=>guard(()=>{
|
|
136
170
|
const id = entityOrId != null && typeof entityOrId === 'object' ? entityOrId.id : entityOrId;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/doubles/entity-repository.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\n\nimport type { EntityRepository } from '@entifix/business';\nimport {\n EntifixConnError,\n type EntifixError,\n type Entity,\n type EntityFilter,\n type EntityFiltering,\n type EntityId,\n type EntityLoadRequest,\n type EntityPage,\n type EntitySorting,\n type FilterGroup,\n} from '@entifix/core';\nimport { Effect } from 'effect';\n\n/** Escapes a string so `like`/`nlike` match literally, as the Mongo adapter does. */\nconst escapeRegex = (value: string): string =>\n value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n\nconst isFilterGroup = <TEntity extends Entity>(\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n): node is FilterGroup<TEntity> =>\n !('property' in node) && (node.operator === 'and' || node.operator === 'or');\n\nconst compareValues = (left: unknown, right: unknown): number => {\n if (left === right) return 0;\n // Mongo sorts missing values below present ones.\n if (left == null) return -1;\n if (right == null) return 1;\n if (typeof left === 'number' && typeof right === 'number') {\n return left - right;\n }\n if (left instanceof Date && right instanceof Date) {\n return left.getTime() - right.getTime();\n }\n return String(left).localeCompare(String(right));\n};\n\nconst matchesNode = <TEntity extends Entity>(\n item: TEntity,\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n): boolean => {\n if (isFilterGroup(node)) {\n return node.operator === 'or'\n ? node.values.some(child => matchesNode(item, child))\n : node.values.every(child => matchesNode(item, child));\n }\n\n const actual = (item as Record<string, unknown>)[String(node.property)];\n\n switch (node.operator) {\n case 'eq':\n return actual === node.value;\n case 'ne':\n return actual !== node.value;\n case 'gt':\n return compareValues(actual, node.value) > 0;\n case 'gte':\n return compareValues(actual, node.value) >= 0;\n case 'lt':\n return compareValues(actual, node.value) < 0;\n case 'lte':\n return compareValues(actual, node.value) <= 0;\n case 'in':\n return node.values.includes(actual as TEntity[keyof TEntity]);\n case 'nin':\n return !node.values.includes(actual as TEntity[keyof TEntity]);\n case 'between':\n return (\n compareValues(actual, node.start) >= 0 &&\n compareValues(actual, node.end) <= 0\n );\n case 'nbetween':\n return !(\n compareValues(actual, node.start) >= 0 &&\n compareValues(actual, node.end) <= 0\n );\n case 'like':\n return new RegExp(escapeRegex(node.value), 'i').test(String(actual));\n case 'nlike':\n return !new RegExp(escapeRegex(node.value), 'i').test(String(actual));\n case 'isNull':\n return actual == null;\n case 'isNotNull':\n return actual != null;\n default: {\n // Exhaustiveness guard — a new operator must be handled explicitly.\n const never: never = node;\n return never;\n }\n }\n};\n\nconst applyFiltering = <TEntity extends Entity>(\n items: TEntity[],\n filtering?: EntityFiltering<TEntity>[],\n): TEntity[] => {\n if (!filtering || filtering.length === 0) return items;\n // Every top-level entry is combined with `and`, matching `translateFiltering`.\n const nodes = filtering.flatMap(entry =>\n Array.isArray(entry) ? entry : [entry],\n );\n return items.filter(item => nodes.every(node => matchesNode(item, node)));\n};\n\nconst applySorting = <TEntity extends Entity>(\n items: TEntity[],\n sorting?: EntitySorting<TEntity>[],\n): TEntity[] => {\n if (!sorting || sorting.length === 0) return items;\n\n const entries = sorting.flatMap(record =>\n Object.keys(record)\n .map(Number)\n .sort((left, right) => left - right)\n .map(priority => record[priority])\n .filter(entry => entry != null),\n );\n\n return [...items].sort((left, right) => {\n for (const entry of entries) {\n const property = String(entry.property);\n const result = compareValues(\n (left as Record<string, unknown>)[property],\n (right as Record<string, unknown>)[property],\n );\n if (result !== 0) return entry.type === 'desc' ? -result : result;\n }\n return 0;\n });\n};\n\nexport interface InMemoryEntityRepository extends EntityRepository {\n /** Everything currently stored, in insertion order. */\n readonly items: Entity[];\n /** Replaces the contents, discarding whatever was there. */\n seed(items: Entity[]): void;\n /**\n * Makes the next call to any method fail with `error`, so error branches are\n * reachable without reaching for a mocking library.\n */\n failNext(error: EntifixError): void;\n}\n\n/**\n * In-memory {@link EntityRepository}: the default double for anything that\n * depends on persistence.\n *\n * Its semantics deliberately mirror `makeMongoRepository` — same filtering and\n * sorting rules, same 1-based paging with a default page size of 10, same\n * \"entity not found\" failure, same id minting on create — and\n * `describeEntityRepositoryContract` holds both to that shared contract.\n */\nexport const makeInMemoryEntityRepository = (\n seed: Entity[] = [],\n): InMemoryEntityRepository => {\n let items = [...seed];\n let pendingFailure: EntifixError | undefined;\n\n /** Consumes a queued failure, if one was armed. */\n const guard = <TValue>(\n run: () => Effect.Effect<TValue, EntifixError>,\n ): Effect.Effect<TValue, EntifixError> => {\n if (pendingFailure !== undefined) {\n const failure = pendingFailure;\n pendingFailure = undefined;\n return Effect.fail(failure);\n }\n return run();\n };\n\n const load = <TEntity extends Entity>(request: EntityLoadRequest<TEntity>) =>\n guard(() => {\n const matched = applySorting(\n applyFiltering(items as TEntity[], request.filtering),\n request.sorting,\n );\n const page = request.page ?? 1;\n const pageSize = request.pageSize ?? 10;\n const start = (page - 1) * pageSize;\n\n return Effect.succeed({\n items: matched.slice(start, start + pageSize),\n total: matched.length,\n request,\n } satisfies EntityPage<TEntity>);\n });\n\n const get = <TEntity extends Entity>(id: EntityId) =>\n guard(() => {\n const found = items.find(item => item.id === id);\n return found === undefined\n ? Effect.fail(\n new EntifixConnError('Entity not found', undefined, { id }),\n )\n : Effect.succeed(found as TEntity);\n });\n\n const save = <TEntity extends Entity>(entity: TEntity) =>\n guard(() => {\n // A create arrives without an id, so the store mints one — same rule the\n // Mongo adapter follows.\n entity.id = entity.id ?? randomUUID();\n const index = items.findIndex(item => item.id === entity.id);\n if (index === -1) {\n items.push(entity);\n } else {\n items[index] = entity;\n }\n return Effect.succeed(entity);\n });\n\n const remove = <TEntity extends Entity>(entityOrId: EntityId | TEntity) =>\n guard(() => {\n const id =\n entityOrId != null && typeof entityOrId === 'object'\n ? entityOrId.id\n : entityOrId;\n items = items.filter(item => item.id !== id);\n return Effect.succeed(undefined);\n });\n\n return {\n load,\n get,\n save,\n delete: remove,\n get items() {\n return items;\n },\n seed: (next: Entity[]) => {\n items = [...next];\n },\n failNext: (error: EntifixError) => {\n pendingFailure = error;\n },\n // Each method closes over its own state, so nothing is required from the\n // Effect environment; that is assignable to the interface's channel.\n } as unknown as InMemoryEntityRepository;\n};\n"],"names":["randomUUID","EntifixConnError","Effect","escapeRegex","value","replace","isFilterGroup","node","operator","compareValues","left","right","Date","getTime","String","localeCompare","matchesNode","item","values","some","child","every","actual","property","includes","start","end","RegExp","test","never","applyFiltering","items","filtering","length","nodes","flatMap","entry","Array","isArray","filter","applySorting","sorting","entries","record","Object","keys","map","Number","sort","priority","result","type","makeInMemoryEntityRepository","seed","pendingFailure","guard","run","undefined","failure","fail","load","request","matched","page","pageSize","succeed","slice","total","get","id","found","find","save","entity","index","findIndex","push","remove","entityOrId","delete","next","failNext","error"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/doubles/entity-repository.ts"],"sourcesContent":["import type { EntityRepository } from '@entifix/business';\nimport {\n deserializeSingleEntity,\n EntifixConnError,\n type EntifixError,\n type Entity,\n EntityCollectionLink,\n type EntityConstructor,\n type EntityFilter,\n type EntityFiltering,\n type EntityId,\n EntityLink,\n type EntityLoadRequest,\n type EntityPage,\n type EntitySorting,\n extractMetaAccessors,\n type FilterGroup,\n serializeEntity,\n} from '@entifix/core';\nimport { Effect } from 'effect';\n\n/** Escapes a string so `like`/`nlike` match literally, as the Mongo adapter does. */\nconst escapeRegex = (value: string): string =>\n value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n\nconst isFilterGroup = <TEntity extends Entity>(\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n): node is FilterGroup<TEntity> =>\n !('property' in node) && (node.operator === 'and' || node.operator === 'or');\n\n/**\n * A member as the Mongo adapter stores it, and so as its queries see it: a\n * link is its target's id, a collection link the list of their ids. Without\n * this no operator can compare a link, since `eq` against an id is never\n * `true` for an object.\n */\nconst storedValue = (value: unknown): unknown => {\n if (value instanceof EntityLink) return value.id;\n if (value instanceof EntityCollectionLink) return value.ids;\n return value;\n};\n\n/** `$in` semantics: a scalar matches a value, an array when any element does. */\nconst matchesAny = (actual: unknown, values: readonly unknown[]): boolean =>\n Array.isArray(actual)\n ? actual.some(element => values.includes(element))\n : values.includes(actual);\n\nconst compareValues = (left: unknown, right: unknown): number => {\n if (left === right) return 0;\n // Mongo sorts missing values below present ones.\n if (left == null) return -1;\n if (right == null) return 1;\n if (typeof left === 'number' && typeof right === 'number') {\n return left - right;\n }\n if (left instanceof Date && right instanceof Date) {\n return left.getTime() - right.getTime();\n }\n return String(left).localeCompare(String(right));\n};\n\nconst matchesNode = <TEntity extends Entity>(\n item: TEntity,\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n): boolean => {\n if (isFilterGroup(node)) {\n return node.operator === 'or'\n ? node.values.some(child => matchesNode(item, child))\n : node.values.every(child => matchesNode(item, child));\n }\n\n const actual = storedValue(\n (item as Record<string, unknown>)[String(node.property)],\n );\n\n switch (node.operator) {\n case 'eq':\n return actual === node.value;\n case 'ne':\n return actual !== node.value;\n case 'gt':\n return compareValues(actual, node.value) > 0;\n case 'gte':\n return compareValues(actual, node.value) >= 0;\n case 'lt':\n return compareValues(actual, node.value) < 0;\n case 'lte':\n return compareValues(actual, node.value) <= 0;\n case 'in':\n return matchesAny(actual, node.values);\n case 'nin':\n return !matchesAny(actual, node.values);\n case 'between':\n return (\n compareValues(actual, node.start) >= 0 &&\n compareValues(actual, node.end) <= 0\n );\n case 'nbetween':\n return !(\n compareValues(actual, node.start) >= 0 &&\n compareValues(actual, node.end) <= 0\n );\n case 'like':\n return new RegExp(escapeRegex(node.value), 'i').test(String(actual));\n case 'nlike':\n return !new RegExp(escapeRegex(node.value), 'i').test(String(actual));\n case 'isNull':\n return actual == null;\n case 'isNotNull':\n return actual != null;\n default: {\n // Exhaustiveness guard — a new operator must be handled explicitly.\n const never: never = node;\n return never;\n }\n }\n};\n\nconst applyFiltering = <TEntity extends Entity>(\n items: TEntity[],\n filtering?: EntityFiltering<TEntity>[],\n): TEntity[] => {\n if (!filtering || filtering.length === 0) return items;\n // Every top-level entry is combined with `and`, matching `translateFiltering`.\n const nodes = filtering.flatMap(entry =>\n Array.isArray(entry) ? entry : [entry],\n );\n return items.filter(item => nodes.every(node => matchesNode(item, node)));\n};\n\nconst applySorting = <TEntity extends Entity>(\n items: TEntity[],\n sorting?: EntitySorting<TEntity>[],\n): TEntity[] => {\n if (!sorting || sorting.length === 0) return items;\n\n const entries = sorting.flatMap(record =>\n Object.keys(record)\n .map(Number)\n .sort((left, right) => left - right)\n .map(priority => record[priority])\n .filter(entry => entry != null),\n );\n\n return [...items].sort((left, right) => {\n for (const entry of entries) {\n const property = String(entry.property);\n const result = compareValues(\n (left as Record<string, unknown>)[property],\n (right as Record<string, unknown>)[property],\n );\n if (result !== 0) return entry.type === 'desc' ? -result : result;\n }\n return 0;\n });\n};\n\n/**\n * A copy of `entity` that shares no state with it.\n *\n * Through the entity's own mapping — serialize, then deserialize — because that\n * is what a real adapter does to every record on the way in and out: a caller\n * can never hold the instance the store keeps, and a member the mapping drops\n * (`hidden`, `readonly`) does not survive a save here either. A class that\n * declares no accessors has no mapping to go through, so it is copied member by\n * member instead; plain spec fixtures are the case this keeps working.\n */\nconst copyOf = <TEntity extends Entity>(\n entity: TEntity,\n): Effect.Effect<TEntity, EntifixError> => {\n const entityConstructor = entity.constructor as EntityConstructor<TEntity>;\n if (extractMetaAccessors(entityConstructor).length === 0) {\n return Effect.succeed(\n Object.assign(\n Object.create(Object.getPrototypeOf(entity) as object) as TEntity,\n entity,\n ),\n );\n }\n // `undefined` only comes back for `null`/`undefined` input, and\n // `serializeEntity` always hands over an object.\n return deserializeSingleEntity(\n entityConstructor,\n serializeEntity(entityConstructor, entity),\n ) as Effect.Effect<TEntity, EntifixError>;\n};\n\nexport interface InMemoryEntityRepository extends EntityRepository {\n /** Everything currently stored, in insertion order. */\n readonly items: Entity[];\n /** Replaces the contents, discarding whatever was there. */\n seed(items: Entity[]): void;\n /**\n * Makes the next call to any method fail with `error`, so error branches are\n * reachable without reaching for a mocking library.\n */\n failNext(error: EntifixError): void;\n}\n\n/**\n * In-memory {@link EntityRepository}: the default double for anything that\n * depends on persistence.\n *\n * Its semantics deliberately mirror `makeMongoRepository` — same filtering and\n * sorting rules, same 1-based paging with a default page size of 10, same\n * \"entity not found\" failure, same id minting on create — and\n * `describeEntityRepositoryContract` holds both to that shared contract.\n */\nexport const makeInMemoryEntityRepository = (\n seed: Entity[] = [],\n): InMemoryEntityRepository => {\n let items = [...seed];\n let pendingFailure: EntifixError | undefined;\n\n /** Consumes a queued failure, if one was armed. */\n const guard = <TValue>(\n run: () => Effect.Effect<TValue, EntifixError>,\n ): Effect.Effect<TValue, EntifixError> => {\n if (pendingFailure !== undefined) {\n const failure = pendingFailure;\n pendingFailure = undefined;\n return Effect.fail(failure);\n }\n return run();\n };\n\n const load = <TEntity extends Entity>(request: EntityLoadRequest<TEntity>) =>\n guard(() => {\n const matched = applySorting(\n applyFiltering(items as TEntity[], request.filtering),\n request.sorting,\n );\n const page = request.page ?? 1;\n const pageSize = request.pageSize ?? 10;\n const start = (page - 1) * pageSize;\n\n return Effect.forEach(\n matched.slice(start, start + pageSize),\n copyOf,\n ).pipe(\n Effect.map(\n copies =>\n ({\n items: copies,\n total: matched.length,\n request,\n }) satisfies EntityPage<TEntity>,\n ),\n );\n });\n\n const get = <TEntity extends Entity>(id: EntityId) =>\n guard(() => {\n const found = items.find(item => item.id === id);\n return found === undefined\n ? Effect.fail(\n new EntifixConnError('Entity not found', undefined, { id }),\n )\n : copyOf(found as TEntity);\n });\n\n const save = <TEntity extends Entity>(entity: TEntity) =>\n guard(() => {\n // A create arrives without an id, so the store mints one — same rule the\n // Mongo adapter follows. The Web Crypto global rather than `node:crypto`,\n // because a browser bundle cannot resolve a `node:` import and an example\n // with no backend runs this repository in the page.\n entity.id = entity.id ?? globalThis.crypto.randomUUID();\n // The store keeps its own copy and hands back another, so neither the\n // caller's instance nor the returned one can change the record without a\n // second save — which a real adapter guarantees by construction.\n return copyOf(entity).pipe(\n Effect.tap(stored => {\n const index = items.findIndex(item => item.id === stored.id);\n if (index === -1) {\n items.push(stored);\n } else {\n items[index] = stored;\n }\n }),\n Effect.flatMap(copyOf),\n );\n });\n\n const remove = <TEntity extends Entity>(entityOrId: EntityId | TEntity) =>\n guard(() => {\n const id =\n entityOrId != null && typeof entityOrId === 'object'\n ? entityOrId.id\n : entityOrId;\n items = items.filter(item => item.id !== id);\n return Effect.succeed(undefined);\n });\n\n return {\n load,\n get,\n save,\n delete: remove,\n get items() {\n return items;\n },\n seed: (next: Entity[]) => {\n items = [...next];\n },\n failNext: (error: EntifixError) => {\n pendingFailure = error;\n },\n // Each method closes over its own state, so nothing is required from the\n // Effect environment; that is assignable to the interface's channel.\n } as unknown as InMemoryEntityRepository;\n};\n"],"names":["deserializeSingleEntity","EntifixConnError","EntityCollectionLink","EntityLink","extractMetaAccessors","serializeEntity","Effect","escapeRegex","value","replace","isFilterGroup","node","operator","storedValue","id","ids","matchesAny","actual","values","Array","isArray","some","element","includes","compareValues","left","right","Date","getTime","String","localeCompare","matchesNode","item","child","every","property","start","end","RegExp","test","never","applyFiltering","items","filtering","length","nodes","flatMap","entry","filter","applySorting","sorting","entries","record","Object","keys","map","Number","sort","priority","result","type","copyOf","entity","entityConstructor","succeed","assign","create","getPrototypeOf","makeInMemoryEntityRepository","seed","pendingFailure","guard","run","undefined","failure","fail","load","request","matched","page","pageSize","forEach","slice","pipe","copies","total","get","found","find","save","globalThis","crypto","randomUUID","tap","stored","index","findIndex","push","remove","entityOrId","delete","next","failNext","error"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type DomainEvent, type EntifixError } from '@entifix/core';
|
|
2
|
-
import type { EventBus, LockService, SequenceService, TransactionInbox } from '@entifix/transactions';
|
|
2
|
+
import type { EventBus, LockService, SagaInstance, SagaStore, SequenceService, TransactionInbox } from '@entifix/transactions';
|
|
3
3
|
import { Effect } from 'effect';
|
|
4
4
|
export interface InMemoryLockService extends LockService {
|
|
5
5
|
/** Keys currently held, in acquisition order. */
|
|
@@ -67,4 +67,15 @@ export declare const makeInMemoryInboxes: () => {
|
|
|
67
67
|
eventId: string;
|
|
68
68
|
}[];
|
|
69
69
|
};
|
|
70
|
+
export interface InMemorySagaStore extends SagaStore {
|
|
71
|
+
/** Every instance, as stored — for a spec to assert on state, not calls. */
|
|
72
|
+
readonly instances: readonly SagaInstance[];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* In-memory {@link SagaStore}, with the Mongo adapter's semantics: `start`
|
|
76
|
+
* never overwrites, outcomes append, and a resume claim only takes an
|
|
77
|
+
* unfinished instance whose `updatedAt` is past the deadline — counting the
|
|
78
|
+
* attempt and re-stamping it, so the next sweep does not find it again.
|
|
79
|
+
*/
|
|
80
|
+
export declare const makeInMemorySagaStore: () => InMemorySagaStore;
|
|
70
81
|
//# sourceMappingURL=transaction-ports.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transaction-ports.d.ts","sourceRoot":"","sources":["../../src/doubles/transaction-ports.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,WAAW,EAEhB,KAAK,YAAY,EAGlB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EACV,QAAQ,EAER,WAAW,EACX,eAAe,EACf,gBAAgB,EACjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,WAAW,mBAAoB,SAAQ,WAAW;IACtD,iDAAiD;IACjD,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC;IACxB,sDAAsD;IACtD,QAAQ,CAAC,GAAG,EAAE,aAAa,CAAC;QAAE,MAAM,EAAE,SAAS,GAAG,SAAS,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC5E;;;OAGG;IACH,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,QAAO,mBAsC1C,CAAC;AAEF,MAAM,WAAW,uBAAwB,SAAQ,eAAe;IAC9D,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACnD;AAED;;;GAGG;AACH,eAAO,MAAM,2BAA2B,QAAO,uBAc9C,CAAC;AAEF,MAAM,WAAW,iBAAkB,SAAQ,QAAQ;IACjD,uCAAuC;IACvC,QAAQ,CAAC,SAAS,EAAE,WAAW,EAAE,CAAC;IAClC;;;;;OAKG;IACH,OAAO,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;IAC/D,wEAAwE;IACxE,eAAe,IAAI,IAAI,CAAC;CACzB;AAED;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,QAAO,iBA4CxC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB;IAI5B,0DAA0D;oBAC1C,MAAM,KAAG,gBAAgB;qBAS3B,SAAS;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE;CAOjE,CAAC"}
|
|
1
|
+
{"version":3,"file":"transaction-ports.d.ts","sourceRoot":"","sources":["../../src/doubles/transaction-ports.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,WAAW,EAEhB,KAAK,YAAY,EAGlB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EACV,QAAQ,EAER,WAAW,EACX,YAAY,EACZ,SAAS,EACT,eAAe,EACf,gBAAgB,EACjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,WAAW,mBAAoB,SAAQ,WAAW;IACtD,iDAAiD;IACjD,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC;IACxB,sDAAsD;IACtD,QAAQ,CAAC,GAAG,EAAE,aAAa,CAAC;QAAE,MAAM,EAAE,SAAS,GAAG,SAAS,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC5E;;;OAGG;IACH,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,QAAO,mBAsC1C,CAAC;AAEF,MAAM,WAAW,uBAAwB,SAAQ,eAAe;IAC9D,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACnD;AAED;;;GAGG;AACH,eAAO,MAAM,2BAA2B,QAAO,uBAc9C,CAAC;AAEF,MAAM,WAAW,iBAAkB,SAAQ,QAAQ;IACjD,uCAAuC;IACvC,QAAQ,CAAC,SAAS,EAAE,WAAW,EAAE,CAAC;IAClC;;;;;OAKG;IACH,OAAO,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;IAC/D,wEAAwE;IACxE,eAAe,IAAI,IAAI,CAAC;CACzB;AAED;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,QAAO,iBA4CxC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB;IAI5B,0DAA0D;oBAC1C,MAAM,KAAG,gBAAgB;qBAS3B,SAAS;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE;CAOjE,CAAC;AAEF,MAAM,WAAW,iBAAkB,SAAQ,SAAS;IAClD,4EAA4E;IAC5E,QAAQ,CAAC,SAAS,EAAE,SAAS,YAAY,EAAE,CAAC;CAC7C;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,QAAO,iBAwExC,CAAC"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
import { EntifixConnError, EntifixLockError, matchesEventPattern } from '@entifix/core';
|
|
2
3
|
import { Effect } from 'effect';
|
|
3
4
|
/**
|
|
@@ -141,5 +142,72 @@ import { Effect } from 'effect';
|
|
|
141
142
|
}
|
|
142
143
|
};
|
|
143
144
|
};
|
|
145
|
+
/**
|
|
146
|
+
* In-memory {@link SagaStore}, with the Mongo adapter's semantics: `start`
|
|
147
|
+
* never overwrites, outcomes append, and a resume claim only takes an
|
|
148
|
+
* unfinished instance whose `updatedAt` is past the deadline — counting the
|
|
149
|
+
* attempt and re-stamping it, so the next sweep does not find it again.
|
|
150
|
+
*/ export const makeInMemorySagaStore = ()=>{
|
|
151
|
+
const instances = new Map();
|
|
152
|
+
const stamp = ()=>new Date().toISOString();
|
|
153
|
+
const unfinished = (entry)=>entry.state === 'RUNNING' || entry.state === 'COMPENSATING';
|
|
154
|
+
const pastDeadline = (entry, olderThanMs)=>entry.updatedAt < new Date(Date.now() - olderThanMs).toISOString();
|
|
155
|
+
const update = (sagaId, change)=>Effect.sync(()=>{
|
|
156
|
+
const current = instances.get(sagaId);
|
|
157
|
+
if (current !== undefined) {
|
|
158
|
+
instances.set(sagaId, _extends({}, change(current), {
|
|
159
|
+
updatedAt: stamp()
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
return {
|
|
164
|
+
start: (started)=>Effect.sync(()=>{
|
|
165
|
+
if (!instances.has(started.sagaId)) {
|
|
166
|
+
instances.set(started.sagaId, _extends({}, started, {
|
|
167
|
+
updatedAt: stamp()
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
}),
|
|
171
|
+
beginStep: (sagaId, stepIndex)=>update(sagaId, (entry)=>_extends({}, entry, {
|
|
172
|
+
stepIndex
|
|
173
|
+
})),
|
|
174
|
+
recordOutcome: (sagaId, outcome)=>update(sagaId, (entry)=>_extends({}, entry, {
|
|
175
|
+
outcomes: [
|
|
176
|
+
...entry.outcomes,
|
|
177
|
+
outcome
|
|
178
|
+
]
|
|
179
|
+
})),
|
|
180
|
+
settle: (sagaId, state, error)=>update(sagaId, (entry)=>_extends({}, entry, {
|
|
181
|
+
state,
|
|
182
|
+
error
|
|
183
|
+
})),
|
|
184
|
+
markCompensated: (sagaId, stepId)=>update(sagaId, (entry)=>_extends({}, entry, {
|
|
185
|
+
outcomes: entry.outcomes.map((outcome)=>outcome.stepId === stepId ? _extends({}, outcome, {
|
|
186
|
+
compensated: true
|
|
187
|
+
}) : outcome)
|
|
188
|
+
})),
|
|
189
|
+
get: (sagaId)=>Effect.sync(()=>instances.get(sagaId)),
|
|
190
|
+
findStale: (olderThanMs)=>Effect.sync(()=>[
|
|
191
|
+
...instances.values()
|
|
192
|
+
].filter((entry)=>unfinished(entry) && pastDeadline(entry, olderThanMs))),
|
|
193
|
+
claimForResume: (sagaId, olderThanMs)=>Effect.sync(()=>{
|
|
194
|
+
const current = instances.get(sagaId);
|
|
195
|
+
if (current === undefined || !unfinished(current) || !pastDeadline(current, olderThanMs)) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
const claimed = _extends({}, current, {
|
|
199
|
+
resumeAttempts: current.resumeAttempts + 1,
|
|
200
|
+
updatedAt: stamp()
|
|
201
|
+
});
|
|
202
|
+
instances.set(sagaId, claimed);
|
|
203
|
+
return claimed;
|
|
204
|
+
}),
|
|
205
|
+
get instances () {
|
|
206
|
+
return [
|
|
207
|
+
...instances.values()
|
|
208
|
+
];
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
};
|
|
144
212
|
|
|
145
213
|
//# sourceMappingURL=transaction-ports.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/doubles/transaction-ports.ts"],"sourcesContent":["import {\n type DomainEvent,\n EntifixConnError,\n type EntifixError,\n EntifixLockError,\n matchesEventPattern,\n} from '@entifix/core';\nimport type {\n EventBus,\n LockHandle,\n LockService,\n SequenceService,\n TransactionInbox,\n} from '@entifix/transactions';\nimport { Effect } from 'effect';\n\nexport interface InMemoryLockService extends LockService {\n /** Keys currently held, in acquisition order. */\n readonly held: string[];\n /** Every acquire/release in the order it happened. */\n readonly log: ReadonlyArray<{ action: 'acquire' | 'release'; key: string }>;\n /**\n * Marks `key` as already taken by someone else, so the next `acquire` fails\n * with {@link EntifixLockError} — the 409 path of the transaction facade.\n */\n contendOn(key: string): void;\n}\n\n/**\n * In-memory {@link LockService}. Acquisition is immediate and uncontended\n * unless {@link InMemoryLockService.contendOn} says otherwise, and `release`\n * only frees a lock whose token matches — the compare-and-delete rule the Redis\n * adapter enforces with a Lua script.\n */\nexport const makeInMemoryLockService = (): InMemoryLockService => {\n const tokens = new Map<string, string>();\n const contended = new Set<string>();\n const log: Array<{ action: 'acquire' | 'release'; key: string }> = [];\n let counter = 0;\n\n return {\n acquire: key =>\n Effect.suspend(() => {\n if (contended.has(key) || tokens.has(key)) {\n return Effect.fail(\n new EntifixLockError(`Could not acquire lock \"${key}\"`, undefined, {\n key,\n }),\n );\n }\n const token = `token-${(counter += 1)}`;\n tokens.set(key, token);\n log.push({ action: 'acquire', key });\n return Effect.succeed({ key, token });\n }),\n release: (handle: LockHandle) =>\n Effect.sync(() => {\n if (tokens.get(handle.key) === handle.token) {\n tokens.delete(handle.key);\n log.push({ action: 'release', key: handle.key });\n }\n }),\n get held() {\n return [...tokens.keys()];\n },\n get log() {\n return log;\n },\n contendOn: key => {\n contended.add(key);\n },\n };\n};\n\nexport interface InMemorySequenceService extends SequenceService {\n /** The current value of every sequence drawn from so far. */\n readonly values: Readonly<Record<string, number>>;\n}\n\n/**\n * In-memory {@link SequenceService} — a plain counter per name. Atomicity is\n * what the Redis `INCR` adapter buys; single-threaded JS gives it for free here.\n */\nexport const makeInMemorySequenceService = (): InMemorySequenceService => {\n const sequences = new Map<string, number>();\n\n return {\n next: name =>\n Effect.sync(() => {\n const next = (sequences.get(name) ?? 0) + 1;\n sequences.set(name, next);\n return next;\n }),\n get values() {\n return Object.fromEntries(sequences);\n },\n };\n};\n\nexport interface RecordingEventBus extends EventBus {\n /** Every event published, in order. */\n readonly published: DomainEvent[];\n /**\n * Pushes an event to the subscribers whose pattern matches it, as a topic\n * exchange would. Deliberately not \"to all subscribers\": routing is the\n * broker's job in production, so a double that ignored it would let a\n * consumer pass its tests while receiving traffic it never bound to.\n */\n deliver(event: DomainEvent): Effect.Effect<void, EntifixError>;\n /** Makes the next `publish` fail, for the broker-unavailable branch. */\n failNextPublish(): void;\n}\n\n/**\n * In-memory {@link EventBus} that records what was published rather than\n * asserting on it. Prefer reading `bus.published` in a test over a mock's call\n * assertions: the expectation then reads as state, not as a spy protocol.\n */\nexport const makeRecordingEventBus = (): RecordingEventBus => {\n const published: DomainEvent[] = [];\n const handlers: Array<{\n pattern: string;\n handle: (event: DomainEvent) => Effect.Effect<void, EntifixError>;\n }> = [];\n let failNext = false;\n\n return {\n publish: event =>\n Effect.suspend(() => {\n if (failNext) {\n failNext = false;\n return Effect.fail(\n new EntifixConnError('Event bus unavailable', undefined, {\n eventId: event.id,\n }),\n );\n }\n published.push(event);\n return Effect.void;\n }),\n subscribe: (subscription, handle) =>\n Effect.sync(() => {\n // Only the pattern is retained: queue durability and the delivery\n // ceiling are broker facts with no in-memory analogue, and faking them\n // would assert against the double rather than against a broker.\n handlers.push({ pattern: subscription.pattern, handle });\n }),\n deliver: event =>\n Effect.forEach(\n handlers.filter(({ pattern }) =>\n matchesEventPattern(pattern, event.name),\n ),\n ({ handle }) => handle(event),\n { discard: true },\n ),\n get published() {\n return published;\n },\n failNextPublish: () => {\n failNext = true;\n },\n };\n};\n\n/**\n * An in-memory {@link TransactionInbox}, one per consumer, over a shared claim\n * set.\n *\n * Sharing the set across the inboxes one factory hands out is what makes the\n * contract's \"one consumer cannot consume another's claim\" case a real\n * assertion, rather than one the double satisfies by only ever having seen a\n * single consumer.\n */\nexport const makeInMemoryInboxes = () => {\n const claimed = new Set<string>();\n\n return {\n /** An inbox for `consumer`, over the shared claim set. */\n for: (consumer: string): TransactionInbox => ({\n claim: eventId =>\n Effect.sync(() => {\n const key = JSON.stringify([consumer, eventId]);\n if (claimed.has(key)) return 'duplicate' as const;\n claimed.add(key);\n return 'claimed' as const;\n }),\n }),\n get claims(): readonly { consumer: string; eventId: string }[] {\n return [...claimed].map(key => {\n const [consumer, eventId] = JSON.parse(key) as [string, string];\n return { consumer, eventId };\n });\n },\n };\n};\n"],"names":["EntifixConnError","EntifixLockError","matchesEventPattern","Effect","makeInMemoryLockService","tokens","Map","contended","Set","log","counter","acquire","key","suspend","has","fail","undefined","token","set","push","action","succeed","release","handle","sync","get","delete","held","keys","contendOn","add","makeInMemorySequenceService","sequences","next","name","values","Object","fromEntries","makeRecordingEventBus","published","handlers","failNext","publish","event","eventId","id","void","subscribe","subscription","pattern","deliver","forEach","filter","discard","failNextPublish","makeInMemoryInboxes","claimed","for","consumer","claim","JSON","stringify","claims","map","parse"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/doubles/transaction-ports.ts"],"sourcesContent":["import {\n type DomainEvent,\n EntifixConnError,\n type EntifixError,\n EntifixLockError,\n matchesEventPattern,\n} from '@entifix/core';\nimport type {\n EventBus,\n LockHandle,\n LockService,\n SagaInstance,\n SagaStore,\n SequenceService,\n TransactionInbox,\n} from '@entifix/transactions';\nimport { Effect } from 'effect';\n\nexport interface InMemoryLockService extends LockService {\n /** Keys currently held, in acquisition order. */\n readonly held: string[];\n /** Every acquire/release in the order it happened. */\n readonly log: ReadonlyArray<{ action: 'acquire' | 'release'; key: string }>;\n /**\n * Marks `key` as already taken by someone else, so the next `acquire` fails\n * with {@link EntifixLockError} — the 409 path of the transaction facade.\n */\n contendOn(key: string): void;\n}\n\n/**\n * In-memory {@link LockService}. Acquisition is immediate and uncontended\n * unless {@link InMemoryLockService.contendOn} says otherwise, and `release`\n * only frees a lock whose token matches — the compare-and-delete rule the Redis\n * adapter enforces with a Lua script.\n */\nexport const makeInMemoryLockService = (): InMemoryLockService => {\n const tokens = new Map<string, string>();\n const contended = new Set<string>();\n const log: Array<{ action: 'acquire' | 'release'; key: string }> = [];\n let counter = 0;\n\n return {\n acquire: key =>\n Effect.suspend(() => {\n if (contended.has(key) || tokens.has(key)) {\n return Effect.fail(\n new EntifixLockError(`Could not acquire lock \"${key}\"`, undefined, {\n key,\n }),\n );\n }\n const token = `token-${(counter += 1)}`;\n tokens.set(key, token);\n log.push({ action: 'acquire', key });\n return Effect.succeed({ key, token });\n }),\n release: (handle: LockHandle) =>\n Effect.sync(() => {\n if (tokens.get(handle.key) === handle.token) {\n tokens.delete(handle.key);\n log.push({ action: 'release', key: handle.key });\n }\n }),\n get held() {\n return [...tokens.keys()];\n },\n get log() {\n return log;\n },\n contendOn: key => {\n contended.add(key);\n },\n };\n};\n\nexport interface InMemorySequenceService extends SequenceService {\n /** The current value of every sequence drawn from so far. */\n readonly values: Readonly<Record<string, number>>;\n}\n\n/**\n * In-memory {@link SequenceService} — a plain counter per name. Atomicity is\n * what the Redis `INCR` adapter buys; single-threaded JS gives it for free here.\n */\nexport const makeInMemorySequenceService = (): InMemorySequenceService => {\n const sequences = new Map<string, number>();\n\n return {\n next: name =>\n Effect.sync(() => {\n const next = (sequences.get(name) ?? 0) + 1;\n sequences.set(name, next);\n return next;\n }),\n get values() {\n return Object.fromEntries(sequences);\n },\n };\n};\n\nexport interface RecordingEventBus extends EventBus {\n /** Every event published, in order. */\n readonly published: DomainEvent[];\n /**\n * Pushes an event to the subscribers whose pattern matches it, as a topic\n * exchange would. Deliberately not \"to all subscribers\": routing is the\n * broker's job in production, so a double that ignored it would let a\n * consumer pass its tests while receiving traffic it never bound to.\n */\n deliver(event: DomainEvent): Effect.Effect<void, EntifixError>;\n /** Makes the next `publish` fail, for the broker-unavailable branch. */\n failNextPublish(): void;\n}\n\n/**\n * In-memory {@link EventBus} that records what was published rather than\n * asserting on it. Prefer reading `bus.published` in a test over a mock's call\n * assertions: the expectation then reads as state, not as a spy protocol.\n */\nexport const makeRecordingEventBus = (): RecordingEventBus => {\n const published: DomainEvent[] = [];\n const handlers: Array<{\n pattern: string;\n handle: (event: DomainEvent) => Effect.Effect<void, EntifixError>;\n }> = [];\n let failNext = false;\n\n return {\n publish: event =>\n Effect.suspend(() => {\n if (failNext) {\n failNext = false;\n return Effect.fail(\n new EntifixConnError('Event bus unavailable', undefined, {\n eventId: event.id,\n }),\n );\n }\n published.push(event);\n return Effect.void;\n }),\n subscribe: (subscription, handle) =>\n Effect.sync(() => {\n // Only the pattern is retained: queue durability and the delivery\n // ceiling are broker facts with no in-memory analogue, and faking them\n // would assert against the double rather than against a broker.\n handlers.push({ pattern: subscription.pattern, handle });\n }),\n deliver: event =>\n Effect.forEach(\n handlers.filter(({ pattern }) =>\n matchesEventPattern(pattern, event.name),\n ),\n ({ handle }) => handle(event),\n { discard: true },\n ),\n get published() {\n return published;\n },\n failNextPublish: () => {\n failNext = true;\n },\n };\n};\n\n/**\n * An in-memory {@link TransactionInbox}, one per consumer, over a shared claim\n * set.\n *\n * Sharing the set across the inboxes one factory hands out is what makes the\n * contract's \"one consumer cannot consume another's claim\" case a real\n * assertion, rather than one the double satisfies by only ever having seen a\n * single consumer.\n */\nexport const makeInMemoryInboxes = () => {\n const claimed = new Set<string>();\n\n return {\n /** An inbox for `consumer`, over the shared claim set. */\n for: (consumer: string): TransactionInbox => ({\n claim: eventId =>\n Effect.sync(() => {\n const key = JSON.stringify([consumer, eventId]);\n if (claimed.has(key)) return 'duplicate' as const;\n claimed.add(key);\n return 'claimed' as const;\n }),\n }),\n get claims(): readonly { consumer: string; eventId: string }[] {\n return [...claimed].map(key => {\n const [consumer, eventId] = JSON.parse(key) as [string, string];\n return { consumer, eventId };\n });\n },\n };\n};\n\nexport interface InMemorySagaStore extends SagaStore {\n /** Every instance, as stored — for a spec to assert on state, not calls. */\n readonly instances: readonly SagaInstance[];\n}\n\n/**\n * In-memory {@link SagaStore}, with the Mongo adapter's semantics: `start`\n * never overwrites, outcomes append, and a resume claim only takes an\n * unfinished instance whose `updatedAt` is past the deadline — counting the\n * attempt and re-stamping it, so the next sweep does not find it again.\n */\nexport const makeInMemorySagaStore = (): InMemorySagaStore => {\n const instances = new Map<string, SagaInstance>();\n const stamp = () => new Date().toISOString();\n const unfinished = (entry: SagaInstance) =>\n entry.state === 'RUNNING' || entry.state === 'COMPENSATING';\n const pastDeadline = (entry: SagaInstance, olderThanMs: number) =>\n entry.updatedAt < new Date(Date.now() - olderThanMs).toISOString();\n const update = (\n sagaId: string,\n change: (entry: SagaInstance) => SagaInstance,\n ) =>\n Effect.sync(() => {\n const current = instances.get(sagaId);\n if (current !== undefined) {\n instances.set(sagaId, { ...change(current), updatedAt: stamp() });\n }\n });\n\n return {\n start: started =>\n Effect.sync(() => {\n if (!instances.has(started.sagaId)) {\n instances.set(started.sagaId, { ...started, updatedAt: stamp() });\n }\n }),\n beginStep: (sagaId, stepIndex) =>\n update(sagaId, entry => ({ ...entry, stepIndex })),\n recordOutcome: (sagaId, outcome) =>\n update(sagaId, entry => ({\n ...entry,\n outcomes: [...entry.outcomes, outcome],\n })),\n settle: (sagaId, state, error) =>\n update(sagaId, entry => ({ ...entry, state, error })),\n markCompensated: (sagaId, stepId) =>\n update(sagaId, entry => ({\n ...entry,\n outcomes: entry.outcomes.map(outcome =>\n outcome.stepId === stepId\n ? { ...outcome, compensated: true }\n : outcome,\n ),\n })),\n get: sagaId => Effect.sync(() => instances.get(sagaId)),\n findStale: olderThanMs =>\n Effect.sync(() =>\n [...instances.values()].filter(\n entry => unfinished(entry) && pastDeadline(entry, olderThanMs),\n ),\n ),\n claimForResume: (sagaId, olderThanMs) =>\n Effect.sync(() => {\n const current = instances.get(sagaId);\n if (\n current === undefined ||\n !unfinished(current) ||\n !pastDeadline(current, olderThanMs)\n ) {\n return undefined;\n }\n const claimed = {\n ...current,\n resumeAttempts: current.resumeAttempts + 1,\n updatedAt: stamp(),\n };\n instances.set(sagaId, claimed);\n return claimed;\n }),\n get instances() {\n return [...instances.values()];\n },\n };\n};\n"],"names":["EntifixConnError","EntifixLockError","matchesEventPattern","Effect","makeInMemoryLockService","tokens","Map","contended","Set","log","counter","acquire","key","suspend","has","fail","undefined","token","set","push","action","succeed","release","handle","sync","get","delete","held","keys","contendOn","add","makeInMemorySequenceService","sequences","next","name","values","Object","fromEntries","makeRecordingEventBus","published","handlers","failNext","publish","event","eventId","id","void","subscribe","subscription","pattern","deliver","forEach","filter","discard","failNextPublish","makeInMemoryInboxes","claimed","for","consumer","claim","JSON","stringify","claims","map","parse","makeInMemorySagaStore","instances","stamp","Date","toISOString","unfinished","entry","state","pastDeadline","olderThanMs","updatedAt","now","update","sagaId","change","current","start","started","beginStep","stepIndex","recordOutcome","outcome","outcomes","settle","error","markCompensated","stepId","compensated","findStale","claimForResume","resumeAttempts"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fake-mongo.d.ts","sourceRoot":"","sources":["../../src/drivers/fake-mongo.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,KAAK,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAuMxC,MAAM,WAAW,mBAAmB;IAClC,uEAAuE;IACvE,QAAQ,CAAC,SAAS,EAAE,QAAQ,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,WAAW;IAC1B,wCAAwC;IACxC,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC;IACtD,6CAA6C;IAC7C,IAAI,CAAC,UAAU,EAAE,MAAM,GAAG,QAAQ,EAAE,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAC/B;;;OAGG;IACH,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAChD,4EAA4E;IAC5E,QAAQ,CAAC,UAAU,EAAE,aAAa,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvE;;;;OAIG;IACH,YAAY,IAAI;QACd,eAAe,CAAC,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;QAChE,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAC5B,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;KAC5B,CAAC;IACF,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;CACtB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,GAC1B,OAAM,MAAM,CAAC,MAAM,EAAE,QAAQ,EAAE,CAAM,KACpC,
|
|
1
|
+
{"version":3,"file":"fake-mongo.d.ts","sourceRoot":"","sources":["../../src/drivers/fake-mongo.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,KAAK,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAuMxC,MAAM,WAAW,mBAAmB;IAClC,uEAAuE;IACvE,QAAQ,CAAC,SAAS,EAAE,QAAQ,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,WAAW;IAC1B,wCAAwC;IACxC,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC;IACtD,6CAA6C;IAC7C,IAAI,CAAC,UAAU,EAAE,MAAM,GAAG,QAAQ,EAAE,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAC/B;;;OAGG;IACH,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAChD,4EAA4E;IAC5E,QAAQ,CAAC,UAAU,EAAE,aAAa,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvE;;;;OAIG;IACH,YAAY,IAAI;QACd,eAAe,CAAC,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;QAChE,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAC5B,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;KAC5B,CAAC;IACF,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;CACtB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,GAC1B,OAAM,MAAM,CAAC,MAAM,EAAE,QAAQ,EAAE,CAAM,KACpC,WAohBF,CAAC"}
|
|
@@ -358,13 +358,20 @@ const matches = (doc, query)=>Object.entries(query).every(([field, condition])=>
|
|
|
358
358
|
* implements is that an absolute-value write loses updates, so a fake that
|
|
359
359
|
* computed the new total from a value it had already read would model the
|
|
360
360
|
* bug rather than the fix.
|
|
361
|
+
*
|
|
362
|
+
* `$push` appends, and a `$set` key of the form `field.$[name].member`
|
|
363
|
+
* applies to every element of `field` that `options.arrayFilters` selects —
|
|
364
|
+
* the pair a saga store records an outcome with and later flags one
|
|
365
|
+
* compensated. Both are what keep an array update from being a
|
|
366
|
+
* read-modify-write, which is the point of using them.
|
|
361
367
|
*/ updateOne: (query, update, options)=>record(name, 'updateOne', ()=>{
|
|
362
368
|
var _update_$setOnInsert;
|
|
363
369
|
const supported = [
|
|
364
370
|
'$set',
|
|
365
371
|
'$addToSet',
|
|
366
372
|
'$inc',
|
|
367
|
-
'$setOnInsert'
|
|
373
|
+
'$setOnInsert',
|
|
374
|
+
'$push'
|
|
368
375
|
];
|
|
369
376
|
const unsupported = Object.keys(update).filter((operator)=>!supported.includes(operator));
|
|
370
377
|
if (unsupported.length > 0) {
|
|
@@ -381,9 +388,35 @@ const matches = (doc, query)=>Object.entries(query).every(([field, condition])=>
|
|
|
381
388
|
if (conflicting.length > 0) {
|
|
382
389
|
throw new Error(`fake-mongo: updateOne has conflicting paths in $setOnInsert and $inc/$set: ${conflicting.join(', ')}`);
|
|
383
390
|
}
|
|
384
|
-
/** `$set`, `$addToSet` and `$
|
|
385
|
-
var _update_$set, _update_$addToSet, _update_$inc;
|
|
386
|
-
const
|
|
391
|
+
/** `$set`, `$addToSet`, `$inc` and `$push`, which apply on both branches. */ const applyOperators = (base)=>{
|
|
392
|
+
var _update_$set, _update_$set1, _update_$push, _update_$addToSet, _update_$inc;
|
|
393
|
+
const plain = Object.entries((_update_$set = update.$set) != null ? _update_$set : {}).filter(([field])=>!field.includes('.$['));
|
|
394
|
+
const updated = _extends({}, base, Object.fromEntries(plain));
|
|
395
|
+
for (const [path, value] of Object.entries((_update_$set1 = update.$set) != null ? _update_$set1 : {})){
|
|
396
|
+
var _ref;
|
|
397
|
+
const filtered = /^([^.]+)\.\$\[([^\]]+)\]\.(.+)$/.exec(path);
|
|
398
|
+
if (filtered === null) continue;
|
|
399
|
+
const [, field, identifier, member] = filtered;
|
|
400
|
+
// The server refuses a filtered position with no filter naming it.
|
|
401
|
+
const conditions = ((_ref = options == null ? void 0 : options.arrayFilters) != null ? _ref : []).flatMap((filter)=>Object.entries(filter).filter(([key])=>key.startsWith(`${identifier}.`)).map(([key, condition])=>[
|
|
402
|
+
key.slice(identifier.length + 1),
|
|
403
|
+
condition
|
|
404
|
+
]));
|
|
405
|
+
if (conditions.length === 0) {
|
|
406
|
+
throw new Error(`fake-mongo: no array filter found for identifier ${identifier}`);
|
|
407
|
+
}
|
|
408
|
+
const current = updated[field];
|
|
409
|
+
updated[field] = (Array.isArray(current) ? current : []).map((element)=>isPlainObject(element) && conditions.every(([key, condition])=>matchesCondition(element[key], condition)) ? _extends({}, element, {
|
|
410
|
+
[member]: value
|
|
411
|
+
}) : element);
|
|
412
|
+
}
|
|
413
|
+
for (const [field, value] of Object.entries((_update_$push = update.$push) != null ? _update_$push : {})){
|
|
414
|
+
const current = updated[field];
|
|
415
|
+
updated[field] = [
|
|
416
|
+
...Array.isArray(current) ? current : [],
|
|
417
|
+
value
|
|
418
|
+
];
|
|
419
|
+
}
|
|
387
420
|
for (const [field, value] of Object.entries((_update_$addToSet = update.$addToSet) != null ? _update_$addToSet : {})){
|
|
388
421
|
const current = updated[field];
|
|
389
422
|
const array = Array.isArray(current) ? current : [];
|