@ultimat3/entity 6.0.0 → 8.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
@@ -568,9 +568,24 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
568
568
  the tenant is a request-time value, so the seam is the enforcement.
569
569
  - **`crossTenant(reason, fn)` (`cross-tenant.ts`) is the ONE way to read across tenants**, for the
570
570
  three cases that have no single one: an admin surface over every org, background reconciliation,
571
- support tooling. An `AsyncLocalStorage` scope with a written reason, the same shape
571
+ support tooling. An async-context scope with a written reason, the same shape
572
572
  `@ultimat3/db`'s `expectedQueryLoop` has, never a boolean argument on a repository call — which
573
573
  reads exactly like forgetting the tenant — and never a config list of exempt entities (axiom 1).
574
+ The scope opens through `asyncContext<string>('the cross-tenant reason')` from `@ultimat3/core`,
575
+ **never a `new AsyncLocalStorage` here, and that is a build error rather than a convention `As of
576
+ 2026-08`** — `scripts/async-context-guard.ts` refuses the construction *and* the import that
577
+ binds the class, anywhere but `packages/core/src/async-context.ts`, and
578
+ `scripts/async-context-guard.test.ts` runs it over the tree in the gate's `unit` step. The
579
+ module-scope `new` this replaced threw `TypeError: undefined is not a constructor` at module
580
+ **evaluation** in a browser bundle, where the bundler stubs `node:async_hooks` to `{}`, taking
581
+ every importer of `cross-tenant.ts` with it. Now the module evaluates and `crossTenantReason()`
582
+ answers `undefined` there — in a browser nothing IS in flight, so that is the true answer. A
583
+ write is the case that names itself: `storage.run` throws `X_ASYNC_CONTEXT_UNAVAILABLE` instead
584
+ of a bare `TypeError`, though `crossTenant()` reaches it only past `assertCrossTenant`, which
585
+ wants a request context a browser does not have. A server saves no allocation — the store is
586
+ built on the first `get()` **or** `run()`, so a read constructs it too; what the laziness costs
587
+ is nothing observable, since `getStore()` outside a scope answers `undefined` whether the storage
588
+ existed or not.
574
589
  **The capability is proven twice**: `CROSS_TENANT_SCOPE` (`tenancy:cross`) on the actor, at the
575
590
  call and again at every plan built inside it, because `withChildContext({ actor })` swaps the
576
591
  actor without closing the scope and an impersonated caller must not inherit it —
