@ultimat3/entity 28.0.0 → 29.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/memory-repo.ts +20 -16
- package/src/memory-update.ts +79 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "29.0.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,16 +26,16 @@
|
|
|
26
26
|
"LICENSE"
|
|
27
27
|
],
|
|
28
28
|
"engines": {
|
|
29
|
-
"bun": ">=1.4.
|
|
29
|
+
"bun": ">=1.4.4"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
33
33
|
"test": "bun test"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@ultimat3/core": "
|
|
37
|
-
"@ultimat3/db": "
|
|
38
|
-
"@ultimat3/schema": "
|
|
39
|
-
"@ultimat3/time": "
|
|
36
|
+
"@ultimat3/core": "29.0.0",
|
|
37
|
+
"@ultimat3/db": "29.0.0",
|
|
38
|
+
"@ultimat3/schema": "29.0.0",
|
|
39
|
+
"@ultimat3/time": "29.0.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/memory-repo.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { invariantViolated, notFound } from './errors';
|
|
|
21
21
|
import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
22
22
|
import { compareByKind, matchesPredicate } from './memory-match';
|
|
23
23
|
import { uniqueClash, uniqueViolation } from './memory-unique';
|
|
24
|
+
import { judgeUpdate, mergedRow } from './memory-update';
|
|
24
25
|
import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
|
|
25
26
|
import type { FindManyArgs, MemoryRepo, RepoOptions, Transactor, Tx } from './repo';
|
|
26
27
|
import { sealedFields } from './sealed';
|
|
@@ -74,14 +75,6 @@ const afterCursor = <Row>(
|
|
|
74
75
|
* migration and tests use it everywhere. Postgres is the production driver and implements
|
|
75
76
|
* this same interface.
|
|
76
77
|
*/
|
|
77
|
-
/**
|
|
78
|
-
* The patch with every `undefined` property dropped — `bindValues` skips them in Postgres, so a
|
|
79
|
-
* patch built from optional input (`{ body: input.body }`) leaves the column alone in both drivers
|
|
80
|
-
* rather than erasing it. `null` is the value that clears a column.
|
|
81
|
-
*/
|
|
82
|
-
const defined = (patch: object): object =>
|
|
83
|
-
Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined));
|
|
84
|
-
|
|
85
78
|
export const memoryRepo = <Row>(
|
|
86
79
|
entity: EntityCore<Row>,
|
|
87
80
|
seed: readonly Row[] = [],
|
|
@@ -182,6 +175,16 @@ export const memoryRepo = <Row>(
|
|
|
182
175
|
return row;
|
|
183
176
|
};
|
|
184
177
|
|
|
178
|
+
/**
|
|
179
|
+
* The stored rows a filtered write does NOT touch — the ones its results may not collide with.
|
|
180
|
+
* Every stored row counts, not only the visible ones: the unique index in Postgres covers the
|
|
181
|
+
* rows of another tenant and the soft-deleted ones too.
|
|
182
|
+
*/
|
|
183
|
+
const untouched = (matched: readonly Row[]): Row[] => {
|
|
184
|
+
const keys = new Set(matched.map((row) => storeKey(row)));
|
|
185
|
+
return [...rows.values()].filter((row) => !keys.has(storeKey(row)));
|
|
186
|
+
};
|
|
187
|
+
|
|
185
188
|
// The same guard the read path applies: on a tenant-scoped entity an id alone is not enough
|
|
186
189
|
// to name a row, so `update`/`delete` resolve through a plan rather than through the map.
|
|
187
190
|
const addressed = (id: string, options: RepoOptions | undefined, operation: string): Row => {
|
|
@@ -310,12 +313,7 @@ export const memoryRepo = <Row>(
|
|
|
310
313
|
|
|
311
314
|
async update(id, patch, options) {
|
|
312
315
|
const current = addressed(id, options, 'update');
|
|
313
|
-
return write(
|
|
314
|
-
Object.assign({}, current, defined(patch)),
|
|
315
|
-
options,
|
|
316
|
-
'update',
|
|
317
|
-
storeKey(current),
|
|
318
|
-
);
|
|
316
|
+
return write(mergedRow(entity, current, patch), options, 'update', storeKey(current));
|
|
319
317
|
},
|
|
320
318
|
|
|
321
319
|
async delete(id, options) {
|
|
@@ -379,8 +377,14 @@ export const memoryRepo = <Row>(
|
|
|
379
377
|
if (hasJsOnlyInvariant(entity.$invariants) && found.length > MAX_ASSERTED_ROWS) {
|
|
380
378
|
throw assertedRowsTooMany(entity.$name, 'updateWhere', found.length);
|
|
381
379
|
}
|
|
382
|
-
|
|
383
|
-
|
|
380
|
+
// The WHOLE batch is judged before any row of it lands — the same statement Postgres sends,
|
|
381
|
+
// which refuses as one. A row this batch refuses used to leave the rows before it stored:
|
|
382
|
+
// the write was a loop, and the loop stopped half way through. Postgres rolls that
|
|
383
|
+
// statement back; here nothing was written yet, so nothing has to be undone.
|
|
384
|
+
const merged = judgeUpdate(entity, found, patch, untouched(found), storeKey);
|
|
385
|
+
for (const [position, row] of merged.entries()) {
|
|
386
|
+
write(row, options, 'updateWhere', storeKey(found[position]));
|
|
387
|
+
}
|
|
384
388
|
return found.length;
|
|
385
389
|
},
|
|
386
390
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Single responsibility: the memory driver's UPDATE — the merge of a patch into a row, and the
|
|
2
|
+
// judgement of a filtered update's whole result before any of it lands.
|
|
3
|
+
//
|
|
4
|
+
// Postgres refuses such a write as ONE statement: the rows come back from `update … returning *`,
|
|
5
|
+
// the app judges them, and a rule only the app can judge refuses the statement, so the
|
|
6
|
+
// transaction holding both takes the write with it (`judged-write.ts`). The in-memory driver wrote
|
|
7
|
+
// row by row, so a row refused half way through left the rows before it stored: one call, two
|
|
8
|
+
// answers — a test over the memory driver saw a partial write, and production rolled it back.
|
|
9
|
+
|
|
10
|
+
import { narrowRow } from './columns';
|
|
11
|
+
import type { EntityCore } from './entity';
|
|
12
|
+
import { uniqueClash, uniqueViolation } from './memory-unique';
|
|
13
|
+
import { assertRowTenant } from './tenancy';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The patch with every `undefined` property dropped — `bindValues` skips them in Postgres, so a
|
|
17
|
+
* patch built from optional input (`{ body: input.body }`) leaves the column alone in both drivers
|
|
18
|
+
* rather than erasing it. `null` is the value that clears a column.
|
|
19
|
+
*/
|
|
20
|
+
const defined = (patch: object): object =>
|
|
21
|
+
Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The row a patch makes of a stored one: the patch's values over the row's own, narrowed to what
|
|
25
|
+
* the store holds.
|
|
26
|
+
*
|
|
27
|
+
* Narrowed HERE, at the merge, because `$assert` judges the merged row and a money column's
|
|
28
|
+
* `bigint` minor unit has to be the `number` the row will hold. `insertAll` narrows twice for the
|
|
29
|
+
* same reason (`memory-repo.ts`), and narrowing twice is harmless: the second pass finds nothing
|
|
30
|
+
* left to change.
|
|
31
|
+
*/
|
|
32
|
+
export const mergedRow = <Row>(entity: EntityCore<Row>, row: Row, patch: object): Row =>
|
|
33
|
+
narrowRow<Row>(entity.$columns, Object.assign({}, row, defined(patch)));
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The rows a filtered update would write, judged as ONE batch: the patch merged into every
|
|
37
|
+
* matched row, and every result judged against the rows this write does not touch and against the
|
|
38
|
+
* results this same batch has already judged.
|
|
39
|
+
*
|
|
40
|
+
* `untouched` are the stored rows the filter did not match — including the rows of another tenant
|
|
41
|
+
* and the soft-deleted ones, because the unique index in Postgres covers those too. `key` is the
|
|
42
|
+
* key the store files a row under, handed in because that is the store's rule and this module must
|
|
43
|
+
* not spell it a second time.
|
|
44
|
+
*
|
|
45
|
+
* The judgement is the whole batch's, so a refusal lands before anything is stored — the shape
|
|
46
|
+
* `insertAll` already has, and the shape Postgres gives the statement it refuses.
|
|
47
|
+
*/
|
|
48
|
+
export const judgeUpdate = <Row>(
|
|
49
|
+
entity: EntityCore<Row>,
|
|
50
|
+
matched: readonly Row[],
|
|
51
|
+
patch: object,
|
|
52
|
+
untouched: readonly Row[],
|
|
53
|
+
key: (row: unknown) => string,
|
|
54
|
+
): readonly Row[] => {
|
|
55
|
+
const judged: Row[] = [];
|
|
56
|
+
const judgedKeys = new Set<string>();
|
|
57
|
+
const untouchedKeys = new Set(untouched.map((row) => key(row)));
|
|
58
|
+
for (const row of matched) {
|
|
59
|
+
const result = mergedRow(entity, row, patch);
|
|
60
|
+
// The same four judgements `write` makes, in the same order — on the merged row rather than
|
|
61
|
+
// on the patch, and on the whole batch rather than on the row that happens to come first.
|
|
62
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, 'updateWhere', result);
|
|
63
|
+
entity.$assert(result);
|
|
64
|
+
// The primary key: a result may not land on a key another row holds — neither one this write
|
|
65
|
+
// does not touch, nor one an earlier result of this same batch took. A row's OWN old version
|
|
66
|
+
// is not among them: it is the row this result replaces, and it never collides with itself.
|
|
67
|
+
const resultKey = key(result);
|
|
68
|
+
if (untouchedKeys.has(resultKey) || judgedKeys.has(resultKey)) {
|
|
69
|
+
throw uniqueViolation(entity, `${entity.$table}_pkey`);
|
|
70
|
+
}
|
|
71
|
+
// The unique constraints, against the same two sets. The rows this batch matched are not in
|
|
72
|
+
// either: each of them is replaced by its own result, which is judged here instead.
|
|
73
|
+
const clash = uniqueClash(entity, result, [...untouched, ...judged]);
|
|
74
|
+
if (clash !== undefined) throw uniqueViolation(entity, clash);
|
|
75
|
+
judged.push(result);
|
|
76
|
+
judgedKeys.add(resultKey);
|
|
77
|
+
}
|
|
78
|
+
return judged;
|
|
79
|
+
};
|