@ultimat3/entity 8.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 +16 -0
- package/package.json +5 -5
- package/src/column.ts +6 -5
- package/src/columns-data.ts +62 -23
- package/src/columns.ts +86 -23
- package/src/errors.ts +7 -0
- package/src/expr.ts +15 -6
- 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`.
|
|
@@ -754,6 +769,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
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` |
|
|
756
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 |
|
|
757
773
|
| `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; bind + `toSql()` DDL |
|
|
758
774
|
| `entity.ts` / `describe.ts` | `entity()`, `$row`; the `EntityDescription` projection |
|
|
759
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
|
}
|
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
|
@@ -12,13 +12,9 @@ import { describeValue, formatIssues, type StandardSchemaV1, validate } from '@u
|
|
|
12
12
|
import { isPlainDate, type PlainDate, plainDateUtc } from '@ultimat3/time';
|
|
13
13
|
import { arrayElementRefused, isRefusedElement } from './array-element';
|
|
14
14
|
import { column } from './column';
|
|
15
|
-
import {
|
|
15
|
+
import { refuseColumn } from './refuse';
|
|
16
16
|
import type { AnyColumn, Column, ColumnMeta } from './types';
|
|
17
17
|
|
|
18
|
-
const reject = (rule: string, detail: string): never => {
|
|
19
|
-
throw invariantViolated('column', rule, detail);
|
|
20
|
-
};
|
|
21
|
-
|
|
22
18
|
/** The rejected value as its SHAPE, never its content — `columns.ts` explains why at length. */
|
|
23
19
|
const got = (value: unknown): string => `got ${describeValue(value)}`;
|
|
24
20
|
|
|
@@ -42,9 +38,10 @@ export const json = <T>(schema: StandardSchemaV1<unknown, T>): Column<T> =>
|
|
|
42
38
|
if (result.issues === undefined) return result.value;
|
|
43
39
|
// The ISSUES, never the value: `formatIssues` renders path + message, and a column rejection
|
|
44
40
|
// reaches the caller and the log line where a value has no key left to redact.
|
|
45
|
-
return
|
|
41
|
+
return refuseColumn(
|
|
46
42
|
'json',
|
|
47
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',
|
|
48
45
|
);
|
|
49
46
|
});
|
|
50
47
|
|
|
@@ -67,14 +64,19 @@ export const bigint = (): Column<string> =>
|
|
|
67
64
|
if (typeof value === 'number') {
|
|
68
65
|
return Number.isSafeInteger(value)
|
|
69
66
|
? String(value)
|
|
70
|
-
:
|
|
67
|
+
: refuseColumn(
|
|
71
68
|
'bigint',
|
|
72
|
-
`${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",
|
|
73
71
|
);
|
|
74
72
|
}
|
|
75
73
|
return typeof value === 'string' && DIGITS.test(value)
|
|
76
74
|
? value
|
|
77
|
-
:
|
|
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
|
+
);
|
|
78
80
|
});
|
|
79
81
|
|
|
80
82
|
export interface DecimalOptions {
|
|
@@ -96,14 +98,26 @@ export interface DecimalOptions {
|
|
|
96
98
|
export const decimal = (options: DecimalOptions = {}): Column<string> => {
|
|
97
99
|
const { precision, scale } = options;
|
|
98
100
|
if ((precision === undefined) !== (scale === undefined)) {
|
|
99
|
-
|
|
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
|
+
);
|
|
100
106
|
}
|
|
101
107
|
if (precision !== undefined && scale !== undefined) {
|
|
102
108
|
if (!Number.isInteger(precision) || precision < 1 || precision > 1000) {
|
|
103
|
-
|
|
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
|
+
);
|
|
104
114
|
}
|
|
105
115
|
if (!Number.isInteger(scale) || scale < 0 || scale > precision) {
|
|
106
|
-
|
|
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
|
+
);
|
|
107
121
|
}
|
|
108
122
|
}
|
|
109
123
|
const shape = /^-?\d+(\.\d+)?$/;
|
|
@@ -112,21 +126,28 @@ export const decimal = (options: DecimalOptions = {}): Column<string> => {
|
|
|
112
126
|
(value) => {
|
|
113
127
|
const text = typeof value === 'number' ? decimalOfNumber(value) : value;
|
|
114
128
|
if (typeof text !== 'string' || !shape.test(text)) {
|
|
115
|
-
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
|
+
);
|
|
116
134
|
}
|
|
117
135
|
const digits = text.replace('-', '').split('.');
|
|
118
136
|
const fraction = digits[1]?.length ?? 0;
|
|
119
137
|
if (scale !== undefined && fraction > scale) {
|
|
120
|
-
return
|
|
138
|
+
return refuseColumn(
|
|
121
139
|
'numeric',
|
|
122
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"`,
|
|
123
142
|
);
|
|
124
143
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
);
|
|
130
151
|
}
|
|
131
152
|
return text;
|
|
132
153
|
},
|
|
@@ -157,12 +178,20 @@ export const date = (): Column<PlainDate> =>
|
|
|
157
178
|
column<PlainDate>('date', (value) => {
|
|
158
179
|
if (value instanceof Date) {
|
|
159
180
|
return Number.isNaN(value.getTime())
|
|
160
|
-
?
|
|
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
|
+
)
|
|
161
186
|
: plainDateUtc(value);
|
|
162
187
|
}
|
|
163
188
|
return isPlainDate(value)
|
|
164
189
|
? value
|
|
165
|
-
:
|
|
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
|
+
);
|
|
166
195
|
});
|
|
167
196
|
|
|
168
197
|
/**
|
|
@@ -174,7 +203,11 @@ export const date = (): Column<PlainDate> =>
|
|
|
174
203
|
export const bytes = (): Column<Uint8Array> =>
|
|
175
204
|
column<Uint8Array>('bytea', (value) => {
|
|
176
205
|
if (!(value instanceof Uint8Array)) {
|
|
177
|
-
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
|
+
);
|
|
178
211
|
}
|
|
179
212
|
// Already the plain form: the overwhelmingly common case, and it costs one prototype read.
|
|
180
213
|
return Object.getPrototypeOf(value) === Uint8Array.prototype ? value : new Uint8Array(value);
|
|
@@ -193,7 +226,13 @@ export const arrayOf = <T>(element: Column<T>): Column<readonly T[]> => {
|
|
|
193
226
|
return column<readonly T[]>(
|
|
194
227
|
'array',
|
|
195
228
|
(value) => {
|
|
196
|
-
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
|
+
}
|
|
197
236
|
return value.map((member) => element.$parse(member));
|
|
198
237
|
},
|
|
199
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/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/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
|
+
};
|