@ultimat3/entity 7.0.0 → 9.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
@@ -742,6 +742,21 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
742
742
  first would turn one statement into two and change what the code under test issues — so a count is
743
743
  reported and a consumer re-reads. It is NOT a second change-feed path: `selectChangeFeed` still
744
744
  decides what a real node reads, and this is never in that decision.
745
+ - **A refusal raised before any entity exists carries an EDIT, never a lookup** — `refuse.ts`,
746
+ `As of 2026-08-22`. Both `reject()` helpers called `invariantViolated('column', rule, detail)`,
747
+ whose fix is `x entities describe <entityName> --json`, so 34 column and invariant refusals
748
+ emitted `x entities describe column --json` — which answers `X_DECLARATION_UNKNOWN`, because no
749
+ entity is named `column` and at declaration time there is no entity at all. A fix line that
750
+ raises a second, unrelated error is worse than none: the reader debugs the wrong subsystem, and
751
+ an agent follows it literally. So the fix is a PARAMETER — `refuseColumn(rule, detail, fix)` —
752
+ and every site names the column form the author should have written, the shape
753
+ `arrayElementRefused` already had. **`invariantViolated`'s entity name is a value, never a
754
+ literal**, and `refuse.test.ts` scans this package's source for one; it also holds every refusal
755
+ to naming a call or a command, carrying no `<placeholder>`, and having a case in its own table,
756
+ so a refusal added without a repair is a failing test. **The two builders construct their
757
+ `EntityError` inline** rather than delegating to a shared one, because `fix-scan.ts` reads a fix
758
+ literal only at a call site whose callee builds the error itself — a wrapper would take all 34
759
+ fix lines back out of `x verify`'s `errors` step (measured: `checked` 1040 -> 1071).
745
760
  - Never throw a bare `Error` — use `errors.ts`.
746
761
  - Tests restore the process-global registry in `afterAll` (`clearRegistry()`): a leaked registry
747
762
  breaks an unrelated package's tests, as it did in `@ultimat3/policy`.
@@ -753,6 +768,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
753
768
  | `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
769
  | `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
770
  | `columns-data.ts` | the wide vocabulary an existing schema needs: `json`, `decimal`, `date`, `bigint`, `bytes`, `arrayOf` |
771
+ | `array-element.ts` | which element kinds `arrayOf()` refuses, and the one-line edit that repairs each |
772
+ | `refuse.ts` | `refuseColumn`/`refuseInvariant` — the refusals raised before any entity exists, each carrying the EDIT that repairs it |
756
773
  | `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; bind + `toSql()` DDL |
757
774
  | `entity.ts` / `describe.ts` | `entity()`, `$row`; the `EntityDescription` projection |
758
775
  | `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": "7.0.0",
3
+ "version": "9.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": "7.0.0",
35
- "@ultimat3/db": "7.0.0",
36
- "@ultimat3/schema": "7.0.0",
37
- "@ultimat3/time": "7.0.0"
34
+ "@ultimat3/core": "9.0.0",
35
+ "@ultimat3/db": "9.0.0",
36
+ "@ultimat3/schema": "9.0.0",
37
+ "@ultimat3/time": "9.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/column.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  // physical name even though two schema modules import each other in a cycle.
8
8
 
9
9
  import { invariantViolated } from './errors';
