@ultimat3/entity 7.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 +1 -0
- package/package.json +5 -5
- package/src/array-element.ts +67 -0
- package/src/columns-data.ts +10 -11
- package/src/describe.ts +7 -3
- package/src/memory-match.ts +17 -7
- package/src/pg-row.ts +6 -0
package/CLAUDE.md
CHANGED
|
@@ -753,6 +753,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
753
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 |
|
|
754
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 |
|
|
755
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 |
|
|
756
757
|
| `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; bind + `toSql()` DDL |
|
|
757
758
|
| `entity.ts` / `describe.ts` | `entity()`, `$row`; the `EntityDescription` projection |
|
|
758
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/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';
|