@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 +17 -0
- package/package.json +5 -5
- package/src/array-element.ts +67 -0
- package/src/column.ts +6 -5
- package/src/columns-data.ts +72 -34
- package/src/columns.ts +86 -23
- package/src/describe.ts +7 -3
- package/src/errors.ts +7 -0
- package/src/expr.ts +15 -6
- package/src/memory-match.ts +17 -7
- package/src/pg-row.ts +6 -0
- package/src/refuse.ts +39 -0
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": "
|
|
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": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/time": "
|
|
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
|
-
|
|
144
|
-
'column',
|
|
144
|
+
return refuseColumn(
|
|
145
145
|
'default',
|
|
146
|
-
`a default must be a literal; got ${typeof value}
|
|
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
|
-
|
|
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;
|
package/src/columns-data.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
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
|
-
:
|
|
67
|
+
: refuseColumn(
|
|
66
68
|
'bigint',
|
|
67
|
-
`${String(value)} is past ±2^53, where a JS number is no longer exact
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
?
|
|
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
|
-
:
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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))
|
|
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 {
|
|
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
|
-
:
|
|
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'
|
|
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
|
-
:
|
|
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'
|
|
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
|
|
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
|
-
:
|
|
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
|
|
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))
|
|
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
|
-
:
|
|
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))
|
|
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
|
-
:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
|
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
|
-
//
|
|
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/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
|
-
|
|
97
|
-
|
|
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
|
|
101
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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}`,
|
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';
|
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
|
+
};
|