10
+ import { refuseColumn } from './refuse';
10
11
  import type {
11
12
  AnyColumn,
12
13
  Column,
@@ -140,10 +141,10 @@ const literal = (value: unknown): ColumnDefault => {
140
141
  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
141
142
  return { kind: 'value', value };
142
143
  }
143
- throw invariantViolated(
144
- 'column',
144
+ return refuseColumn(
145
145
  'default',
146
- `a default must be a literal; got ${typeof value}. For an instant use timestamp().defaultNow()`,
146
+ `a default must be a literal; got ${typeof value}`,
147
+ ".default('draft'), .default(0) or .default(false) — a literal the DDL can carry; for an instant use timestamp().defaultNow(), and a computed default belongs in the insert",
147
148
  );
148
149
  };
149
150
 
@@ -205,10 +206,10 @@ export const makeColumn = <T, Optional extends boolean>(
205
206
  */
206
207
  export const assertColumnName = (name: string): string => {
207
208
  if (!/^[a-z_][a-z0-9_$]*$/.test(name) || name.length > 63) {
208
- throw invariantViolated(
209
- 'column',
209
+ refuseColumn(
210
210
  'column-name',
211
211
  `"${name}" is not a physical column name: lower-case letters, digits and underscores, at most 63 of them`,
212
+ ".column('created_at') — lower-case letters, digits and underscores, at most 63 of them",
212
213
  );
213
214
  }
214
215
  return name;
@@ -10,14 +10,11 @@
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
- import { invariantViolated } from './errors';
15
+ import { refuseColumn } from './refuse';
15
16
  import type { AnyColumn, Column, ColumnMeta } from './types';
16
17
 
17
- const reject = (rule: string, detail: string): never => {
18
- throw invariantViolated('column', rule, detail);
19
- };
20
-
21
18
  /** The rejected value as its SHAPE, never its content — `columns.ts` explains why at length. */
22
19
  const got = (value: unknown): string => `got ${describeValue(value)}`;
23
20
 
@@ -27,9 +24,13 @@ const got = (value: unknown): string => `got ${describeValue(value)}`;
27
24
  * place for one — the value arrives from the DATABASE as often as from a caller, so the row type
28
25
  * would be a claim nothing ever checked.
29
26
  *
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.
27
+ * The value crosses to Postgres as TEXT and is cast back `bindValues` calls `JSON.stringify` and
28
+ * `cellCast` (`pg-sql.ts`) writes `::text::jsonb` and both halves are load-bearing. The driver
29
+ * seam refuses a plain object as a parameter (`X_SQL_UNSAFE`), so the object cannot cross as
30
+ * itself; and under a bare `$1::jsonb` the server describes the parameter as `jsonb`, Bun's `sql`
31
+ * JSON-ENCODES the string it was handed, and `{"a":1}` lands as a JSON *string* — `jsonb_typeof`
32
+ * answers `string` (measured, Postgres 17.10). Pinning the parameter to `text` first is what makes
33
+ * the server parse the characters, so neither half may be changed without the other.
33
34
  */
34
35
  export const json = <T>(schema: StandardSchemaV1<unknown, T>): Column<T> =>
35
36
  column<T>('jsonb', (value) => {
@@ -37,9 +38,10 @@ export const json = <T>(schema: StandardSchemaV1<unknown, T>): Column<T> =>
37
38
  if (result.issues === undefined) return result.value;
38
39
  // The ISSUES, never the value: `formatIssues` renders path + message, and a column rejection
39
40
  // reaches the caller and the log line where a value has no key left to redact.
40
- return reject(
41
+ return refuseColumn(
41
42
  'json',
42
43
  `does not match the column's schema — ${formatIssues(result.issues).join('; ')}`,
44
+ 'correct the key the cause names, or widen the schema this column was declared with — json(t.object({ seats: t.number })) validates on the way in and on the way back',
43
45
  );
44
46
  });
45
47
 
@@ -62,14 +64,19 @@ export const bigint = (): Column<string> =>
62
64
  if (typeof value === 'number') {
63
65
  return Number.isSafeInteger(value)
64
66
  ? String(value)
65
- : reject(
67
+ : refuseColumn(
66
68
  'bigint',
67
- `${String(value)} is past ±2^53, where a JS number is no longer exact — pass the digits as a string`,
69
+ `${String(value)} is past ±2^53, where a JS number is no longer exact`,
70
+ "quote the digits — bigint() takes and returns a decimal string, so pass '9007199254740993' rather than a number literal",
68
71
  );
69
72
  }
70
73
  return typeof value === 'string' && DIGITS.test(value)
71
74
  ? value
72
- : reject('bigint', `expected whole digits, ${got(value)}`);
75
+ : refuseColumn(
76
+ 'bigint',
77
+ `expected whole digits, ${got(value)}`,
78
+ 'String(value) when it is already whole digits — a fractional value is decimal({ precision: 18, scale: 8 }) and an amount is money()',
79
+ );
73
80
  });
74
81
 
75
82
  export interface DecimalOptions {
@@ -91,14 +98,26 @@ export interface DecimalOptions {
91
98
  export const decimal = (options: DecimalOptions = {}): Column<string> => {
92
99
  const { precision, scale } = options;
93
100
  if ((precision === undefined) !== (scale === undefined)) {
94
- reject('numeric', 'precision and scale are declared together — numeric(18, 8), or neither');
101
+ refuseColumn(
102
+ 'numeric',
103
+ 'precision and scale are declared together — numeric(18, 8), or neither',
104
+ 'decimal({ precision: 18, scale: 8 }) — both keys together, or decimal() for an unbounded numeric',
105
+ );
95
106
  }
96
107
  if (precision !== undefined && scale !== undefined) {
97
108
  if (!Number.isInteger(precision) || precision < 1 || precision > 1000) {
98
- reject('numeric', `precision must be 1..1000, ${got(precision)}`);
109
+ refuseColumn(
110
+ 'numeric',
111
+ `precision must be 1..1000, ${got(precision)}`,
112
+ 'decimal({ precision: 18, scale: 8 }) — precision is the TOTAL digit count, from 1 to 1000',
113
+ );
99
114
  }
100
115
  if (!Number.isInteger(scale) || scale < 0 || scale > precision) {
101
- reject('numeric', `scale must be 0..precision, ${got(scale)}`);
116
+ refuseColumn(
117
+ 'numeric',
118
+ `scale must be 0..precision, ${got(scale)}`,
119
+ `decimal({ precision: ${precision}, scale: ${Math.min(2, precision)} }) — scale counts the digits AFTER the point and cannot exceed precision`,
120
+ );
102
121
  }
103
122
  }
104
123
  const shape = /^-?\d+(\.\d+)?$/;
@@ -107,21 +126,28 @@ export const decimal = (options: DecimalOptions = {}): Column<string> => {
107
126
  (value) => {
108
127
  const text = typeof value === 'number' ? decimalOfNumber(value) : value;
109
128
  if (typeof text !== 'string' || !shape.test(text)) {
110
- return reject('numeric', `expected a decimal number, ${got(value)}`);
129
+ return refuseColumn(
130
+ 'numeric',
131
+ `expected a decimal number, ${got(value)}`,
132
+ "pass the digits as a string — decimal() holds an exact decimal, so write '1.25'; a float is taken only where String(value) is already exact",
133
+ );
111
134
  }
112
135
  const digits = text.replace('-', '').split('.');
113
136
  const fraction = digits[1]?.length ?? 0;
114
137
  if (scale !== undefined && fraction > scale) {
115
- return reject(
138
+ return refuseColumn(
116
139
  'numeric',
117
140
  `${text} has ${fraction} decimal places and the column stores ${scale} — Postgres would round it, silently`,
141
+ `Number(value).toFixed(${scale}) at the call site decides the rounding, or widen the column to decimal({ precision: ${(precision ?? fraction) + fraction - scale}, scale: ${fraction} }) and run x db gen "widen the numeric"`,
118
142
  );
119
143
  }
120
- if (
121
- precision !== undefined &&
122
- (digits[0] ?? '').replace(/^0+(?=\d)/, '').length > precision - (scale ?? 0)
123
- ) {
124
- return reject('numeric', `${text} does not fit numeric(${precision}, ${scale ?? 0})`);
144
+ const whole = (digits[0] ?? '').replace(/^0+(?=\d)/, '').length;
145
+ if (precision !== undefined && whole > precision - (scale ?? 0)) {
146
+ return refuseColumn(
147
+ 'numeric',
148
+ `${text} does not fit numeric(${precision}, ${scale ?? 0})`,
149
+ `widen the column — decimal({ precision: ${whole + (scale ?? 0)}, scale: ${scale ?? 0} }) — and run x db gen "widen the numeric": what overflows is the digits BEFORE the point`,
150
+ );
125
151
  }
126
152
  return text;
127
153
  },
@@ -152,12 +178,20 @@ export const date = (): Column<PlainDate> =>
152
178
  column<PlainDate>('date', (value) => {
153
179
  if (value instanceof Date) {
154
180
  return Number.isNaN(value.getTime())
155
- ? reject('date', `expected a calendar date, ${got(value)}`)
181
+ ? refuseColumn(
182
+ 'date',
183
+ `expected a calendar date, ${got(value)}`,
184
+ "pass a Date built from a real value — new Date('2026-08-22'); new Date(undefined) and a failed parse both produce the Invalid Date this refuses",
185
+ )
156
186
  : plainDateUtc(value);
157
187
  }
158
188
  return isPlainDate(value)
159
189
  ? value
160
- : reject('date', `expected a YYYY-MM-DD calendar date, ${got(value)}`);
190
+ : refuseColumn(
191
+ 'date',
192
+ `expected a YYYY-MM-DD calendar date, ${got(value)}`,
193
+ "pass '2026-08-22' or a Date — date() stores a calendar date with no clock and no zone; an instant is timestamp()",
194
+ );
161
195
  });
162
196
 
163
197
  /**
@@ -169,7 +203,11 @@ export const date = (): Column<PlainDate> =>
169
203
  export const bytes = (): Column<Uint8Array> =>
170
204
  column<Uint8Array>('bytea', (value) => {
171
205
  if (!(value instanceof Uint8Array)) {
172
- return reject('bytea', `expected bytes, ${got(value)}`);
206
+ return refuseColumn(
207
+ 'bytea',
208
+ `expected bytes, ${got(value)}`,
209
+ "Buffer.from(value, 'base64') for base64 and new TextEncoder().encode(value) for text — bytes() stores a Uint8Array; a structured payload is json(schema)",
210
+ );
173
211
  }
174
212
  // Already the plain form: the overwhelmingly common case, and it costs one prototype read.
175
213
  return Object.getPrototypeOf(value) === Uint8Array.prototype ? value : new Uint8Array(value);
@@ -180,21 +218,21 @@ export const bytes = (): Column<Uint8Array> =>
180
218
  * `$parse` decides every member: `arrayOf(text({ max: 40 }))` refuses a 41-character tag exactly
181
219
  * where a `text()` column would.
182
220
  *
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.
221
+ * Four element kinds are refused rather than approximated see `arrayElementRefused`.
185
222
  */
186
223
  export const arrayOf = <T>(element: Column<T>): Column<readonly T[]> => {
187
224
  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
- }
225
+ if (isRefusedElement(kind)) throw arrayElementRefused(kind);
194
226
  return column<readonly T[]>(
195
227
  'array',
196
228
  (value) => {
197
- if (!Array.isArray(value)) return reject('array', `expected an array, ${got(value)}`);
229
+ if (!Array.isArray(value)) {
230
+ return refuseColumn(
231
+ 'array',
232
+ `expected an array, ${got(value)}`,
233
+ 'wrap it — [value] — or drop arrayOf() and declare the element column on its own when the table holds one scalar',
234
+ );
235
+ }
198
236
  return value.map((member) => element.$parse(member));
199
237
  },
200
238
  { element: element as AnyColumn },
package/src/columns.ts CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  makeColumn,
20
20
  makeTimestamp,
21
21
  } from './column';
22
- import { invariantViolated } from './errors';
22
+ import { refuseColumn } from './refuse';
23
23
  import type {
24
24
  Column,
25
25
  ColumnMap,
@@ -31,10 +31,6 @@ import type {
31
31
  UuidColumn,
32
32
  } from './types';
33
33
 
34
- const reject = (rule: string, detail: string): never => {
35
- throw invariantViolated('column', rule, detail);
36
- };
37
-
38
34
  /**
39
35
  * The rejected value, rendered as its SHAPE and never its content — `@ultimat3/schema`'s
40
36
  * `describeValue`, the same renderer every builtin validator fails through, so a column and a
@@ -60,7 +56,11 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
60
56
  const parseUuid = (value: unknown): string =>
61
57
  typeof value === 'string' && UUID.test(value)
62
58
  ? value
63
- : reject('format', `expected a uuid, ${got(value)}`);
59
+ : refuseColumn(
60
+ 'format',
61
+ `expected a uuid, ${got(value)}`,
62
+ 'newId() mints a uuid v7, and a reference carries the exact id the target row was inserted with — a natural key that is not a uuid is text(), a legacy int8 key is bigint()',
63
+ );
64
64
 
65
65
  /**
66
66
  * The one place a brand is applied. A brand is a compile-time tag with no runtime witness, so
@@ -101,7 +101,13 @@ export const text = (options: TextOptions = {}): Column<string> =>
101
101
  column<string>(
102
102
  'text',
103
103
  (value) =>
104
- typeof value === 'string' ? value : reject('type', `expected a string, ${got(value)}`),
104
+ typeof value === 'string'
105
+ ? value
106
+ : refuseColumn(
107
+ 'type',
108
+ `expected a string, ${got(value)}`,
109
+ 'String(value) at the call site when this really is text — a number column is integer(), an exact decimal is decimal(), a structured payload is json(schema)',
110
+ ),
105
111
  options.max === undefined
106
112
  ? {}
107
113
  : { length: options.max, check: (name) => `char_length(${name}) <= ${options.max}` },
@@ -111,12 +117,22 @@ export const integer = (): Column<number> =>
111
117
  column<number>('integer', (value) =>
112
118
  typeof value === 'number' && Number.isSafeInteger(value)
113
119
  ? value
114
- : reject('type', `expected a safe integer, ${got(value)}`),
120
+ : refuseColumn(
121
+ 'type',
122
+ `expected a safe integer, ${got(value)}`,
123
+ 'Math.trunc(value) for a float and Number(value) for a numeric string — a count past ±2^53 is bigint(), a fractional value is decimal()',
124
+ ),
115
125
  );
116
126
 
117
127
  export const boolean = (): Column<boolean> =>
118
128
  column<boolean>('boolean', (value) =>
119
- typeof value === 'boolean' ? value : reject('type', `expected a boolean, ${got(value)}`),
129
+ typeof value === 'boolean'
130
+ ? value
131
+ : refuseColumn(
132
+ 'type',
133
+ `expected a boolean, ${got(value)}`,
134
+ "value === 'true' at the call site for a text flag, and boolean().nullable() when the column has a third state",
135
+ ),
120
136
  );
121
137
 
122
138
  const parseInstant = (value: unknown): Date => {
@@ -125,7 +141,11 @@ const parseInstant = (value: unknown): Date => {
125
141
  const parsed = new Date(value);
126
142
  if (!Number.isNaN(parsed.getTime())) return parsed;
127
143
  }
128
- return reject('format', `expected a UTC instant, ${got(value)}`);
144
+ return refuseColumn(
145
+ 'format',
146
+ `expected a UTC instant, ${got(value)}`,
147
+ 'new Date(value) at the call site — timestamp() stores an instant; a calendar date with no clock is date(), and an elapsed span is integer()',
148
+ );
129
149
  };
130
150
 
131
151
  /** Always `timestamptz`. UTC storage is not a per-table decision. */
@@ -151,7 +171,11 @@ export const enumerated = <const V extends readonly string[]>(values: V): Column
151
171
  (value) =>
152
172
  typeof value === 'string' && allowed.has(value)
153
173
  ? value
154
- : reject('enum', `expected one of ${values.join(' | ')}, ${got(value)}`),
174
+ : refuseColumn(
175
+ 'enum',
176
+ `expected one of ${values.join(' | ')}, ${got(value)}`,
177
+ 'store one of the values enumerated() declares, or add the new variant to that list and run x db gen "extend the enum check" — the values are a CHECK constraint, so the table moves with them',
178
+ ),
155
179
  { values, check: oneOf(values) },
156
180
  );
157
181
  };
@@ -172,7 +196,11 @@ export const url = (): Column<string> =>
172
196
  // fall through to the shared rejection so the error names the rule
173
197
  }
174
198
  }
175
- return reject('format', `expected an absolute http(s) URL, ${got(value)}`);
199
+ return refuseColumn(
200
+ 'format',
201
+ `expected an absolute http(s) URL, ${got(value)}`,
202
+ 'prefix the value with https:// — url() stores an absolute http(s) URL; a path, a template or a mailto: address is text()',
203
+ );
176
204
  },
177
205
  { check: (name) => `${name} ~ '^https?://'` },
178
206
  );
@@ -192,7 +220,13 @@ export const url = (): Column<string> =>
192
220
  */
193
221
  export const tz = <const Z extends readonly string[]>(zones: Z): Column<Z[number]> => {
194
222
  for (const zone of zones) {
195
- if (!isValidTimeZone(zone)) reject('iana-tz', `${zone} is not an IANA time zone`);
223
+ if (!isValidTimeZone(zone)) {
224
+ refuseColumn(
225
+ 'iana-tz',
226
+ `${zone} is not an IANA time zone`,
227
+ "tz(['Europe/Bucharest']) — an IANA region/city name. An abbreviation (CET, EST) or an offset (+02:00) carries no DST rule; Intl.supportedValuesOf('timeZone') lists every name this accepts",
228
+ );
229
+ }
196
230
  }
197
231
  const allowed = new Set<string>(zones);
198
232
  return column<Z[number]>(
@@ -200,7 +234,11 @@ export const tz = <const Z extends readonly string[]>(zones: Z): Column<Z[number
200
234
  (value) =>
201
235
  typeof value === 'string' && allowed.has(value)
202
236
  ? value
203
- : reject('iana-tz', `expected one of ${zones.join(' | ')}, ${got(value)}`),
237
+ : refuseColumn(
238
+ 'iana-tz',
239
+ `expected one of ${zones.join(' | ')}, ${got(value)}`,
240
+ 'store one of the zones tz() declares, or add it to that list and run x db gen "extend the time zone check" — the zones are a CHECK constraint',
241
+ ),
204
242
  { values: zones, check: oneOf(zones) },
205
243
  );
206
244
  };
@@ -209,7 +247,13 @@ const BCP47 = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
209
247
 
210
248
  export const locale = <const L extends readonly string[]>(locales: L): Column<L[number]> => {
211
249
  for (const tag of locales) {
212
- if (!BCP47.test(tag)) reject('bcp-47', `${tag} is not a BCP-47 language tag`);
250
+ if (!BCP47.test(tag)) {
251
+ refuseColumn(
252
+ 'bcp-47',
253
+ `${tag} is not a BCP-47 language tag`,
254
+ "locale(['en', 'en-GB', 'pt-BR']) — a 2-3 letter language, then optional subtags after a hyphen",
255
+ );
256
+ }
213
257
  }
214
258
  const allowed = new Set<string>(locales);
215
259
  return column<L[number]>(
@@ -217,7 +261,11 @@ export const locale = <const L extends readonly string[]>(locales: L): Column<L[
217
261
  (value) =>
218
262
  typeof value === 'string' && allowed.has(value)
219
263
  ? value
220
- : reject('bcp-47', `expected one of ${locales.join(' | ')}, ${got(value)}`),
264
+ : refuseColumn(
265
+ 'bcp-47',
266
+ `expected one of ${locales.join(' | ')}, ${got(value)}`,
267
+ 'store one of the tags locale() declares, or add it to that list and run x db gen "extend the locale check" — the tags are a CHECK constraint',
268
+ ),
221
269
  { values: locales, check: oneOf(locales) },
222
270
  );
223
271
  };
@@ -240,19 +288,25 @@ const parseMinor = (value: unknown): number => {
240
288
  ? Number(value)
241
289
  : value;
242
290
  if (typeof minor !== 'number' || !Number.isFinite(minor)) {
243
- return reject('money-minor-units', `expected integer minor units, ${got(value)}`);
291
+ return refuseColumn(
292
+ 'money-minor-units',
293
+ `expected integer minor units, ${got(value)}`,
294
+ "pass integer minor units — { minor: 1234, currency: 'EUR' } is 12.34 EUR; a formatted amount is text() and an exact decimal is decimal()",
295
+ );
244
296
  }
245
297
  if (!Number.isInteger(minor)) {
246
- return reject(
298
+ return refuseColumn(
247
299
  'money-minor-units',
248
300
  `got the float ${minor}; money is integer minor units — 12.34 EUR is 1234, not 12.34`,
301
+ "{ minor: Math.round(amount * 100), currency: 'EUR' } at the call site converts the major-unit amount and decides the rounding, or name the precision instead: { minor: 1250000, currency: 'EUR', scale: 6 } is 1.25 EUR at six decimal places",
249
302
  );
250
303
  }
251
304
  if (!Number.isSafeInteger(minor)) {
252
- return reject(
305
+ return refuseColumn(
253
306
  'money-minor-units',
254
307
  `${String(value)} is past ±2^53 and no JS number holds it exactly — money is minor units ` +
255
- 'inside that range; store the overflow in its own column or split the amount',
308
+ 'inside that range',
309
+ "split the amount across rows, or hold the digits beside it in a bigint() column — money()'s minor is a number so JSON.stringify carries it, and no JS number holds this one exactly",
256
310
  );
257
311
  }
258
312
  return minor;
@@ -267,7 +321,11 @@ const parseMinor = (value: unknown): number => {
267
321
  const parseCurrency = (value: unknown): string =>
268
322
  isCurrencyCode(value)
269
323
  ? value
270
- : reject('iso-4217', `expected a 3-letter ISO-4217 code, ${got(value)}`);
324
+ : refuseColumn(
325
+ 'iso-4217',
326
+ `expected a 3-letter ISO-4217 code, ${got(value)}`,
327
+ "pass money() a 3-letter uppercase ISO-4217 code — { minor: 1234, currency: 'EUR' }; a symbol or a currency name is not one",
328
+ );
271
329
 
272
330
  /**
273
331
  * The decimal exponent `minor` counts in, when it is not the currency's own. `undefined` and `0`
@@ -279,15 +337,20 @@ const parseScale = (value: unknown): number => {
279
337
  const scale = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
280
338
  return isMoneyScale(scale)
281
339
  ? scale
282
- : reject(
340
+ : refuseColumn(
283
341
  'money-scale',
284
342
  `expected a whole number of decimal places between 0 and ${MAX_MONEY_SCALE}, ${got(value)}`,
343
+ `omit scale for the currency's own minor unit, or pass money() a whole number of decimal places from 0 to ${MAX_MONEY_SCALE} — { minor: 1250000, currency: 'EUR', scale: 6 }`,
285
344
  );
286
345
  };
287
346
 
288
347
  const parseMoney = (value: unknown): MoneyValue => {
289
348
  if (typeof value !== 'object' || value === null) {
290
- return reject('money', `expected { minor, currency }, ${got(value)}`);
349
+ return refuseColumn(
350
+ 'money',
351
+ `expected { minor, currency }, ${got(value)}`,
352
+ "{ minor: 1234, currency: 'EUR' } — money() is always both parts; a bare amount is integer() or decimal(), and a formatted string is text()",
353
+ );
291
354
  }
292
355
  const input: Partial<MoneyInput> = value;
293
356
  return {
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;
package/src/errors.ts CHANGED
@@ -113,6 +113,13 @@ export const entityDuplicate = (name: string, existingTable: string): EntityErro
113
113
  fix: `x entities list --json # then rename one of the two entity({ name }) declarations`,
114
114
  });
115
115
 
116
+ /**
117
+ * The entity name is a VALUE, never a literal — `entity.$name`, `table`, the `name` `entity()` was
118
+ * given. A literal is an entity that does not exist, and this fix then hands the reader
119
+ * `x entities describe column --json`, which answers `X_DECLARATION_UNKNOWN` (issue #290). A
120
+ * refusal raised before any entity exists belongs in `refuse.ts`, where the caller supplies the
121
+ * edit; `refuse.test.ts` fails on a literal here.
122
+ */
116
123
  export const invariantViolated = (
117
124
  entityName: string,
118
125
  invariantName: string,
package/src/expr.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  // does not know this rule — silently pretending it reached Postgres would be worse.
10
10
 
11
11
  import { invariantViolated } from './errors';
12
+ import { refuseInvariant } from './refuse';
12
13
  import type { ColumnMap } from './types';
13
14
 
14
15
  export type Row = Readonly<Record<string, unknown>>;
@@ -93,12 +94,13 @@ const literal = (value: unknown): string =>
93
94
  const matchOperator = (pattern: RegExp): string => {
94
95
  const flags = pattern.flags.replaceAll('i', '');
95
96
  if (flags !== '') {
96
- throw invariantViolated(
97
- 'invariant',
97
+ // The pasted predicate drops `g` and `y`: `.test()` under either advances `lastIndex`, so the
98
+ // rule would stop being a function of the row — the same reason the CHECK cannot carry them.
99
+ refuseInvariant(
98
100
  'matches',
99
101
  `/${pattern.source}/${pattern.flags} carries the flag${flags.length === 1 ? '' : 's'} ` +
100
- `"${flags}", which Postgres has no operator for — drop it and fold the behaviour into ` +
101
- `the pattern, or pass a function instead: matches((value) => /${pattern.source}/${pattern.flags}.test(value)), which is app-only and reports sql: null`,
102
+ `"${flags}", which Postgres has no operator for`,
103
+ `drop the flag and fold the behaviour into the pattern, or pass a predicate instead: matches((value) => /${pattern.source}/${pattern.flags.replaceAll(/[gy]/g, '')}.test(value)) app-only, and it reports sql: null. Never g or y in that predicate: .test() advances lastIndex, so one row's verdict depends on the row before it`,
102
104
  );
103
105
  }
104
106
  return pattern.ignoreCase ? '~*' : '~';
@@ -213,8 +215,15 @@ const part = (term: Term, key: string): ColumnExpr =>
213
215
  });
214
216
 
215
217
  const sameAs = (left: Term, other: ColumnExpr): Expr => {
216
- const right = terms.get(other);
217
- if (right === undefined) throw invariantViolated('invariant', 'eq', 'not a column expression');
218
+ // `??` rather than an `if`: `eq` reaches here only past `isColumnExpr(other)`, which IS
219
+ // `terms.has(other)`, so this refusal is unreachable and exists to narrow `right` off the map.
220
+ const right =
221
+ terms.get(other) ??
222
+ refuseInvariant(
223
+ 'eq',
224
+ 'not a column expression',
225
+ "pass a column of the same c — c.total.eq(c.subtotal) — or compare against a value: c.total.eq(0). A column of another entity cannot appear in this table's CHECK",
226
+ );
218
227
  return check(
219
228
  [left.path, right.path],
220
229
  `${left.label} must equal ${right.label}`,
@@ -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';
package/src/refuse.ts ADDED
@@ -0,0 +1,39 @@
1
+ // The two refusals raised while a SCHEMA is still being written — a column and an invariant — and
2
+ // why neither goes through `invariantViolated`: that builder's fix is
3
+ // `x entities describe <entityName> --json`, which needs an entity that exists. Passing the
4
+ // literal `'column'` emitted `x entities describe column --json`, which answers
5
+ // `X_DECLARATION_UNKNOWN` — a fix line that raises a second, unrelated error (issue #290).
6
+ //
7
+ // So the fix is a parameter: every caller supplies the EDIT that repairs its own refusal, the
8
+ // shape `arrayElementRefused` (`array-element.ts`) already ships. Two builders rather than one
9
+ // with a `subject` parameter, because `fix-scan.ts` only reads a fix literal at a call site whose
10
+ // callee constructs the error itself — a wrapper delegating to a shared inner one would take all
11
+ // 30 of these fix lines back out of `x verify`'s `errors` step.
12
+
13
+ import { EntityError } from './errors';
14
+
15
+ /**
16
+ * A column refusing a value or its own declaration. `column.<rule>` is the cause's subject and is
17
+ * unchanged from what `invariantViolated('column', …)` rendered: the defect was the fix line, and
18
+ * a cause a hundred tests already read is not the place to make a second change.
19
+ */
20
+ export const refuseColumn = (rule: string, detail: string, fix: string): never => {
21
+ throw new EntityError({
22
+ code: 'X_INVARIANT_VIOLATED',
23
+ cause: `column.${rule}: ${detail}`,
24
+ fix,
25
+ });
26
+ };
27
+
28
+ /**
29
+ * An invariant refusing its own declaration, before any entity holds it — `matches(/…/g)` and an
30
+ * `eq` against a column of some other entity's `c`. Same reason as above: `invariantColumns` knows
31
+ * the entity name and passes it, these two are reached from the expression builder, which does not.
32
+ */
33
+ export const refuseInvariant = (rule: string, detail: string, fix: string): never => {
34
+ throw new EntityError({
35
+ code: 'X_INVARIANT_VIOLATED',
36
+ cause: `invariant.${rule}: ${detail}`,
37
+ fix,
38
+ });
39
+ };