@ultimat3/entity 27.6.0 → 27.6.1

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
@@ -48,7 +48,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
48
48
  list or nothing, and a NULL in it emits `(col in (…) or col is null)`; a column the row never NAMED
49
49
  is NULL for `eq`/`neq`/`in`.
50
50
  - **The Postgres driver is proved against a real Postgres** (`pg-driver.live.test.ts`, skipped without
51
- `TEST_DATABASE_URL`). A new operator, column kind or write path is not done until it round-trips.
51
+ `TEST_DATABASE_URL`). A new operator, kind or write path is not done until it round-trips.
52
52
  - **A repository call rejects, never throws synchronously** (`tableFor`'s writes are `async`).
53
53
  - **`defaultDriver()`** is the process default; `Driver.reset?()` is optional (memory only), resets
54
54
  repositories in place. Test seam only.
@@ -102,7 +102,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
102
102
  `seekFrom(entity, plan)`; **`assertSeekable` runs in `planFor`**, before a statement exists. A
103
103
  `timestamptz` sort key is refused when its `<column>$US` alias would pass 63 bytes. A cursor is bound
104
104
  to `planScope(plan)` (entity, filters, sort); a bad one is `X_CURSOR_INVALID`, never "from the top".
105
- - **A NULLABLE sort key orders** `asc nulls last` / `desc nulls first` (written down); the cursor tags
105
+ - **A NULLABLE sort key orders** `asc nulls last` / `desc nulls first`; the cursor tags
106
106
  `~` for NULL and `!` for present; the seek reaches NULLs; a nullable key has no row comparison. Only a
107
107
  nullable PRIMARY-KEY column is refused. `pg-null-order.live.test.ts`.
108
108
  - **A `timestamp` cursor carries MICROSECONDS**: `seekPrecision` projects `(col at time zone
@@ -145,8 +145,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
145
145
  NAME and the TABLE are different things** — cache tags and policies key on the name; index names are
146
146
  the table's.
147
147
  - **Invariants run twice and only ONE side is rendered here**: app-side `assertInvariants`, and the
148
- CHECK/UNIQUE DDL is `@ultimat3/db`'s `invariant-ddl.ts` reading `$describe()` (`$migration()` and the
149
- local renderers are deleted). An untranslatable predicate is `kind: 'assert'`, `sql: null`.
148
+ CHECK/UNIQUE DDL is `@ultimat3/db`'s `invariant-ddl.ts` reading `$describe()`. An untranslatable predicate is `kind: 'assert'`, `sql: null`.
150
149
  **`InvariantDescription.columns` is projected** (`describe-invariant.test.ts`).
151
150
  - **The two halves must AGREE, term by term** (`expr.ts`): `matches(/…/i)` is `~*`, other flags are
152
151
  refused (`matchOperator`); `minLength` counts code points; `trimmed()` strips U+0020 only (`btrim`);
@@ -175,8 +174,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
175
174
  `scale: null` means a two-column amount (`decodeRow` branches on `$meta.kind === 'money'`).
176
175
  - **Timestamps are `timestamptz`.** A naive timestamp stays inexpressible.
177
176
  - **A `jsonb` value is bound `::text::jsonb`** (load-bearing); an array is written as a quoted `{…}`
178
- literal. **Wide column types are normalised in `$parse`** to one row type per driver (`bigint()` and
179
- `decimal()` are strings).
177
+ literal, read by its DECLARATION (`pg-array-decode.ts`); `column-matrix-fixture.ts` is every kind
178
+ on both drivers. **Wide types are normalised in `$parse`** (`bigint()`, `decimal()`: strings).
180
179
  - **Every framework member on an entity is `$`-prefixed.** Row types are derived, never re-declared.
181
180
  **A branded id survives to the signature** (`IdOf<Row>`). `type-pins.ts` enforces all of it.
182
181
  - **`$parse` tells absence from `null`** (`raw === undefined ? defaultValue(...) : raw`).
package/README.md CHANGED
@@ -240,7 +240,7 @@ for a table it was going to create; these are the shapes a table already has, `A
240
240
  | `date()` | `date` | `@ultimat3/time`'s `PlainDate` — a calendar date, no time, no zone. `effective_on` is the date a rate applies, and as a `timestamptz` it is a different date on either side of midnight for half the planet |
241
241
  | `bigint()` | `bigint` | a decimal `string`. A JS `bigint` is what `JSON.stringify` throws on and a `number` loses digits past 2^53 — which is exactly where a legacy `int8` key lives. Both driver spellings (a string from Bun's `sql`, a `bigint` from PGlite) arrive as one. Bounded at `int8` (−2^63 … 2^63−1): one past either end is refused where Postgres answers `22003` |
242
242
  | `bytes()` | `bytea` | a plain `Uint8Array`, normalised: Bun's `sql` returns a `Buffer` and PGlite a `Uint8Array`, and the two do not serialise alike |
243
- | `arrayOf(column)` | `<element>[]` | `readonly T[]`, each member parsed by the element column it was given. Money and nested arrays are refused — an element is one scalar column |
243
+ | `arrayOf(column)` | `<element>[]` | `readonly T[]`, each member parsed by the element column it was given. Money and nested arrays are refused — an element is one scalar column. Read by the declaration, not by the driver: `Bun.SQL` hands `uuid[]` back as its text literal (`'{…}'`) and PGlite as an `Array`, and both decode to the same row |
244
244
 
245
245
  ## Adopting an existing table
246
246
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "27.6.0",
3
+ "version": "27.6.1",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,9 +33,9 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "dependencies": {
36
- "@ultimat3/core": "27.6.0",
37
- "@ultimat3/db": "27.6.0",
38
- "@ultimat3/schema": "27.6.0",
39
- "@ultimat3/time": "27.6.0"
36
+ "@ultimat3/core": "27.6.1",
37
+ "@ultimat3/db": "27.6.1",
38
+ "@ultimat3/schema": "27.6.1",
39
+ "@ultimat3/time": "27.6.1"
40
40
  }
41
41
  }
package/src/index.ts CHANGED
@@ -88,10 +88,12 @@ export { memoryRepo, memoryTransactor } from './memory-repo';
88
88
  export type { StatementLoop } from './n-plus-one';
89
89
  export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
90
90
  export { persistedRecordTypes } from './persisted-types';
91
- export type { PostgresDriverOptions } from './pg-driver';
92
- export { postgresDriver, postgresRepo } from './pg-driver';
93
91
  // For a change feed holding a table name and raw columns: `entityForTable` + `decodeRow` is the row
94
92
  // the app declared, money and all. Exported for `@ultimat3/realtime` (plan 101, 06 i).
93
+ export type { PgArray } from './pg-array-literal';
94
+ export { parsePgArray } from './pg-array-literal';
95
+ export type { PostgresDriverOptions } from './pg-driver';
96
+ export { postgresDriver, postgresRepo } from './pg-driver';
95
97
  export { decodeRow } from './pg-row';
96
98
  export { postgresTransactor } from './pg-transactor';
97
99
  // The two page bounds, beside `N_PLUS_ONE_THRESHOLD` and for the same reason: an app validating
@@ -0,0 +1,82 @@
1
+ // Single responsibility: an `arrayOf()` cell as the driver handed it back -> the JS array its
2
+ // element column can parse. A driver decides PER TYPE OID and PER PROTOCOL what an array cell is,
3
+ // and the repository may not depend on that table:
4
+ //
5
+ // * `Bun.SQL` parses the array types it knows and hands every other one back as its TEXT
6
+ // literal — `uuid[]` is `'{01a1…,01a2…}'` (measured, Bun 1.4.2 against Postgres 17 and 18),
7
+ // which `arrayOf(uuid())` refused as "expected an array, got a string" on every read while
8
+ // PGlite, which `x dev` and every non-live suite run on, parsed it;
9
+ // * `Bun.SQL` hands `int4[]` back as an `Int32Array` whenever the statement carries a parameter
10
+ // (the extended protocol's binary result) and as an `Array` when it carries none.
11
+ //
12
+ // Neither is decided by a type-parser registration: `Bun.SQL` has none, and a table keyed by oid
13
+ // would miss the next type it does not know — a domain or an extension type has a per-database
14
+ // oid. The DECLARED column is the only thing that knows this cell is an array, so the literal is
15
+ // read here, keyed by the declaration and by nothing the driver says.
16
+
17
+ import { instantMicros } from './instant';
18
+ import { parsePgArray } from './pg-array-literal';
19
+ import type { ColumnKind, ColumnMeta } from './types';
20
+
21
+ const MICROS_PER_MILLI = 1000n;
22
+
23
+ /** Floor, so a pre-1970 instant with a sub-millisecond part lands on the millisecond below it. */
24
+ const millisOf = (micros: bigint): number => {
25
+ const remainder = ((micros % MICROS_PER_MILLI) + MICROS_PER_MILLI) % MICROS_PER_MILLI;
26
+ return Number((micros - remainder) / MICROS_PER_MILLI);
27
+ };
28
+
29
+ const WHOLE = /^-?\d+$/;
30
+
31
+ /**
32
+ * One element's TEXT -> the value a driver that parsed the array would have handed over, which is
33
+ * what each column's `$parse` was written against: a `number` for `integer`, a `boolean` for
34
+ * `boolean`, a `Date` for `timestamptz`, and the text itself for every kind whose row value IS
35
+ * text (`uuid`, `text`, `numeric`, `bigint`, `date`).
36
+ *
37
+ * A text this cannot read is returned AS IT IS, never coerced: the element column refuses it with
38
+ * its own words, where `Number('abc')` would have stored a `NaN` nobody can find again.
39
+ */
40
+ const elementOf = (kind: ColumnKind | undefined, raw: string): unknown => {
41
+ switch (kind) {
42
+ case 'integer':
43
+ return WHOLE.test(raw) ? Number(raw) : raw;
44
+ case 'boolean':
45
+ return raw === 't' || raw === 'true' ? true : raw === 'f' || raw === 'false' ? false : raw;
46
+ case 'timestamptz': {
47
+ // `2026-01-02 03:04:05.123456+00`: Postgres names the offset, so the text is an instant.
48
+ const micros = instantMicros(raw);
49
+ return micros === undefined || WHOLE.test(raw) ? raw : new Date(millisOf(micros));
50
+ }
51
+ default:
52
+ return raw;
53
+ }
54
+ };
55
+
56
+ /** Every integer and float typed array; a `DataView` is bytes with no elements to list. */
57
+ const isTypedArray = (value: unknown): value is ArrayLike<unknown> =>
58
+ ArrayBuffer.isView(value) && !(value instanceof DataView);
59
+
60
+ /** What `arrayFromDriver` answers when the cell is neither an array nor a literal of one. */
61
+ export const NOT_AN_ARRAY = Symbol('not-an-array');
62
+
63
+ /**
64
+ * The cell of a column DECLARED `arrayOf()`, as a plain JS array of driver-shaped elements — or
65
+ * `NOT_AN_ARRAY` when it is none of the three shapes a driver produces. Called for an array column
66
+ * only: a string in a `text()` column is a string, and nothing here ever asks whether it "looks
67
+ * like" a literal.
68
+ *
69
+ * A nested literal (`{{a,b},{c,d}}`) parses to nested arrays and is refused by the element column
70
+ * one line later — `arrayOf()` declares one dimension, so a second one is the table disagreeing
71
+ * with the entity, said by the column that knows its own type.
72
+ */
73
+ export const arrayFromDriver = (
74
+ meta: ColumnMeta,
75
+ value: unknown,
76
+ ): readonly unknown[] | typeof NOT_AN_ARRAY => {
77
+ if (Array.isArray(value)) return value;
78
+ if (isTypedArray(value)) return Array.from(value);
79
+ if (typeof value !== 'string') return NOT_AN_ARRAY;
80
+ const kind = meta.element?.$meta.kind;
81
+ return parsePgArray(value, (raw) => elementOf(kind, raw)) ?? NOT_AN_ARRAY;
82
+ };
@@ -0,0 +1,106 @@
1
+ // Single responsibility: the Postgres array TEXT literal — `{a,b}`, `{"a,b",NULL}`,
2
+ // `{{1,2},{3,4}}` — read into a JS array. The one grammar in the framework: the repository reads a
3
+ // literal a driver left unparsed (`pg-array-decode.ts`) and `@ultimat3/realtime` reads the one the
4
+ // WAL carries, and two readers of one grammar are how a quoted `"NULL"` becomes a null in one of
5
+ // them. The element decoder arrives as a parameter, so this file knows no type.
6
+ //
7
+ // The writer is `arrayLiteral` in `pg-row.ts`; `pg-array-literal.test.ts` holds the two to each
8
+ // other, so what this package writes is what it can read.
9
+
10
+ /** Where the scan is, so every branch below advances one cursor rather than slicing the text. */
11
+ interface Scan {
12
+ readonly text: string;
13
+ at: number;
14
+ }
15
+
16
+ /**
17
+ * One array literal -> a nested JS array, or `null` when the text is not one this grammar
18
+ * describes. `null` is not an error: a dimension prefix (`[0:2]={…}`), a `DateStyle` this decoder
19
+ * does not read, or a corrupted literal all mean the same thing to the caller — keep the text it
20
+ * arrived as rather than deliver an array that is missing a member.
21
+ *
22
+ * `decode` is the ELEMENT decoder. It never sees a quoted element's quotes or its backslash
23
+ * escapes: an unquoted `NULL` is the null value and a quoted `"NULL"` is the four-character
24
+ * string, which is the one distinction the quoting exists to carry.
25
+ */
26
+ export function parsePgArray<T>(text: string, decode: (raw: string) => T): PgArray<T> | null {
27
+ const scan: Scan = { text, at: 0 };
28
+ const parsed = readArray(scan, decode);
29
+ // Trailing content means the literal was not what this grammar accepted, whatever it parsed.
30
+ return parsed === null || scan.at !== text.length ? null : parsed;
31
+ }
32
+
33
+ /** A member is a decoded value, the null member, or — for a multidimensional array — a row of them. */
34
+ export type PgArray<T> = (T | null | PgArray<T>)[];
35
+
36
+ function readArray<T>(scan: Scan, decode: (raw: string) => T): PgArray<T> | null {
37
+ if (scan.text[scan.at] !== '{') return null;
38
+ scan.at += 1;
39
+ const out: PgArray<T> = [];
40
+ if (scan.text[scan.at] === '}') {
41
+ scan.at += 1;
42
+ return out;
43
+ }
44
+ for (;;) {
45
+ const member = readMember(scan, decode);
46
+ if (member === FAILED) return null;
47
+ out.push(member);
48
+ const next = scan.text[scan.at];
49
+ scan.at += 1;
50
+ if (next === '}') return out;
51
+ if (next !== ',') return null;
52
+ }
53
+ }
54
+
55
+ /** A sentinel, because `null` is a legal member and `undefined` would be a second spelling of it. */
56
+ const FAILED = Symbol('pg-array-failed');
57
+
58
+ function readMember<T>(
59
+ scan: Scan,
60
+ decode: (raw: string) => T,
61
+ ): T | null | PgArray<T> | typeof FAILED {
62
+ const head = scan.text[scan.at];
63
+ if (head === '{') {
64
+ const nested = readArray(scan, decode);
65
+ return nested === null ? FAILED : nested;
66
+ }
67
+ if (head === '"') {
68
+ const quoted = readQuoted(scan);
69
+ return quoted === null ? FAILED : decode(quoted);
70
+ }
71
+ const start = scan.at;
72
+ while (scan.at < scan.text.length) {
73
+ const char = scan.text[scan.at];
74
+ if (char === ',' || char === '}') break;
75
+ // A brace or a quote inside a bare element is a literal this grammar does not describe.
76
+ if (char === '{' || char === '"') return FAILED;
77
+ scan.at += 1;
78
+ }
79
+ if (scan.at === scan.text.length) return FAILED;
80
+ const raw = scan.text.slice(start, scan.at);
81
+ // Unquoted and case-insensitive is the ONLY spelling of the null member; `"NULL"` is a string.
82
+ return raw.toUpperCase() === 'NULL' ? null : decode(raw);
83
+ }
84
+
85
+ /** The text between one pair of quotes, with `\\` and `\"` unescaped. `null` if it never closes. */
86
+ function readQuoted(scan: Scan): string | null {
87
+ scan.at += 1;
88
+ let out = '';
89
+ while (scan.at < scan.text.length) {
90
+ const char = scan.text[scan.at];
91
+ if (char === '"') {
92
+ scan.at += 1;
93
+ return out;
94
+ }
95
+ if (char === '\\') {
96
+ const escaped = scan.text[scan.at + 1];
97
+ if (escaped === undefined) return null;
98
+ out += escaped;
99
+ scan.at += 2;
100
+ continue;
101
+ }
102
+ out += char ?? '';
103
+ scan.at += 1;
104
+ }
105
+ return null;
106
+ }
package/src/pg-row.ts CHANGED
@@ -10,6 +10,7 @@ import { kindOf } from './cursor';
10
10
  import type { EntityCore } from './entity';
11
11
  import { invariantViolated } from './errors';
12
12
  import { pgInstantMicros, seekAlias } from './instant';
13
+ import { arrayFromDriver, NOT_AN_ARRAY } from './pg-array-decode';
13
14
  import type { SortKey } from './tenancy';
14
15
  import type { AnyColumn, MoneyValue, RowPatch } from './types';
15
16
 
@@ -168,6 +169,26 @@ const moneyOf = (
168
169
  };
169
170
  };
