New:Socket for Asana Is Now Available.Learn more
Sign In

@ultimat3/entity

Package Overview
Dependencies
Maintainers
1
Versions
24
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/entity - npm Package Compare versions

Comparing version
8.0.0
to
9.0.0
+39
src/refuse.ts
// 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"
}
}

@@ -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 @@ }

@@ -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 @@ },

@@ -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;

@@ -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,

@@ -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