@ultimat3/entity 10.0.0 → 11.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/CLAUDE.md +15 -4
- package/package.json +5 -5
- package/src/memory-match.ts +28 -4
- package/src/repo.ts +8 -1
package/CLAUDE.md
CHANGED
|
@@ -60,7 +60,13 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
60
60
|
wrapped into a one-element list for the SQL and refused in memory — 0 rows against one driver, 1
|
|
61
61
|
against the other, from a call `andWhere(column, op, value: unknown)` compiles), and a list
|
|
62
62
|
carrying a NULL emits `(col in (…) or col is null)` — `col = null` is UNKNOWN, so the null row
|
|
63
|
-
the caller listed was the one row Postgres left out while memory included it.
|
|
63
|
+
the caller listed was the one row Postgres left out while memory included it. **A column the row
|
|
64
|
+
never NAMED is NULL**, `As of 2026-08-23`: the table holds NULL whether a row spelled it out or
|
|
65
|
+
omitted it, so `eq`, `neq` and `in` read the row side through `isNull` exactly as `is-null` and
|
|
66
|
+
the ordering guard already did — `===` made the two rows different, and `eq null` skipped the
|
|
67
|
+
absent one, `in [null]` missed it and `neq null` answered it, each the opposite of the same
|
|
68
|
+
predicate in production. A `money()` column holding NULL reaches this with no hand-built row at
|
|
69
|
+
all: `valueAt(row, 'price.minor')` has nothing to read, whatever `$parse` produced.
|
|
64
70
|
- **The Postgres driver is proved against a real Postgres, not only against a recording client.**
|
|
65
71
|
`pg-driver.live.test.ts` runs the whole chain — `entity()` -> `$describe()` ->
|
|
66
72
|
`generateMigration()` -> a live server -> `postgresDriver()` -> decoded row — and skips when no
|
|
@@ -354,9 +360,14 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
354
360
|
or a filter alone never addresses a row on a tenant-scoped entity — another tenant's id reads as
|
|
355
361
|
`X_NOT_FOUND`, never as their row. That bounds WHICH rows a write touches; it cannot bound what
|
|
356
362
|
they become, and `insert`/`insertAll`/`upsertAll` build no plan at all. So the VALUE is judged as
|
|
357
|
-
well, by `assertRowTenant` (`tenancy.ts`) at the
|
|
358
|
-
`write()` plus its `insertAll`/`upsertAll` batch loops
|
|
359
|
-
`update` and `updateWhere`. A
|
|
363
|
+
well, by `assertRowTenant` (`tenancy.ts`) at the seams every write passes: `memoryRepo`'s
|
|
364
|
+
`write()` plus its `insertAll`/`upsertAll` batch loops and its `updateWhere`, and
|
|
365
|
+
`postgresRepo`'s `writeRows()`, `update` and `updateWhere`. **A filtered update judges the PATCH,
|
|
366
|
+
before it reads a row** — `As of 2026-08-23`, in both drivers. `memoryRepo` judged the merged
|
|
367
|
+
rows inside its loop, and a loop over no rows judges nothing, so
|
|
368
|
+
`updateWhere(filter, { orgId: theirs })` over a filter matching nothing answered `0` in memory
|
|
369
|
+
and `X_TENANCY_ACTOR_MISMATCH` in Postgres: whether the guard fired depended on what the table
|
|
370
|
+
held rather than on what the caller asked for. A row or patch naming another tenant is `X_TENANCY_ACTOR_MISMATCH` —
|
|
360
371
|
the same code the read path throws, because it is the same mistake in a different argument.
|
|
361
372
|
Rules, none optional. **Refuse, never stamp**: a row that names no tenant is left alone and the
|
|
362
373
|
column's `NOT NULL` answers it. Filling one in from the actor would change the column list
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.0.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/time": "
|
|
34
|
+
"@ultimat3/core": "11.0.0",
|
|
35
|
+
"@ultimat3/db": "11.0.0",
|
|
36
|
+
"@ultimat3/schema": "11.0.0",
|
|
37
|
+
"@ultimat3/time": "11.0.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/memory-match.ts
CHANGED
|
@@ -141,7 +141,20 @@ export const matchesPredicate = <Row>(
|
|
|
141
141
|
// predicate and a money sort key both name.
|
|
142
142
|
const kind = kindOf(entity, predicate.column);
|
|
143
143
|
const actual = valueAt(row, predicate.column);
|
|
144
|
-
|
|
144
|
+
/**
|
|
145
|
+
* `col = <value>`, with SQL's three-valued logic on both sides: a NULL is never EQUAL to
|
|
146
|
+
* anything, the other NULL included, so `equals` answers false the moment either side is one
|
|
147
|
+
* and the operators below decide what that means for them.
|
|
148
|
+
*
|
|
149
|
+
* The row side reads through `isNull`, so a row that never NAMED a nullable column is the same
|
|
150
|
+
* row as one that stored `null` — which is what the table holds for both, and what `is-null`
|
|
151
|
+
* has always answered here. `===` made them two: `eq null` skipped the absent row, `in [null]`
|
|
152
|
+
* missed it and `neq null` answered it, each the opposite of the same predicate in production.
|
|
153
|
+
* A `money()` column holding NULL reaches this every time, with no hand-built row at all —
|
|
154
|
+
* `valueAt(row, 'price.minor')` has nothing to read.
|
|
155
|
+
*/
|
|
156
|
+
const equals = (candidate: unknown): boolean =>
|
|
157
|
+
!isNull(actual) && !isNull(candidate) && sameValueOfKind(kind, actual, candidate);
|
|
145
158
|
// `col > NULL` is UNKNOWN in SQL and UNKNOWN is not a match, so a NULL on EITHER side matches no
|
|
146
159
|
// row here either — `predicateSql` emits a bare `"col" > $1` and Postgres returns nothing. Without
|
|
147
160
|
// this the fall-through compared `String(null)` as the text `"null"`, which sorts after `"5"` and
|
|
@@ -151,14 +164,25 @@ export const matchesPredicate = <Row>(
|
|
|
151
164
|
const unknown = (): boolean => isNull(actual) || isNull(predicate.value);
|
|
152
165
|
const order = (): number => compareByKind(kind, actual, predicate.value);
|
|
153
166
|
switch (predicate.op) {
|
|
167
|
+
// `predicateSql` compiles a null operand to `"col" is null` rather than binding it, so this
|
|
168
|
+
// is the same predicate, not a widening of it.
|
|
154
169
|
case 'eq':
|
|
155
|
-
return
|
|
170
|
+
return predicate.value === null ? isNull(actual) : equals(predicate.value);
|
|
171
|
+
// `is distinct from` reads a NULL as a value on BOTH sides: TRUE where one side is null and
|
|
172
|
+
// the other is not, FALSE where both are. A bound `undefined` is a NULL parameter there, so
|
|
173
|
+
// the operand side reads through `isNull` and the two spellings mean one thing.
|
|
156
174
|
case 'neq':
|
|
157
|
-
return !
|
|
175
|
+
return isNull(predicate.value) ? !isNull(actual) : !equals(predicate.value);
|
|
158
176
|
// `in` reads a LIST or nothing: an operand that is not an array matches no row, which is what
|
|
159
177
|
// `predicateSql` now compiles it to and what `@ultimat3/query` answers for the same operand.
|
|
178
|
+
// A NULL inside the list is asked as `is null` beside the list there, for the same reason.
|
|
160
179
|
case 'in':
|
|
161
|
-
return
|
|
180
|
+
return (
|
|
181
|
+
Array.isArray(predicate.value) &&
|
|
182
|
+
predicate.value.some((candidate) =>
|
|
183
|
+
isNull(candidate) ? isNull(actual) : equals(candidate),
|
|
184
|
+
)
|
|
185
|
+
);
|
|
162
186
|
case 'gt':
|
|
163
187
|
return !unknown() && order() > 0;
|
|
164
188
|
case 'gte':
|
package/src/repo.ts
CHANGED
|
@@ -413,10 +413,17 @@ export const memoryRepo = <Row>(
|
|
|
413
413
|
},
|
|
414
414
|
|
|
415
415
|
async updateWhere(filter, patch, options) {
|
|
416
|
+
const plan = updatePlan(entity, filter, patch, options, 'updateWhere');
|
|
417
|
+
// The PATCH, judged whole and before the rows are read — the same call `postgresRepo` makes
|
|
418
|
+
// before its statement exists. Inside the loop below it is judged only where a row was
|
|
419
|
+
// matched, so a patch handing rows to another tenant was refused or accepted depending on
|
|
420
|
+
// what the table happened to hold: `updateWhere(filter, { orgId: theirs })` over a filter
|
|
421
|
+
// matching nothing answered `0` here and threw there, from one call.
|
|
422
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, 'updateWhere', patch);
|
|
416
423
|
// `rowsOf` again, so a soft-deleted row is as unreachable here as it is through
|
|
417
424
|
// `addressed()` — patching a row the app has already deleted is not an update, it is a
|
|
418
425
|
// resurrection nobody asked for. `write` re-asserts the invariants on each result.
|
|
419
|
-
const found = rowsOf(
|
|
426
|
+
const found = rowsOf(plan, {});
|
|
420
427
|
for (const row of found) write(Object.assign({}, row, patch), options, 'updateWhere');
|
|
421
428
|
return found.length;
|
|
422
429
|
},
|