@@ -738,6 +753,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
738
753
  | `types.ts` | `Column`, `RowOf`, `Insertable`, `IdOf` — the type derivation. `COLUMN_KINDS` is the runtime array `ColumnKind` DERIVES from (the shape core's `PRIMITIVE_KINDS` uses), so a package answering "one case per kind" reads a real list rather than spelling its own |
739
754
  | `column.ts` / `columns.ts` | the chain + property-key binding; the blessed builders; `columnName`/`moneyColumns`, the ONE physical-name resolver; `narrowMoney`, the one write-side narrowing both drivers run |
740
755
  | `columns-data.ts` | the wide vocabulary an existing schema needs: `json`, `decimal`, `date`, `bigint`, `bytes`, `arrayOf` |
756
+ | `array-element.ts` | which element kinds `arrayOf()` refuses, and the one-line edit that repairs each |
741
757
  | `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; bind + `toSql()` DDL |
742
758
  | `entity.ts` / `describe.ts` | `entity()`, `$row`; the `EntityDescription` projection |
743
759
  | `view.ts` | `$view(keys)` — the row projection an action names as its `output` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "6.0.0",
3
+ "version": "8.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": "6.0.0",
35
- "@ultimat3/db": "6.0.0",
36
- "@ultimat3/schema": "6.0.0",
37
- "@ultimat3/time": "6.0.0"
34
+ "@ultimat3/core": "8.0.0",
35
+ "@ultimat3/db": "8.0.0",
36
+ "@ultimat3/schema": "8.0.0",
37
+ "@ultimat3/time": "8.0.0"
38
38
  }
39
39
  }
@@ -0,0 +1,67 @@
1
+ // Which element kinds `arrayOf()` refuses, and the one-line edit that repairs each. Split from
2
+ // `columns-data.ts`, which parses columns: a refusal POLICY and four repair strings are a second
3
+ // responsibility, and the file was 250 lines with both in it.
4
+
5
+ import { EntityError } from './errors';
6
+
7
+ /** The element kinds `arrayElement` (`pg-row.ts`) has no literal for, and why each one is refused. */
8
+ const ARRAY_ELEMENT_REFUSED = ['money', 'array', 'jsonb', 'bytea'] as const;
9
+
10
+ export type RefusedElement = (typeof ARRAY_ELEMENT_REFUSED)[number];
11
+
12
+ export const isRefusedElement = (kind: string): kind is RefusedElement =>
13
+ (ARRAY_ELEMENT_REFUSED as readonly string[]).includes(kind);
14
+
15
+ /**
16
+ * One column per refused element kind: the shape that holds the same list and can be written.
17
+ * `Object.freeze<Record<K, V>>` and never `Readonly<Record<K, V>> = Object.freeze({…})`, which
18
+ * infers the key set from the literal and would accept a fifth key in silence.
19
+ *
20
+ * Each value is a MECHANICAL edit with nothing for the reader to supply — the placeholder form
21
+ * `json(t.array(<element schema>))` was the defect: a `fix:` a reader has to complete is one they
22
+ * can complete wrongly, and `<element schema>` pasted verbatim is a syntax error. The two that
23
+ * need a second table name it (`amounts`, `blobs`) rather than saying "a child table", so every
24
+ * line is text that runs.
25
+ */
26
+ const ARRAY_ELEMENT_FIXES = Object.freeze<Record<RefusedElement, string>>({
27
+ money:
28
+ 'move the list to its own entity and relate it — ' +
29
+ "entity('amounts', { columns: { amount: money() } }) — then drop this column: an array column " +
30
+ 'is ONE column and money() is three (minor, currency, scale)',
31
+ array:
32
+ 'rewrite arrayOf(arrayOf(x)) as arrayOf(x) if the nesting carries no meaning, or move the ' +
33
+ "inner list to its own entity and relate it — entity('items', { columns: { value: text() } })",
34
+ jsonb:
35
+ 'rewrite arrayOf(json(S)) as json(t.array(S)) with S unchanged — one jsonb column holds the ' +
36
+ 'whole list and t.array still validates every member',
37
+ bytea:
38
+ 'move the list to its own entity and relate it — ' +
39
+ "entity('blobs', { columns: { data: bytes() } }) — then drop this column: one row per blob, " +
40
+ 'and bytea has no array literal that survives the driver',
41
+ });
42
+
43
+ /**
44
+ * An element the Postgres array literal cannot carry, refused where the schema is still being
45
+ * written. Two different reasons, one code — the situation is a single one, "this list needs a
46
+ * different column" — so only the cause and the fix branch.
47
+ *
48
+ * `money` and `array` are not ONE column: three physical columns for an amount, and a nested array
49
+ * has no unambiguous literal form. `jsonb` and `bytea` are one column each and were the silent
50
+ * half: `arrayElement` renders any object as `""`, so two objects bound as `{"",""}` and one blob
51
+ * as `{""}` (measured), while `memoryRepo` kept the value — a loss no test in this tree could see
52
+ * and only a table could show.
53
+ *
54
+ * Not `reject()`: a declaration is repaired by an EDIT, and `reject`'s
55
+ * `x entities describe column --json` is `X_DECLARATION_UNKNOWN` — no entity is named `column`, and
56
+ * there is no entity at all yet. So each fix is the edit that holds the list instead.
57
+ */
58
+ export const arrayElementRefused = (kind: RefusedElement): EntityError => {
59
+ const singleColumn = kind === 'money' || kind === 'array';
60
+ return new EntityError({
61
+ code: 'X_INVARIANT_VIOLATED',
62
+ cause: singleColumn
63
+ ? `arrayOf(${kind}) has no single column behind it — an array element is one scalar column, and ${kind === 'money' ? 'money is three (minor, currency, scale)' : 'a nested array has no unambiguous literal form'}`
64
+ : `arrayOf(${kind}) has no array literal form — every element would cross to Postgres as an empty string while memoryRepo kept the value, so the loss is invisible until the row is read back`,
65
+ fix: ARRAY_ELEMENT_FIXES[kind],
66
+ });
67
+ };
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { describeValue, formatIssues, type StandardSchemaV1, validate } from '@ultimat3/schema';
12
12
  import { isPlainDate, type PlainDate, plainDateUtc } from '@ultimat3/time';
13
+ import { arrayElementRefused, isRefusedElement } from './array-element';
13
14
  import { column } from './column';
14
15
  import { invariantViolated } from './errors';
15
16
  import type { AnyColumn, Column, ColumnMeta } from './types';
@@ -27,9 +28,13 @@ const got = (value: unknown): string => `got ${describeValue(value)}`;
27
28
  * place for one — the value arrives from the DATABASE as often as from a caller, so the row type
28
29
  * would be a claim nothing ever checked.
29
30
  *
30
- * The object is bound as an object, never as a string: a JSON string parameter is stored as a JSON
31
- * *string* by Postgres (measured `'{"a":1}'` comes back as the text, not the object), so
32
- * stringifying here would change the value's type in the table.
31
+ * The value crosses to Postgres as TEXT and is cast back `bindValues` calls `JSON.stringify` and
32
+ * `cellCast` (`pg-sql.ts`) writes `::text::jsonb` and both halves are load-bearing. The driver
33
+ * seam refuses a plain object as a parameter (`X_SQL_UNSAFE`), so the object cannot cross as
34
+ * itself; and under a bare `$1::jsonb` the server describes the parameter as `jsonb`, Bun's `sql`
35
+ * JSON-ENCODES the string it was handed, and `{"a":1}` lands as a JSON *string* — `jsonb_typeof`
36
+ * answers `string` (measured, Postgres 17.10). Pinning the parameter to `text` first is what makes
37
+ * the server parse the characters, so neither half may be changed without the other.
33
38
  */
34
39
  export const json = <T>(schema: StandardSchemaV1<unknown, T>): Column<T> =>
35
40
  column<T>('jsonb', (value) => {
@@ -180,17 +185,11 @@ export const bytes = (): Column<Uint8Array> =>
180
185
  * `$parse` decides every member: `arrayOf(text({ max: 40 }))` refuses a 41-character tag exactly
181
186
  * where a `text()` column would.
182
187
  *
183
- * Money and arrays of arrays are refused rather than approximated: money is three physical columns
184
- * and cannot be one array element, and a nested array has no unambiguous literal form.
188
+ * Four element kinds are refused rather than approximated see `arrayElementRefused`.
185
189
  */
186
190
  export const arrayOf = <T>(element: Column<T>): Column<readonly T[]> => {
187
191
  const kind = element.$meta.kind;
188
- if (kind === 'money' || kind === 'array') {
189
- reject(
190
- 'array',
191
- `arrayOf(${kind}) has no single column behind it — an array element is one scalar column`,
192
- );
193
- }
192
+ if (isRefusedElement(kind)) throw arrayElementRefused(kind);
194
193
  return column<readonly T[]>(
195
194
  'array',
196
195
  (value) => {
@@ -3,11 +3,11 @@
3
3
  // reads exactly like forgetting the tenant, and never a config list of exempt entities (axiom 1):
4
4
  // both put the argument somewhere other than the read it defends.
5
5
 
6
- // `node:` because Bun exposes no native async-context primitive: the scope has to outlive every
7
- // `await` inside it, and `AsyncLocalStorage` is the only thing that carries a value across them.
8
- // A module-scope flag would be shared by two concurrent requests one of them ordinary.
9
- import { AsyncLocalStorage } from 'node:async_hooks';
10
- import { actorLabel, assert, hasScope, tryUseContext } from '@ultimat3/core';
6
+ // The scope has to outlive every `await` inside it and a module-scope flag would be shared by two
7
+ // concurrent requests one of them ordinary so it needs an async context. Opened through core's
8
+ // one lazy seam rather than a `node:async_hooks` construction here, which threw at module
9
+ // EVALUATION in a browser bundle (the bundler stubs the module to `{}`).
10
+ import { actorLabel, assert, asyncContext, hasScope, tryUseContext } from '@ultimat3/core';
11
11
  import { crossTenantDenied } from './errors';
12
12
 
13
13
  /**
@@ -18,7 +18,7 @@ import { crossTenantDenied } from './errors';
18
18
  */
19
19
  export const CROSS_TENANT_SCOPE = 'tenancy:cross';
20
20
 
21
- const storage = new AsyncLocalStorage<string>();
21
+ const storage = asyncContext<string>('the cross-tenant reason');
22
22
 
23
23
  /**
24
24
  * Run `fn` with the tenant guard lifted — every read and write it issues, at any depth and across
@@ -54,7 +54,7 @@ export function crossTenant<T>(reason: string, fn: () => T): T {
54
54
  * The innermost enclosing reason, or `undefined` outside every scope — which is every query in an
55
55
  * app that never calls `crossTenant`. Read by the tenant guard, and by nothing else.
56
56
  */
57
- export const crossTenantReason = (): string | undefined => storage.getStore();
57
+ export const crossTenantReason = (): string | undefined => storage.get();
58
58
 
59
59
  /**
60
60
  * The capability check itself, run at `crossTenant()` and again for every plan built inside it.
package/src/describe.ts CHANGED
@@ -67,9 +67,13 @@ export const sqlTypeOf = (meta: ColumnMeta): string => {
67
67
  }
68
68
  if (meta.kind === 'array') {
69
69
  const element = meta.element?.$meta;
70
- // `arrayOf` refuses an element that is not one scalar column, so this is total in practice;
71
- // `text[]` is the answer that keeps a description renderable rather than throwing inside a
72
- // projection, which is the one place an error has no caller to instruct.
70
+ // The element KIND is bounded by `arrayOf`, which refuses money, a nested array, `jsonb` and
71
+ // `bytea` at declaration it refused only the first two until 2026-08, so this line emitted a
72
+ // real `jsonb[]`/`bytea[]` for a column `bindValues` wrote as `{"",""}`: a DDL type for a value
73
+ // that could not survive the trip. What is NOT bounded is `element` itself, which is absent on
74
+ // any `ColumnMeta` nobody built through `arrayOf()`; `text[]` keeps such a description
75
+ // renderable rather than throwing inside a projection, the one place an error has no caller to
76
+ // instruct.
73
77
  return `${element === undefined ? 'text' : sqlTypeOf(element)}[]`;
74
78
  }
75
79
  return meta.kind;
@@ -31,6 +31,9 @@ import type { ColumnKind } from './types';
31
31
  */
32
32
  const DECIMAL_TEXT: ReadonlySet<ColumnKind> = new Set<ColumnKind>(['bigint', 'numeric']);
33
33
 
34
+ /** Absent and NULL are one thing to a predicate: a column the projection left out is not a value. */
35
+ const isNull = (value: unknown): boolean => value === null || value === undefined;
36
+
34
37
  const sign = <T extends number | bigint | string>(left: T, right: T): number =>
35
38
  left < right ? -1 : left > right ? 1 : 0;
36
39
 
@@ -139,6 +142,13 @@ export const matchesPredicate = <Row>(
139
142
  const kind = kindOf(entity, predicate.column);
140
143
  const actual = valueAt(row, predicate.column);
141
144
  const same = (candidate: unknown): boolean => sameValueOfKind(kind, actual, candidate);
145
+ // `col > NULL` is UNKNOWN in SQL and UNKNOWN is not a match, so a NULL on EITHER side matches no
146
+ // row here either — `predicateSql` emits a bare `"col" > $1` and Postgres returns nothing. Without
147
+ // this the fall-through compared `String(null)` as the text `"null"`, which sorts after `"5"` and
148
+ // before `"z"`: `gt(seats, 5)` answered the null row in memory and never in production, and
149
+ // `lt(seats, null)` answered every row. The guard is HERE and not in `compareByKind`, which also
150
+ // orders a page — a sort puts NULLs last (`asc nulls last`) rather than dropping them.
151
+ const unknown = (): boolean => isNull(actual) || isNull(predicate.value);
142
152
  const order = (): number => compareByKind(kind, actual, predicate.value);
143
153
  switch (predicate.op) {
144
154
  case 'eq':
@@ -150,20 +160,20 @@ export const matchesPredicate = <Row>(
150
160
  case 'in':
151
161
  return Array.isArray(predicate.value) && predicate.value.some(same);
152
162
  case 'gt':
153
- return order() > 0;
163
+ return !unknown() && order() > 0;
154
164
  case 'gte':
155
- return order() >= 0;
165
+ return !unknown() && order() >= 0;
156
166
  case 'lt':
157
- return order() < 0;
167
+ return !unknown() && order() < 0;
158
168
  case 'lte':
159
- return order() <= 0;
169
+ return !unknown() && order() <= 0;
160
170
  // Real LIKE semantics, so `'draft%'` means "starts with" here exactly as it does in Postgres.
161
171
  // Treating the pattern as a substring would make the two drivers disagree.
162
172
  case 'like':
163
- return likePattern(entity.$name, String(predicate.value)).test(String(actual));
173
+ return !unknown() && likePattern(entity.$name, String(predicate.value)).test(String(actual));
164
174
  case 'is-null':
165
- return actual === null || actual === undefined;
175
+ return isNull(actual);
166
176
  case 'is-not-null':
167
- return actual !== null && actual !== undefined;
177
+ return !isNull(actual);
168
178
  }
169
179
  };
package/src/pg-row.ts CHANGED
@@ -102,6 +102,12 @@ export const bindValues = <Row>(
102
102
  * One array element, as a Postgres array literal spells it. Quoted always: an unquoted element
103
103
  * containing a comma, a brace or a backslash is a different array, and an empty string unquoted
104
104
  * is nothing at all.
105
+ *
106
+ * The `object` branch is the LAST resort and never a declared column's value: `arrayOf()` refuses
107
+ * `jsonb`, `bytea`, `money` and a nested array at declaration (`columns-data.ts`) precisely because
108
+ * this line has no literal for them and rendered every one as `""` — silently, and only against a
109
+ * real table, since `memoryRepo` stores the value it was handed. A `Date` is the one object shape
110
+ * with a literal, so it is named above.
105
111
  */
106
112
  const arrayElement = (value: unknown): string => {
107
113
  if (value === null || value === undefined) return 'NULL';