170
171
 
172
+ /**
173
+ * What a column's `$parse` is handed. Every kind but one takes the driver's value as it is; an
174
+ * `arrayOf()` cell is first made the JS array its declaration says it is, because a driver hands
175
+ * one back three ways (`pg-array-decode.ts`) and only one of them is an `Array`.
176
+ *
177
+ * A cell that is none of the three is the table no longer matching the entity, and it is said
178
+ * here — `arrayOf`'s own refusal is written for a WRITE ("wrap it — [value]"), which instructs
179
+ * nobody about a value Postgres returned.
180
+ */
181
+ const cellOf = (entityName: string, property: string, column: AnyColumn, value: unknown) => {
182
+ if (column.$meta.kind !== 'array') return value;
183
+ const members = arrayFromDriver(column.$meta, value);
184
+ if (members !== NOT_AN_ARRAY) return members;
185
+ throw invariantViolated(
186
+ entityName,
187
+ property,
188
+ `the database returned ${typeof value === 'string' ? `text that is not a Postgres array literal (${value.length} characters)` : `a ${typeof value}`} for an arrayOf() column — the table no longer matches the entity`,
189
+ );
190
+ };
191
+
171
192
  /**
172
193
  * Physical row -> entity row. A column the projection left out is left out here too, so a
173
194
  * `select` narrows the object as well as the statement.
@@ -188,7 +209,10 @@ export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Ro
188
209
  // A sealed column's stored value is not the author's to parse: it goes up as it is, and the
189
210
  // sealing seam opens it — so a row somebody left in the clear is `X_SEAL_INVALID` there
190
211
  // rather than whatever the plaintext parser thinks of its length here.
191
- row[property] = column.$meta.sealed === undefined ? column.$parse(value) : value;
212
+ row[property] =
213
+ column.$meta.sealed === undefined
214
+ ? column.$parse(cellOf(entity.$name, property, column, value))
215
+ : value;
192
216
  continue;
193
217
  }
194
218
  if (column.$meta.notNull) {