@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "28.0.0",
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.3"
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": "28.0.0",
37
- "@ultimat3/db": "28.0.0",
38
- "@ultimat3/schema": "28.0.0",
39
- "@ultimat3/time": "28.0.0"
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
  }
@@ -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
- for (const row of found)
383
- write(Object.assign({}, row, defined(patch)), options, 'updateWhere', storeKey(row));
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
+ };