@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 +17 -1
- package/package.json +5 -5
- package/src/array-element.ts +67 -0
- package/src/columns-data.ts +10 -11
- package/src/cross-tenant.ts +7 -7
- package/src/describe.ts +7 -3
- package/src/memory-match.ts +17 -7
- package/src/pg-row.ts +6 -0
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
|
|
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": "
|
|
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": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/time": "
|
|
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
|
+
};
|
package/src/columns-data.ts
CHANGED
|
@@ -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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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) => {
|
package/src/cross-tenant.ts
CHANGED
|
@@ -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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
//
|
|
71
|
-
// `
|
|
72
|
-
//
|
|
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;
|
package/src/memory-match.ts
CHANGED
|
@@ -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
|
|
175
|
+
return isNull(actual);
|
|
166
176
|
case 'is-not-null':
|
|
167
|
-
return actual
|
|
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';
|