@ultimat3/entity
Advanced tools
| // The two refusals raised while a SCHEMA is still being written — a column and an invariant — and | ||
| // why neither goes through `invariantViolated`: that builder's fix is | ||
| // `x entities describe <entityName> --json`, which needs an entity that exists. Passing the | ||
| // literal `'column'` emitted `x entities describe column --json`, which answers | ||
| // `X_DECLARATION_UNKNOWN` — a fix line that raises a second, unrelated error (issue #290). | ||
| // | ||
| // So the fix is a parameter: every caller supplies the EDIT that repairs its own refusal, the | ||
| // shape `arrayElementRefused` (`array-element.ts`) already ships. Two builders rather than one | ||
| // with a `subject` parameter, because `fix-scan.ts` only reads a fix literal at a call site whose | ||
| // callee constructs the error itself — a wrapper delegating to a shared inner one would take all | ||
| // 30 of these fix lines back out of `x verify`'s `errors` step. | ||
| import { EntityError } from './errors'; | ||
| /** | ||
| * A column refusing a value or its own declaration. `column.<rule>` is the cause's subject and is | ||
| * unchanged from what `invariantViolated('column', …)` rendered: the defect was the fix line, and | ||
| * a cause a hundred tests already read is not the place to make a second change. | ||
| */ | ||
| export const refuseColumn = (rule: string, detail: string, fix: string): never => { | ||
| throw new EntityError({ | ||
| code: 'X_INVARIANT_VIOLATED', | ||
| cause: `column.${rule}: ${detail}`, | ||
| fix, | ||
| }); | ||
| }; | ||
| /** | ||
| * An invariant refusing its own declaration, before any entity holds it — `matches(/…/g)` and an | ||
| * `eq` against a column of some other entity's `c`. Same reason as above: `invariantColumns` knows | ||
| * the entity name and passes it, these two are reached from the expression builder, which does not. | ||
| */ | ||
| export const refuseInvariant = (rule: string, detail: string, fix: string): never => { | ||
| throw new EntityError({ | ||
| code: 'X_INVARIANT_VIOLATED', | ||
| cause: `invariant.${rule}: ${detail}`, | ||
| fix, | ||
| }); | ||
| }; |
+5
-5
| { | ||
| "name": "@ultimat3/entity", | ||
| "version": "8.0.0", | ||
| "version": "9.0.0", | ||
| "description": "A table + its domain type + invariants the database also enforces", | ||
@@ -34,7 +34,7 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "8.0.0", | ||
| "@ultimat3/db": "8.0.0", | ||
| "@ultimat3/schema": "8.0.0", | ||
| "@ultimat3/time": "8.0.0" | ||
| "@ultimat3/core": "9.0.0", | ||
| "@ultimat3/db": "9.0.0", | ||
| "@ultimat3/schema": "9.0.0", | ||
| "@ultimat3/time": "9.0.0" | ||
| } | ||
| } |
+6
-5
@@ -10,2 +10,3 @@ // The chain every column builder is made of. Each link returns a new column, so a chain reads | ||
| import { invariantViolated } from './errors'; | ||
| import { refuseColumn } from './refuse'; | ||
| import type { | ||
@@ -144,6 +145,6 @@ AnyColumn, | ||
| } | ||
| throw invariantViolated( | ||
| 'column', | ||
| return refuseColumn( | ||
| 'default', | ||
| `a default must be a literal; got ${typeof value}. For an instant use timestamp().defaultNow()`, | ||
| `a default must be a literal; got ${typeof value}`, | ||
| ".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", | ||
| ); | ||
@@ -210,6 +211,6 @@ }; | ||
| if (!/^[a-z_][a-z0-9_$]*$/.test(name) || name.length > 63) { | ||
| throw invariantViolated( | ||
| 'column', | ||
| refuseColumn( | ||
| 'column-name', | ||
| `"${name}" is not a physical column name: lower-case letters, digits and underscores, at most 63 of them`, | ||
| ".column('created_at') — lower-case letters, digits and underscores, at most 63 of them", | ||
| ); | ||
@@ -216,0 +217,0 @@ } |
+62
-23
@@ -15,9 +15,5 @@ // The column builders an EXISTING schema needs. `columns.ts` holds the opinionated set — one way | ||
| import { column } from './column'; | ||
| import { invariantViolated } from './errors'; | ||
| import { refuseColumn } from './refuse'; | ||
| import type { AnyColumn, Column, ColumnMeta } from './types'; | ||
| const reject = (rule: string, detail: string): never => { | ||
| throw invariantViolated('column', rule, detail); | ||
| }; | ||
| /** The rejected value as its SHAPE, never its content — `columns.ts` explains why at length. */ | ||
@@ -46,5 +42,6 @@ const got = (value: unknown): string => `got ${describeValue(value)}`; | ||
| // reaches the caller and the log line where a value has no key left to redact. | ||
| return reject( | ||
| return refuseColumn( | ||
| 'json', | ||
| `does not match the column's schema — ${formatIssues(result.issues).join('; ')}`, | ||
| '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', | ||
| ); | ||
@@ -72,5 +69,6 @@ }); | ||
| ? String(value) | ||
| : reject( | ||
| : refuseColumn( | ||
| 'bigint', | ||
| `${String(value)} is past ±2^53, where a JS number is no longer exact — pass the digits as a string`, | ||
| `${String(value)} is past ±2^53, where a JS number is no longer exact`, | ||
| "quote the digits — bigint() takes and returns a decimal string, so pass '9007199254740993' rather than a number literal", | ||
| ); | ||
@@ -80,3 +78,7 @@ } | ||
| ? value | ||
| : reject('bigint', `expected whole digits, ${got(value)}`); | ||
| : refuseColumn( | ||
| 'bigint', | ||
| `expected whole digits, ${got(value)}`, | ||
| 'String(value) when it is already whole digits — a fractional value is decimal({ precision: 18, scale: 8 }) and an amount is money()', | ||
| ); | ||
| }); | ||
@@ -103,10 +105,22 @@ | ||
| if ((precision === undefined) !== (scale === undefined)) { | ||
| reject('numeric', 'precision and scale are declared together — numeric(18, 8), or neither'); | ||
| refuseColumn( | ||
| 'numeric', | ||
| 'precision and scale are declared together — numeric(18, 8), or neither', | ||
| 'decimal({ precision: 18, scale: 8 }) — both keys together, or decimal() for an unbounded numeric', | ||
| ); | ||
| } | ||
| if (precision !== undefined && scale !== undefined) { | ||
| if (!Number.isInteger(precision) || precision < 1 || precision > 1000) { | ||
| reject('numeric', `precision must be 1..1000, ${got(precision)}`); | ||
| refuseColumn( | ||
| 'numeric', | ||
| `precision must be 1..1000, ${got(precision)}`, | ||
| 'decimal({ precision: 18, scale: 8 }) — precision is the TOTAL digit count, from 1 to 1000', | ||
| ); | ||
| } | ||
| if (!Number.isInteger(scale) || scale < 0 || scale > precision) { | ||
| reject('numeric', `scale must be 0..precision, ${got(scale)}`); | ||
| refuseColumn( | ||
| 'numeric', | ||
| `scale must be 0..precision, ${got(scale)}`, | ||
| `decimal({ precision: ${precision}, scale: ${Math.min(2, precision)} }) — scale counts the digits AFTER the point and cannot exceed precision`, | ||
| ); | ||
| } | ||
@@ -120,3 +134,7 @@ } | ||
| if (typeof text !== 'string' || !shape.test(text)) { | ||
| return reject('numeric', `expected a decimal number, ${got(value)}`); | ||
| return refuseColumn( | ||
| 'numeric', | ||
| `expected a decimal number, ${got(value)}`, | ||
| "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", | ||
| ); | ||
| } | ||
@@ -126,12 +144,15 @@ const digits = text.replace('-', '').split('.'); | ||
| if (scale !== undefined && fraction > scale) { | ||
| return reject( | ||
| return refuseColumn( | ||
| 'numeric', | ||
| `${text} has ${fraction} decimal places and the column stores ${scale} — Postgres would round it, silently`, | ||
| `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"`, | ||
| ); | ||
| } | ||
| if ( | ||
| precision !== undefined && | ||
| (digits[0] ?? '').replace(/^0+(?=\d)/, '').length > precision - (scale ?? 0) | ||
| ) { | ||
| return reject('numeric', `${text} does not fit numeric(${precision}, ${scale ?? 0})`); | ||
| const whole = (digits[0] ?? '').replace(/^0+(?=\d)/, '').length; | ||
| if (precision !== undefined && whole > precision - (scale ?? 0)) { | ||
| return refuseColumn( | ||
| 'numeric', | ||
| `${text} does not fit numeric(${precision}, ${scale ?? 0})`, | ||
| `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`, | ||
| ); | ||
| } | ||
@@ -167,3 +188,7 @@ return text; | ||
| return Number.isNaN(value.getTime()) | ||
| ? reject('date', `expected a calendar date, ${got(value)}`) | ||
| ? refuseColumn( | ||
| 'date', | ||
| `expected a calendar date, ${got(value)}`, | ||
| "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", | ||
| ) | ||
| : plainDateUtc(value); | ||
@@ -173,3 +198,7 @@ } | ||
| ? value | ||
| : reject('date', `expected a YYYY-MM-DD calendar date, ${got(value)}`); | ||
| : refuseColumn( | ||
| 'date', | ||
| `expected a YYYY-MM-DD calendar date, ${got(value)}`, | ||
| "pass '2026-08-22' or a Date — date() stores a calendar date with no clock and no zone; an instant is timestamp()", | ||
| ); | ||
| }); | ||
@@ -186,3 +215,7 @@ | ||
| if (!(value instanceof Uint8Array)) { | ||
| return reject('bytea', `expected bytes, ${got(value)}`); | ||
| return refuseColumn( | ||
| 'bytea', | ||
| `expected bytes, ${got(value)}`, | ||
| "Buffer.from(value, 'base64') for base64 and new TextEncoder().encode(value) for text — bytes() stores a Uint8Array; a structured payload is json(schema)", | ||
| ); | ||
| } | ||
@@ -206,3 +239,9 @@ // Already the plain form: the overwhelmingly common case, and it costs one prototype read. | ||
| (value) => { | ||
| if (!Array.isArray(value)) return reject('array', `expected an array, ${got(value)}`); | ||
| if (!Array.isArray(value)) { | ||
| return refuseColumn( | ||
| 'array', | ||
| `expected an array, ${got(value)}`, | ||
| 'wrap it — [value] — or drop arrayOf() and declare the element column on its own when the table holds one scalar', | ||
| ); | ||
| } | ||
| return value.map((member) => element.$parse(member)); | ||
@@ -209,0 +248,0 @@ }, |
+86
-23
@@ -22,3 +22,3 @@ // The blessed column builders. There is exactly one way to store an id, an instant, money, a | ||
| } from './column'; | ||
| import { invariantViolated } from './errors'; | ||
| import { refuseColumn } from './refuse'; | ||
| import type { | ||
@@ -35,6 +35,2 @@ Column, | ||
| const reject = (rule: string, detail: string): never => { | ||
| throw invariantViolated('column', rule, detail); | ||
| }; | ||
| /** | ||
@@ -65,3 +61,7 @@ * The rejected value, rendered as its SHAPE and never its content — `@ultimat3/schema`'s | ||
| ? value | ||
| : reject('format', `expected a uuid, ${got(value)}`); | ||
| : refuseColumn( | ||
| 'format', | ||
| `expected a uuid, ${got(value)}`, | ||
| '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()', | ||
| ); | ||
@@ -107,3 +107,9 @@ /** | ||
| (value) => | ||
| typeof value === 'string' ? value : reject('type', `expected a string, ${got(value)}`), | ||
| typeof value === 'string' | ||
| ? value | ||
| : refuseColumn( | ||
| 'type', | ||
| `expected a string, ${got(value)}`, | ||
| '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)', | ||
| ), | ||
| options.max === undefined | ||
@@ -118,3 +124,7 @@ ? {} | ||
| ? value | ||
| : reject('type', `expected a safe integer, ${got(value)}`), | ||
| : refuseColumn( | ||
| 'type', | ||
| `expected a safe integer, ${got(value)}`, | ||
| '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,3 +134,9 @@ | ||
| column<boolean>('boolean', (value) => | ||
| typeof value === 'boolean' ? value : reject('type', `expected a boolean, ${got(value)}`), | ||
| typeof value === 'boolean' | ||
| ? value | ||
| : refuseColumn( | ||
| 'type', | ||
| `expected a boolean, ${got(value)}`, | ||
| "value === 'true' at the call site for a text flag, and boolean().nullable() when the column has a third state", | ||
| ), | ||
| ); | ||
@@ -134,3 +150,7 @@ | ||
| } | ||
| return reject('format', `expected a UTC instant, ${got(value)}`); | ||
| return refuseColumn( | ||
| 'format', | ||
| `expected a UTC instant, ${got(value)}`, | ||
| '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()', | ||
| ); | ||
| }; | ||
@@ -161,3 +181,7 @@ | ||
| ? value | ||
| : reject('enum', `expected one of ${values.join(' | ')}, ${got(value)}`), | ||
| : refuseColumn( | ||
| 'enum', | ||
| `expected one of ${values.join(' | ')}, ${got(value)}`, | ||
| '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', | ||
| ), | ||
| { values, check: oneOf(values) }, | ||
@@ -183,3 +207,7 @@ ); | ||
| } | ||
| return reject('format', `expected an absolute http(s) URL, ${got(value)}`); | ||
| return refuseColumn( | ||
| 'format', | ||
| `expected an absolute http(s) URL, ${got(value)}`, | ||
| 'prefix the value with https:// — url() stores an absolute http(s) URL; a path, a template or a mailto: address is text()', | ||
| ); | ||
| }, | ||
@@ -204,3 +232,9 @@ { check: (name) => `${name} ~ '^https?://'` }, | ||
| for (const zone of zones) { | ||
| if (!isValidTimeZone(zone)) reject('iana-tz', `${zone} is not an IANA time zone`); | ||
| if (!isValidTimeZone(zone)) { | ||
| refuseColumn( | ||
| 'iana-tz', | ||
| `${zone} is not an IANA time zone`, | ||
| "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", | ||
| ); | ||
| } | ||
| } | ||
@@ -213,3 +247,7 @@ const allowed = new Set<string>(zones); | ||
| ? value | ||
| : reject('iana-tz', `expected one of ${zones.join(' | ')}, ${got(value)}`), | ||
| : refuseColumn( | ||
| 'iana-tz', | ||
| `expected one of ${zones.join(' | ')}, ${got(value)}`, | ||
| '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', | ||
| ), | ||
| { values: zones, check: oneOf(zones) }, | ||
@@ -223,3 +261,9 @@ ); | ||
| for (const tag of locales) { | ||
| if (!BCP47.test(tag)) reject('bcp-47', `${tag} is not a BCP-47 language tag`); | ||
| if (!BCP47.test(tag)) { | ||
| refuseColumn( | ||
| 'bcp-47', | ||
| `${tag} is not a BCP-47 language tag`, | ||
| "locale(['en', 'en-GB', 'pt-BR']) — a 2-3 letter language, then optional subtags after a hyphen", | ||
| ); | ||
| } | ||
| } | ||
@@ -232,3 +276,7 @@ const allowed = new Set<string>(locales); | ||
| ? value | ||
| : reject('bcp-47', `expected one of ${locales.join(' | ')}, ${got(value)}`), | ||
| : refuseColumn( | ||
| 'bcp-47', | ||
| `expected one of ${locales.join(' | ')}, ${got(value)}`, | ||
| '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', | ||
| ), | ||
| { values: locales, check: oneOf(locales) }, | ||
@@ -256,15 +304,21 @@ ); | ||
| if (typeof minor !== 'number' || !Number.isFinite(minor)) { | ||
| return reject('money-minor-units', `expected integer minor units, ${got(value)}`); | ||
| return refuseColumn( | ||
| 'money-minor-units', | ||
| `expected integer minor units, ${got(value)}`, | ||
| "pass integer minor units — { minor: 1234, currency: 'EUR' } is 12.34 EUR; a formatted amount is text() and an exact decimal is decimal()", | ||
| ); | ||
| } | ||
| if (!Number.isInteger(minor)) { | ||
| return reject( | ||
| return refuseColumn( | ||
| 'money-minor-units', | ||
| `got the float ${minor}; money is integer minor units — 12.34 EUR is 1234, not 12.34`, | ||
| "{ 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", | ||
| ); | ||
| } | ||
| if (!Number.isSafeInteger(minor)) { | ||
| return reject( | ||
| return refuseColumn( | ||
| 'money-minor-units', | ||
| `${String(value)} is past ±2^53 and no JS number holds it exactly — money is minor units ` + | ||
| 'inside that range; store the overflow in its own column or split the amount', | ||
| 'inside that range', | ||
| "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", | ||
| ); | ||
@@ -284,3 +338,7 @@ } | ||
| ? value | ||
| : reject('iso-4217', `expected a 3-letter ISO-4217 code, ${got(value)}`); | ||
| : refuseColumn( | ||
| 'iso-4217', | ||
| `expected a 3-letter ISO-4217 code, ${got(value)}`, | ||
| "pass money() a 3-letter uppercase ISO-4217 code — { minor: 1234, currency: 'EUR' }; a symbol or a currency name is not one", | ||
| ); | ||
@@ -297,5 +355,6 @@ /** | ||
| ? scale | ||
| : reject( | ||
| : refuseColumn( | ||
| 'money-scale', | ||
| `expected a whole number of decimal places between 0 and ${MAX_MONEY_SCALE}, ${got(value)}`, | ||
| `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 }`, | ||
| ); | ||
@@ -306,3 +365,7 @@ }; | ||
| if (typeof value !== 'object' || value === null) { | ||
| return reject('money', `expected { minor, currency }, ${got(value)}`); | ||
| return refuseColumn( | ||
| 'money', | ||
| `expected { minor, currency }, ${got(value)}`, | ||
| "{ minor: 1234, currency: 'EUR' } — money() is always both parts; a bare amount is integer() or decimal(), and a formatted string is text()", | ||
| ); | ||
| } | ||
@@ -309,0 +372,0 @@ const input: Partial<MoneyInput> = value; |
+7
-0
@@ -116,2 +116,9 @@ // The entity layer's stable error codes. Each factory produces the exact command | ||
| /** | ||
| * The entity name is a VALUE, never a literal — `entity.$name`, `table`, the `name` `entity()` was | ||
| * given. A literal is an entity that does not exist, and this fix then hands the reader | ||
| * `x entities describe column --json`, which answers `X_DECLARATION_UNKNOWN` (issue #290). A | ||
| * refusal raised before any entity exists belongs in `refuse.ts`, where the caller supplies the | ||
| * edit; `refuse.test.ts` fails on a literal here. | ||
| */ | ||
| export const invariantViolated = ( | ||
@@ -118,0 +125,0 @@ entityName: string, |
+15
-6
@@ -12,2 +12,3 @@ // The invariant expression language. One declaration compiles to two enforcement points: a | ||
| import { invariantViolated } from './errors'; | ||
| import { refuseInvariant } from './refuse'; | ||
| import type { ColumnMap } from './types'; | ||
@@ -97,8 +98,9 @@ | ||
| if (flags !== '') { | ||
| throw invariantViolated( | ||
| 'invariant', | ||
| // The pasted predicate drops `g` and `y`: `.test()` under either advances `lastIndex`, so the | ||
| // rule would stop being a function of the row — the same reason the CHECK cannot carry them. | ||
| refuseInvariant( | ||
| 'matches', | ||
| `/${pattern.source}/${pattern.flags} carries the flag${flags.length === 1 ? '' : 's'} ` + | ||
| `"${flags}", which Postgres has no operator for — drop it and fold the behaviour into ` + | ||
| `the pattern, or pass a function instead: matches((value) => /${pattern.source}/${pattern.flags}.test(value)), which is app-only and reports sql: null`, | ||
| `"${flags}", which Postgres has no operator for`, | ||
| `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`, | ||
| ); | ||
@@ -218,4 +220,11 @@ } | ||
| const sameAs = (left: Term, other: ColumnExpr): Expr => { | ||
| const right = terms.get(other); | ||
| if (right === undefined) throw invariantViolated('invariant', 'eq', 'not a column expression'); | ||
| // `??` rather than an `if`: `eq` reaches here only past `isColumnExpr(other)`, which IS | ||
| // `terms.has(other)`, so this refusal is unreachable and exists to narrow `right` off the map. | ||
| const right = | ||
| terms.get(other) ?? | ||
| refuseInvariant( | ||
| 'eq', | ||
| 'not a column expression', | ||
| "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", | ||
| ); | ||
| return check( | ||
@@ -222,0 +231,0 @@ [left.path, right.path], |
Sorry, the diff of this file is too big to display
504341
2.08%42
2.44%7819
2.06%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated