@ultimat3/entity 9.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 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 four seams every write passes: `memoryRepo`'s
358
- `write()` plus its `insertAll`/`upsertAll` batch loops, and `postgresRepo`'s `writeRows()`,
359
- `update` and `updateWhere`. A row or patch naming another tenant is `X_TENANCY_ACTOR_MISMATCH` —
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": "9.0.0",
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": "9.0.0",
35
- "@ultimat3/db": "9.0.0",
36
- "@ultimat3/schema": "9.0.0",
37
- "@ultimat3/time": "9.0.0"
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/entity.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // (the typed db handle, migrations, cache tags, the admin UI, the manifest) is projected from
4
4
  // this one call.
5
5
 
6
+ import { renderThrowable } from '@ultimat3/core';
6
7
  import { describeValue, type StandardSchemaV1 } from '@ultimat3/schema';
7
8
  import { entityNow } from './clock';
8
9
  import { assertColumnName, bindColumn, columnName, moneyColumns } from './column';
@@ -284,9 +285,13 @@ export const entity = <const C extends ColumnMap>(
284
285
  try {
285
286
  return { value: parse(value) };
286
287
  } catch (error) {
287
- return {
288
- issues: [{ message: error instanceof Error ? error.message : String(error) }],
289
- };
288
+ // `renderThrowable`, never `error instanceof Error ? error.message : String(error)`:
289
+ // both halves of that read the caught value directly. `instanceof` consults
290
+ // `getPrototypeOf` and `String()` runs the value's own coercion, so a `Proxy` or a
291
+ // null-prototype throwable raised a SECOND, uncatchable `TypeError` out of the
292
+ // validator — where a rejection belongs. A column parser is app-reachable and an
293
+ // app's `$parse` may throw anything at all.
294
+ return { issues: [{ message: renderThrowable(error) }] };
290
295
  }
291
296
  },
292
297
  },
package/src/errors.ts CHANGED
@@ -61,6 +61,14 @@ registerErrorCodes(
61
61
  Object.fromEntries(Object.entries(ENTITY_ERROR_TITLES).map(([code, title]) => [code, { title }])),
62
62
  );
63
63
 
64
+ /**
65
+ * Base for every error this package throws. No `docs:` — `UltimateError` fills it from
66
+ * `describeErrorCode(code).docs`, which is `@ultimat3/core`'s `ERROR_DOCS_URL`: one page for every
67
+ * code, never one per code, because `wiki/` is the framework's only public documentation surface
68
+ * and a code lives there in a TABLE ROW, which has no anchor. The
69
+ * `https://ultimate.dev/errors/<code>` links this class built until 9.x answered 404, host
70
+ * included, on every refusal it has ever raised.
71
+ */
64
72
  export class EntityError extends UltimateError {
65
73
  override readonly name = 'EntityError';
66
74
 
@@ -69,7 +77,6 @@ export class EntityError extends UltimateError {
69
77
  code: init.code,
70
78
  cause: init.cause,
71
79
  fix: init.fix,
72
- docs: `https://ultimate.dev/errors/${init.code}`,
73
80
  });
74
81
  }
75
82
  }
@@ -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
- const same = (candidate: unknown): boolean => sameValueOfKind(kind, actual, candidate);
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 same(predicate.value);
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 !same(predicate.value);
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 Array.isArray(predicate.value) && predicate.value.some(same);
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(updatePlan(entity, filter, patch, options, 'updateWhere'), {});
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
  },
package/src/view.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // already describe. Values are validated by the entity's own column parsers; an unknown key is a
4
4
  // declaration-time failure, not a surprise on the first request.
5
5
 
6
+ import { renderThrowable } from '@ultimat3/core';
6
7
  import { describeValue, type StandardSchemaV1 } from '@ultimat3/schema';
7
8
  import { invariantViolated } from './errors';
8
9
  import type { AnyColumn, ColumnMap } from './types';
@@ -83,9 +84,10 @@ export const viewFor = <Row, K extends keyof Row & string>(
83
84
  try {
84
85
  return { value: parse(value) };
85
86
  } catch (error) {
86
- return {
87
- issues: [{ message: error instanceof Error ? error.message : String(error) }],
88
- };
87
+ // The entity's rule, for the same reason: `instanceof` and `String()` are both reads of
88
+ // a caught value, and a throwable that fights being read turned a rejected projection
89
+ // into an uncatchable `TypeError`. `renderThrowable` is total.
90
+ return { issues: [{ message: renderThrowable(error) }] };
89
91
  }
90
92
  },
91
93
  },