@ultimat3/entity
Advanced tools
| // Which element kinds `arrayOf()` refuses, and the one-line edit that repairs each. Split from | ||
| // `columns-data.ts`, which parses columns: a refusal POLICY and four repair strings are a second | ||
| // responsibility, and the file was 250 lines with both in it. | ||
| import { EntityError } from './errors'; | ||
| /** The element kinds `arrayElement` (`pg-row.ts`) has no literal for, and why each one is refused. */ | ||
| const ARRAY_ELEMENT_REFUSED = ['money', 'array', 'jsonb', 'bytea'] as const; | ||
| export type RefusedElement = (typeof ARRAY_ELEMENT_REFUSED)[number]; | ||
| export const isRefusedElement = (kind: string): kind is RefusedElement => | ||
| (ARRAY_ELEMENT_REFUSED as readonly string[]).includes(kind); | ||
| /** | ||
| * One column per refused element kind: the shape that holds the same list and can be written. | ||
| * `Object.freeze<Record<K, V>>` and never `Readonly<Record<K, V>> = Object.freeze({…})`, which | ||
| * infers the key set from the literal and would accept a fifth key in silence. | ||
| * | ||
| * Each value is a MECHANICAL edit with nothing for the reader to supply — the placeholder form | ||
| * `json(t.array(<element schema>))` was the defect: a `fix:` a reader has to complete is one they | ||
| * can complete wrongly, and `<element schema>` pasted verbatim is a syntax error. The two that | ||
| * need a second table name it (`amounts`, `blobs`) rather than saying "a child table", so every | ||
| * line is text that runs. | ||
| */ | ||
| const ARRAY_ELEMENT_FIXES = Object.freeze<Record<RefusedElement, string>>({ | ||
| money: | ||
| 'move the list to its own entity and relate it — ' + | ||
| "entity('amounts', { columns: { amount: money() } }) — then drop this column: an array column " + | ||
| 'is ONE column and money() is three (minor, currency, scale)', | ||
| array: | ||
| 'rewrite arrayOf(arrayOf(x)) as arrayOf(x) if the nesting carries no meaning, or move the ' + | ||
| "inner list to its own entity and relate it — entity('items', { columns: { value: text() } })", | ||
| jsonb: | ||
| 'rewrite arrayOf(json(S)) as json(t.array(S)) with S unchanged — one jsonb column holds the ' + | ||
| 'whole list and t.array still validates every member', | ||
| bytea: | ||
| 'move the list to its own entity and relate it — ' + | ||
| "entity('blobs', { columns: { data: bytes() } }) — then drop this column: one row per blob, " + | ||
| 'and bytea has no array literal that survives the driver', | ||
| }); | ||
| /** | ||
| * An element the Postgres array literal cannot carry, refused where the schema is still being | ||
| * written. Two different reasons, one code — the situation is a single one, "this list needs a | ||
| * different column" — so only the cause and the fix branch. | ||
| * | ||
| * `money` and `array` are not ONE column: three physical columns for an amount, and a nested array | ||
| * has no unambiguous literal form. `jsonb` and `bytea` are one column each and were the silent | ||
| * half: `arrayElement` renders any object as `""`, so two objects bound as `{"",""}` and one blob | ||
| * as `{""}` (measured), while `memoryRepo` kept the value — a loss no test in this tree could see | ||
| * and only a table could show. | ||
| * | ||
| * Not `reject()`: a declaration is repaired by an EDIT, and `reject`'s | ||
| * `x entities describe column --json` is `X_DECLARATION_UNKNOWN` — no entity is named `column`, and | ||
| * there is no entity at all yet. So each fix is the edit that holds the list instead. | ||
| */ | ||
| export const arrayElementRefused = (kind: RefusedElement): EntityError => { | ||
| const singleColumn = kind === 'money' || kind === 'array'; | ||
| return new EntityError({ | ||
| code: 'X_INVARIANT_VIOLATED', | ||
| cause: singleColumn | ||
| ? `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'}` | ||
| : `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`, | ||
| fix: ARRAY_ELEMENT_FIXES[kind], | ||
| }); | ||
| }; |
+5
-5
| { | ||
| "name": "@ultimat3/entity", | ||
| "version": "7.0.0", | ||
| "version": "8.0.0", | ||
| "description": "A table + its domain type + invariants the database also enforces", | ||
@@ -34,7 +34,7 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "7.0.0", | ||
| "@ultimat3/db": "7.0.0", | ||
| "@ultimat3/schema": "7.0.0", | ||
| "@ultimat3/time": "7.0.0" | ||
| "@ultimat3/core": "8.0.0", | ||
| "@ultimat3/db": "8.0.0", | ||
| "@ultimat3/schema": "8.0.0", | ||
| "@ultimat3/time": "8.0.0" | ||
| } | ||
| } |
+10
-11
@@ -13,2 +13,3 @@ // The column builders an EXISTING schema needs. `columns.ts` holds the opinionated set — one way | ||
| import { isPlainDate, type PlainDate, plainDateUtc } from '@ultimat3/time'; | ||
| import { arrayElementRefused, isRefusedElement } from './array-element'; | ||
| import { column } from './column'; | ||
@@ -31,5 +32,9 @@ import { invariantViolated } from './errors'; | ||
| * | ||
| * The object is bound as an object, never as a string: a JSON string parameter is stored as a JSON | ||
| * *string* by Postgres (measured — `'{"a":1}'` comes back as the text, not the object), so | ||
| * stringifying here would change the value's type in the table. | ||
| * The value crosses to Postgres as TEXT and is cast back — `bindValues` calls `JSON.stringify` and | ||
| * `cellCast` (`pg-sql.ts`) writes `::text::jsonb` — and both halves are load-bearing. The driver | ||
| * seam refuses a plain object as a parameter (`X_SQL_UNSAFE`), so the object cannot cross as | ||
| * itself; and under a bare `$1::jsonb` the server describes the parameter as `jsonb`, Bun's `sql` | ||
| * JSON-ENCODES the string it was handed, and `{"a":1}` lands as a JSON *string* — `jsonb_typeof` | ||
| * answers `string` (measured, Postgres 17.10). Pinning the parameter to `text` first is what makes | ||
| * the server parse the characters, so neither half may be changed without the other. | ||
| */ | ||
@@ -185,13 +190,7 @@ export const json = <T>(schema: StandardSchemaV1<unknown, T>): Column<T> => | ||
| * | ||
| * Money and arrays of arrays are refused rather than approximated: money is three physical columns | ||
| * and cannot be one array element, and a nested array has no unambiguous literal form. | ||
| * Four element kinds are refused rather than approximated — see `arrayElementRefused`. | ||
| */ | ||
| export const arrayOf = <T>(element: Column<T>): Column<readonly T[]> => { | ||
| const kind = element.$meta.kind; | ||
| if (kind === 'money' || kind === 'array') { | ||
| reject( | ||
| 'array', | ||
| `arrayOf(${kind}) has no single column behind it — an array element is one scalar column`, | ||
| ); | ||
| } | ||
| if (isRefusedElement(kind)) throw arrayElementRefused(kind); | ||
| return column<readonly T[]>( | ||
@@ -198,0 +197,0 @@ 'array', |
+7
-3
@@ -70,5 +70,9 @@ // The projection an entity hands the rest of the toolchain: `x.manifest.json`, the migration | ||
| const element = meta.element?.$meta; | ||
| // `arrayOf` refuses an element that is not one scalar column, so this is total in practice; | ||
| // `text[]` is the answer that keeps a description renderable rather than throwing inside a | ||
| // projection, which is the one place an error has no caller to instruct. | ||
| // The element KIND is bounded by `arrayOf`, which refuses money, a nested array, `jsonb` and | ||
| // `bytea` at declaration — it refused only the first two until 2026-08, so this line emitted a | ||
| // real `jsonb[]`/`bytea[]` for a column `bindValues` wrote as `{"",""}`: a DDL type for a value | ||
| // that could not survive the trip. What is NOT bounded is `element` itself, which is absent on | ||
| // any `ColumnMeta` nobody built through `arrayOf()`; `text[]` keeps such a description | ||
| // renderable rather than throwing inside a projection, the one place an error has no caller to | ||
| // instruct. | ||
| return `${element === undefined ? 'text' : sqlTypeOf(element)}[]`; | ||
@@ -75,0 +79,0 @@ } |
+17
-7
@@ -34,2 +34,5 @@ // Single responsibility: what a `Predicate` MEANS in the in-memory driver — equality, ordering and | ||
| /** Absent and NULL are one thing to a predicate: a column the projection left out is not a value. */ | ||
| const isNull = (value: unknown): boolean => value === null || value === undefined; | ||
| const sign = <T extends number | bigint | string>(left: T, right: T): number => | ||
@@ -143,2 +146,9 @@ left < right ? -1 : left > right ? 1 : 0; | ||
| const same = (candidate: unknown): boolean => sameValueOfKind(kind, actual, candidate); | ||
| // `col > NULL` is UNKNOWN in SQL and UNKNOWN is not a match, so a NULL on EITHER side matches no | ||
| // row here either — `predicateSql` emits a bare `"col" > $1` and Postgres returns nothing. Without | ||
| // this the fall-through compared `String(null)` as the text `"null"`, which sorts after `"5"` and | ||
| // before `"z"`: `gt(seats, 5)` answered the null row in memory and never in production, and | ||
| // `lt(seats, null)` answered every row. The guard is HERE and not in `compareByKind`, which also | ||
| // orders a page — a sort puts NULLs last (`asc nulls last`) rather than dropping them. | ||
| const unknown = (): boolean => isNull(actual) || isNull(predicate.value); | ||
| const order = (): number => compareByKind(kind, actual, predicate.value); | ||
@@ -155,18 +165,18 @@ switch (predicate.op) { | ||
| case 'gt': | ||
| return order() > 0; | ||
| return !unknown() && order() > 0; | ||
| case 'gte': | ||
| return order() >= 0; | ||
| return !unknown() && order() >= 0; | ||
| case 'lt': | ||
| return order() < 0; | ||
| return !unknown() && order() < 0; | ||
| case 'lte': | ||
| return order() <= 0; | ||
| return !unknown() && order() <= 0; | ||
| // Real LIKE semantics, so `'draft%'` means "starts with" here exactly as it does in Postgres. | ||
| // Treating the pattern as a substring would make the two drivers disagree. | ||
| case 'like': | ||
| return likePattern(entity.$name, String(predicate.value)).test(String(actual)); | ||
| return !unknown() && likePattern(entity.$name, String(predicate.value)).test(String(actual)); | ||
| case 'is-null': | ||
| return actual === null || actual === undefined; | ||
| return isNull(actual); | ||
| case 'is-not-null': | ||
| return actual !== null && actual !== undefined; | ||
| return !isNull(actual); | ||
| } | ||
| }; |
+6
-0
@@ -105,2 +105,8 @@ // Single responsibility: the two-way map between a physical Postgres row and an entity row. | ||
| * is nothing at all. | ||
| * | ||
| * The `object` branch is the LAST resort and never a declared column's value: `arrayOf()` refuses | ||
| * `jsonb`, `bytea`, `money` and a nested array at declaration (`columns-data.ts`) precisely because | ||
| * this line has no literal for them and rendered every one as `""` — silently, and only against a | ||
| * real table, since `memoryRepo` stores the value it was handed. A `Date` is the one object shape | ||
| * with a literal, so it is named above. | ||
| */ | ||
@@ -107,0 +113,0 @@ const arrayElement = (value: unknown): string => { |
Sorry, the diff of this file is too big to display
494058
1.23%41
2.5%7661
1.04%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated