@ultimat3/entity
Advanced tools
| // Single responsibility: what this package means by NULL when it looks at a row. One rule, because | ||
| // three files had their own copy of it — `memory-match.ts`, `containment.ts` and, the moment | ||
| // `isNull()` joined the invariant vocabulary, `expr.ts`. | ||
| /** | ||
| * Absent and NULL are one value. `undefined` is a key nobody typed or a column a projection left | ||
| * out; `null` is one the row spelled. Postgres cannot tell them apart and neither may anything | ||
| * reading a row here. | ||
| * | ||
| * A copy is not free: the rule decides whether a row the caller never NAMED a column on is the same | ||
| * row as one that stored `null`, and the table holds NULL for both. `===` made them two, and | ||
| * `eq null` then skipped the absent row while `neq null` answered it — the opposite of the same | ||
| * predicate in production. | ||
| */ | ||
| export const isNullish = (value: unknown): boolean => value === null || value === undefined; |
| // Single responsibility: decide whether every construct in a regex source means the SAME thing to | ||
| // `RegExp.prototype.test` and to Postgres' `~`. Nothing here escapes, emits or refuses — it names | ||
| // the first construct that does not, and `expr.ts` turns that into the refusal. | ||
| // | ||
| // WHY it has to exist: `matches(/…/)` puts ONE string in front of two engines, and JavaScript's | ||
| // RegExp and Postgres' ARE are not the same language. `\b` is a word boundary in one and a | ||
| // BACKSPACE character in the other; both compile, neither errors, and the CHECK enforces a rule the | ||
| // app never wrote. Refusing the construct is the only outcome that keeps "one declaration, two | ||
| // enforcement points" true. | ||
| /** The first construct in a pattern the two engines disagree about. */ | ||
| export interface UnportablePattern { | ||
| /** As it appears in the source: `\b`, `.`, `[:`, `a-é`. */ | ||
| readonly construct: string; | ||
| /** Offset of `construct` in the source, so the refusal can point at it. */ | ||
| readonly at: number; | ||
| /** One sentence naming BOTH readings — never "unsupported". */ | ||
| readonly why: string; | ||
| /** A spelling that means the same thing in both, when one exists. */ | ||
| readonly instead: string | undefined; | ||
| } | ||
| /** A cursor past the construct just read, or the refusal that ends the scan. */ | ||
| type Step = number | UnportablePattern; | ||
| const isRefusal = (step: Step): step is UnportablePattern => typeof step !== 'number'; | ||
| const refuse = ( | ||
| construct: string, | ||
| at: number, | ||
| why: string, | ||
| instead: string | undefined, | ||
| ): UnportablePattern => ({ construct, at, why, instead }); | ||
| /** | ||
| * `\` + one of these is the same character class or control character in both engines. `\d` earns | ||
| * its place by measurement, not by reading: POSIX fixes `[[:digit:]]` at the ten ASCII digits in | ||
| * every locale, and `'٣' ~ '^\d$'` and `'5' ~ '^\d$'` are both false on a UTF-8 server, exactly as | ||
| * `/^\d$/` is. `\w` and `\s` are the two that look like they belong here and do not. | ||
| */ | ||
| const PORTABLE_ESCAPES = new Set(['d', 'D', 'n', 'r', 't', 'f', 'v']); | ||
| /** | ||
| * The escapes that compile on both sides and mean different things. Measured on PostgreSQL 18.4, | ||
| * UTF8, `en_US.utf8` — the pairs are in `pg-invariant-pattern.live.test.ts`, which re-runs them | ||
| * against whatever server is configured so a future Postgres cannot quietly change one. | ||
| * | ||
| * A `Map` and not a frozen record: the key is data read from a pattern, and `TABLE[key]` on an | ||
| * object literal answers an `Object.prototype` member for `constructor` and `toString`. | ||
| */ | ||
| const DIVERGENT_ESCAPES = new Map<string, readonly [why: string, instead: string | undefined]>([ | ||
| ['b', ['is a word boundary in JavaScript and a BACKSPACE character in Postgres', undefined]], | ||
| ['B', ['is a non-word-boundary in JavaScript and a literal backslash in Postgres', undefined]], | ||
| [ | ||
| 'w', | ||
| [ | ||
| "is [A-Za-z0-9_] in JavaScript and the LOCALE's alphanumeric class in Postgres, which matches é", | ||
| '[A-Za-z0-9_]', | ||
| ], | ||
| ], | ||
| [ | ||
| 'W', | ||
| [ | ||
| "is [^A-Za-z0-9_] in JavaScript and the complement of the LOCALE's alphanumeric class in Postgres", | ||
| '[^A-Za-z0-9_]', | ||
| ], | ||
| ], | ||
| [ | ||
| 's', | ||
| [ | ||
| 'matches U+00A0 and the Unicode separators in JavaScript, which Postgres’ [[:space:]] does not', | ||
| '[ \\t\\n\\r\\f\\v]', | ||
| ], | ||
| ], | ||
| ['S', ['is the complement of a class the two engines do not agree on', '[^ \\t\\n\\r\\f\\v]']], | ||
| ['A', ['anchors the start of the string in Postgres and is the letter A in JavaScript', '^']], | ||
| ['Z', ['anchors the end of the string in Postgres and is the letter Z in JavaScript', '$']], | ||
| ['y', ['is a word boundary in Postgres and the letter y in JavaScript', undefined]], | ||
| ['Y', ['is a non-word-boundary in Postgres and the letter Y in JavaScript', undefined]], | ||
| ['m', ['anchors a word start in Postgres and is the letter m in JavaScript', undefined]], | ||
| ['M', ['anchors a word end in Postgres and is the letter M in JavaScript', undefined]], | ||
| ['a', ['is BEL in Postgres and the letter a in JavaScript', undefined]], | ||
| ['e', ['is ESC in Postgres and the letter e in JavaScript', undefined]], | ||
| ['x', ['reads up to three hex digits in Postgres and exactly two in JavaScript', undefined]], | ||
| ['U', ['is an 8-digit codepoint in Postgres and the letter U in JavaScript', undefined]], | ||
| ['c', ['is a control escape the two engines delimit differently', undefined]], | ||
| ['k', ['names a group Postgres cannot declare', undefined]], | ||
| ['p', ['is a Unicode property in JavaScript and an error in Postgres', undefined]], | ||
| ['P', ['is a negated Unicode property in JavaScript and an error in Postgres', undefined]], | ||
| ['0', ['is NUL in JavaScript and no Postgres text can hold one', undefined]], | ||
| ]); | ||
| const BACKREFERENCE = [ | ||
| 'is a backreference, and the two engines number their groups differently once a lookaround is', | ||
| 'involved', | ||
| ].join(' '); | ||
| /** | ||
| * The group openings that survive both engines. `(?:` `(?=` `(?!` `(?<=` `(?<!` are measured to | ||
| * agree — each has a row in `pg-invariant-pattern.live.test.ts`'s agreement table, the two | ||
| * LOOKBEHINDS only since 2026-08-25: this sentence shipped naming five and the table ran three, so | ||
| * it was broader than the evidence for as long as it existed. The whole rest of the `(?` family is | ||
| * refused, because ARE has no named groups at all — `(?<year>…)` is a server-side `invalid regular | ||
| * expression` — and its inline directors (`(?i)`) are not JavaScript syntax. | ||
| * | ||
| * Greediness needs no rule: `~` and `.test()` both answer whether a match EXISTS, and with | ||
| * backreferences refused no amount of greedy-vs-lazy backtracking can change that answer. So | ||
| * `a+?b` stays in. | ||
| */ | ||
| const GROUP_OPENINGS = ['(?:', '(?=', '(?!', '(?<=', '(?<!'] as const; | ||
| /** `{n}` `{n,}` `{n,m}`, the three forms both engines read the same way. */ | ||
| const QUANTIFIER = /^\{\d+(?:,\d*)?\}/; | ||
| /** | ||
| * `\uwxyz` is EXACTLY four hex digits in ARE and exactly four in JavaScript without the `u` flag — | ||
| * measured to agree, including inside a bracket expression. It is in the subset for a reason that | ||
| * is not convenience: **Bun returns a regex LITERAL's non-ASCII characters escaped**, | ||
| * `/^é$/.source` is `^\u00E9$`, so refusing the escape would refuse every pattern an i18n rule | ||
| * writes. Anything shorter is `invalid escape` on the server and the letter `u` in JavaScript. | ||
| */ | ||
| const HEX4 = /^[0-9A-Fa-f]{4}/; | ||
| const isAscii = (char: string): boolean => (char.codePointAt(0) ?? 0) < 0x80; | ||
| const NUL = refuse( | ||
| '\\0', | ||
| 0, | ||
| 'is a null byte, which no Postgres text value can hold — the statement never reaches the server', | ||
| undefined, | ||
| ); | ||
| function escapeAt(source: string, at: number): Step { | ||
| const next = source[at + 1]; | ||
| if (next === undefined) { | ||
| return refuse('\\', at, 'ends the pattern, so there is nothing for it to escape', undefined); | ||
| } | ||
| const divergent = DIVERGENT_ESCAPES.get(next); | ||
| if (divergent !== undefined) return refuse(`\\${next}`, at, divergent[0], divergent[1]); | ||
| if (next === 'u') { | ||
| return HEX4.test(source.slice(at + 2)) | ||
| ? at + 6 | ||
| : refuse( | ||
| '\\u', | ||
| at, | ||
| 'is a codepoint escape Postgres reads as exactly four hex digits and JavaScript reads as the letter u when there are fewer', | ||
| undefined, | ||
| ); | ||
| } | ||
| if (PORTABLE_ESCAPES.has(next)) return at + 2; | ||
| if (next >= '1' && next <= '9') return refuse(`\\${next}`, at, BACKREFERENCE, undefined); | ||
| // Postgres reads `\` + any remaining ALPHANUMERIC as a special it has not been taught here, and | ||
| // JavaScript reads it as the letter — the `\b` shape, one letter along. `\` + punctuation is that | ||
| // character literally in both, which is what keeps `\.` `\$` `\-` `\'` `\]` in the subset. | ||
| if (/[0-9A-Za-z]/.test(next)) { | ||
| return refuse( | ||
| `\\${next}`, | ||
| at, | ||
| 'is an escape Postgres reads as a special and JavaScript reads as the letter', | ||
| undefined, | ||
| ); | ||
| } | ||
| if (!isAscii(next) || next < ' ') { | ||
| return refuse( | ||
| `\\${next}`, | ||
| at, | ||
| 'escapes a character outside printable ASCII, where the two engines are not measured to agree', | ||
| undefined, | ||
| ); | ||
| } | ||
| return at + 2; | ||
| } | ||
| function groupAt(source: string, at: number): Step { | ||
| if (source[at + 1] !== '?') return at + 1; | ||
| const opening = GROUP_OPENINGS.find((form) => source.startsWith(form, at)); | ||
| if (opening !== undefined) return at + opening.length; | ||
| return refuse( | ||
| source.slice(at, at + 4), | ||
| at, | ||
| 'is a group form Postgres has no syntax for — it has no named groups and no inline flags', | ||
| undefined, | ||
| ); | ||
| } | ||
| const LEADING_BRACKET_WHY = | ||
| 'opens a class whose first ] Postgres reads as a MEMBER and JavaScript reads as the close of an empty class'; | ||
| const RANGE_WHY = | ||
| 'is a range whose endpoints Postgres orders by the database COLLATION and JavaScript orders by code point'; | ||
| function bracketAt(source: string, at: number): Step { | ||
| let cursor = source[at + 1] === '^' ? at + 2 : at + 1; | ||
| // `[]a]` is the literal `]` plus `a` to Postgres and an EMPTY class followed by `a]` to | ||
| // JavaScript — measured to disagree, and `\]` is the one spelling both read as a member. | ||
| if (source[cursor] === ']') return refuse('[]', at, LEADING_BRACKET_WHY, '\\]'); | ||
| /** The last member that could be the lower end of a range; `undefined` after one is consumed. */ | ||
| let previous: string | undefined; | ||
| while (cursor < source.length) { | ||
| const char = source[cursor] ?? ''; | ||
| if (char === ']') return cursor + 1; | ||
| if (char === '\u0000') return { ...NUL, at: cursor }; | ||
| if (char === '[') { | ||
| const kind = source[cursor + 1] ?? ''; | ||
| if (kind === ':' || kind === '.' || kind === '=') { | ||
| return refuse( | ||
| `[${kind}`, | ||
| cursor, | ||
| 'opens a POSIX class, collating element or equivalence class, none of which JavaScript has', | ||
| undefined, | ||
| ); | ||
| } | ||
| previous = char; | ||
| cursor += 1; | ||
| continue; | ||
| } | ||
| if (char === '\\') { | ||
| const step = escapeAt(source, cursor); | ||
| if (isRefusal(step)) return step; | ||
| previous = undefined; | ||
| cursor = step; | ||
| continue; | ||
| } | ||
| const upper = source[cursor + 1]; | ||
| if (char === '-' && previous !== undefined && upper !== undefined && upper !== ']') { | ||
| if (upper === '\\') { | ||
| const step = escapeAt(source, cursor + 1); | ||
| if (isRefusal(step)) return step; | ||
| if (!isAscii(previous)) return refuse(`${previous}-\\`, cursor - 1, RANGE_WHY, undefined); | ||
| previous = undefined; | ||
| cursor = step; | ||
| continue; | ||
| } | ||
| if (!isAscii(previous) || !isAscii(upper)) { | ||
| return refuse(`${previous}-${upper}`, cursor - 1, RANGE_WHY, undefined); | ||
| } | ||
| previous = undefined; | ||
| cursor += 2; | ||
| continue; | ||
| } | ||
| previous = char; | ||
| cursor += 1; | ||
| } | ||
| return refuse('[', at, 'opens a bracket expression that is never closed', undefined); | ||
| } | ||
| /** | ||
| * The first construct in `source` the two engines read differently, or `undefined` when the whole | ||
| * pattern is in the subset they agree on. | ||
| * | ||
| * Only the POSTGRES direction is judged: `matches` is handed a built `RegExp`, so JavaScript has | ||
| * already accepted the source by the time this runs. | ||
| */ | ||
| export function unportableConstruct(source: string): UnportablePattern | undefined { | ||
| let at = 0; | ||
| /** Whether a `{n}` here would be a quantifier at all — it needs something to repeat. */ | ||
| let repeatable = false; | ||
| while (at < source.length) { | ||
| const char = source[at] ?? ''; | ||
| if (char === '\u0000') return { ...NUL, at }; | ||
| let step: Step = at + 1; | ||
| if (char === '\\') step = escapeAt(source, at); | ||
| else if (char === '[') step = bracketAt(source, at); | ||
| else if (char === '(') step = groupAt(source, at); | ||
| else if (char === '{') { | ||
| // `/^{2}$/` compiles in JavaScript under Annex B and is `invalid regular expression` on the | ||
| // server, so the migration is the thing that fails. Refusing here moves it to the line that | ||
| // wrote it — and a `{` with nothing before it is that same error, which is why the quantifier | ||
| // has to be judged in position and not by its own shape. | ||
| const quantifier = repeatable ? QUANTIFIER.exec(source.slice(at)) : null; | ||
| step = | ||
| quantifier === null | ||
| ? refuse('{', at, 'opens no quantifier Postgres can read', '\\{') | ||
| : at + quantifier[0].length; | ||
| } else if (char === '.') { | ||
| // `'a\nb' ~ 'a.b'` is TRUE and `/a.b/.test('a\nb')` is false: ARE's `.` matches a newline. | ||
| step = refuse('.', at, 'matches a newline in Postgres and never in JavaScript', '[^\\n\\r]'); | ||
| } | ||
| if (isRefusal(step)) return step; | ||
| // `^`, `$`, `|` and an opening `(` leave nothing to repeat; everything else does, including a | ||
| // `)` that closed a group and a `]` that closed a class. | ||
| repeatable = char !== '^' && char !== '$' && char !== '|' && char !== '('; | ||
| at = step; | ||
| } | ||
| return undefined; | ||
| } |
+5
-5
| { | ||
| "name": "@ultimat3/entity", | ||
| "version": "15.0.0", | ||
| "version": "16.0.0", | ||
| "description": "A table + its domain type + invariants the database also enforces", | ||
@@ -34,7 +34,7 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "15.0.0", | ||
| "@ultimat3/db": "15.0.0", | ||
| "@ultimat3/schema": "15.0.0", | ||
| "@ultimat3/time": "15.0.0" | ||
| "@ultimat3/core": "16.0.0", | ||
| "@ultimat3/db": "16.0.0", | ||
| "@ultimat3/schema": "16.0.0", | ||
| "@ultimat3/time": "16.0.0" | ||
| } | ||
| } |
+31
-0
@@ -158,2 +158,33 @@ # @ultimat3/entity 🗄️ | ||
| A `RegExp` is not a predicate and does reach the database: `c.slug.matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)` | ||
| is `kind: 'check'` and emits `slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'`. The **same string** runs in both | ||
| places — nothing is translated — so a construct the two engines read differently is REFUSED where it | ||
| is written, with the portable spelling in the `fix` where one exists (`\w` -> `[A-Za-z0-9_]`, `.` -> | ||
| `[^\n\r]`) and the app-only predicate where none does (`\b` is a word boundary to `.test()` and a | ||
| BACKSPACE to Postgres). `i` becomes `~*`; every other flag is refused. | ||
| `isNull()` / `isNotNull()` are the only pair in the vocabulary TOTAL over NULL — `IS NULL` answers | ||
| true or false for every input, and the app side reads an ABSENT key and a stored `null` as one value. | ||
| `iff(a, b)` is the biconditional over two predicates, rendered `(a) = (b)`, which is what Postgres | ||
| spells one as: | ||
| ```ts | ||
| import { entity, enumerated, iff, invariant, timestamp, uuid } from '@ultimat3/entity'; | ||
| export const publishable = entity('publishable', { | ||
| columns: { | ||
| id: uuid().primaryKey(), | ||
| status: enumerated(['draft', 'published']).default('draft'), | ||
| publishedAt: timestamp().nullable(), | ||
| }, | ||
| invariants: (c) => [ | ||
| invariant('post_publish_coherent', iff(c.status.eq('published'), c.publishedAt.isNotNull())), | ||
| // check ((status = 'published') = (published_at is not null)) | ||
| ], | ||
| }); | ||
| ``` | ||
| A `satisfies(fn, [...])` that could be written this way should be: only one of the two reaches the | ||
| database, and `x verify` reports the other as a rule the database does not know. | ||
| ## One typed handle | ||
@@ -160,0 +191,0 @@ |
+11
-3
@@ -5,2 +5,3 @@ // Two things every column builder needs and neither owns: how a rejected value is DESCRIBED, and | ||
| import { literal } from '@ultimat3/db'; | ||
| import { describeValue } from '@ultimat3/schema'; | ||
@@ -25,7 +26,14 @@ | ||
| const quote = (value: string): string => `'${value.replaceAll("'", "''")}'`; | ||
| /** | ||
| * The value list of a closed set, quoted by `@ultimat3/db`'s `literal()` — the framework's one | ||
| * splice rule, imported downward rather than restated. This file carried its own | ||
| * `'${v.replaceAll("'", "''")}'` and `expr.ts` carried the same line; two copies of an escape is two | ||
| * places a hardening has to land, and only one of them ever does. The members are an APP's own | ||
| * `enumerated([...])` array, so they reach `create table` as text nothing validated. | ||
| * | ||
| * `.text` because a column's `check` is a bare string all the way to the DDL, not a `SqlFragment`. | ||
| */ | ||
| export const oneOf = | ||
| (values: readonly string[]) => | ||
| (name: string): string => | ||
| `${name} in (${values.map(quote).join(', ')})`; | ||
| `${name} in (${values.map((value) => literal(value).text).join(', ')})`; |
@@ -10,4 +10,3 @@ // Single responsibility: what `@>`, `<@`, `&&` and a JSON key test MEAN, written once so the | ||
| /** Absent and NULL are one thing, exactly as they are to every other predicate. */ | ||
| const isNull = (value: unknown): boolean => value === null || value === undefined; | ||
| import { isNullish as isNull } from './is-null'; | ||
@@ -14,0 +13,0 @@ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> => |
+145
-8
@@ -10,7 +10,27 @@ // The invariant expression language. One declaration compiles to two enforcement points: a | ||
| // does not know this rule — silently pretending it reached Postgres would be worse. | ||
| // | ||
| // A `RegExp` is the other case and it DOES reach SQL, because nothing about it is translated: the | ||
| // source `pattern.test` runs is the source spliced into the CHECK. `pattern-portability.ts` is what | ||
| // makes that legal, and `@ultimat3/db`'s `literal()` is what keeps the splice inside its own quotes. | ||
| import { literal as sqlLiteral } from '@ultimat3/db'; | ||
| import { invariantViolated } from './errors'; | ||
| import { isNullish } from './is-null'; | ||
| import { unportableConstruct } from './pattern-portability'; | ||
| import { refuseInvariant } from './refuse'; | ||
| import type { ColumnMap } from './types'; | ||
| /** | ||
| * A declared operand as SQL TEXT. The escape itself is `@ultimat3/db`'s `literal()` — tier 1 owns | ||
| * that rule and this is an ordinary downward import — and nothing here re-spells it; the wrapper | ||
| * exists for two narrower reasons. | ||
| * | ||
| * It takes the four types a CHECK operand can be, rather than `unknown`: every call below already | ||
| * knows which one it holds, and a widened parameter would put `String(someObject)` into statement | ||
| * text as `[object Object]`. And it unwraps `.text`, because `Invariant.sql` is a bare string that | ||
| * `@ultimat3/db` re-renders at DDL time — a `SqlFragment` cannot survive that round trip. | ||
| */ | ||
| const literal = (value: string | number | boolean | bigint): string => | ||
| typeof value === 'string' ? sqlLiteral(value).text : String(value); | ||
| export type Row = Readonly<Record<string, unknown>>; | ||
@@ -42,3 +62,7 @@ | ||
| contains(value: string): Expr; | ||
| /** A `RegExp` reaches the database; a function is app-only. */ | ||
| /** | ||
| * A `RegExp` reaches the database as `~`/`~*` over its own source; a function is app-only. A | ||
| * construct the two engines read differently is refused at declaration, never emitted as a | ||
| * lookalike — `pattern-portability.ts` names the subset and why each exclusion is in it. | ||
| */ | ||
| matches(pattern: RegExp | ((value: string) => boolean)): Expr; | ||
@@ -48,2 +72,11 @@ atLeast(value: number | bigint): Expr; | ||
| isTrue(): Expr; | ||
| /** | ||
| * `col is null` / `col is not null`, and the only pair in this vocabulary that is TOTAL over | ||
| * NULL in both halves: Postgres' `IS NULL` answers true or false for every input including NULL, | ||
| * and the app side reads absent and `null` as one value (`is-null.ts`). Every other operator | ||
| * here answers NULL in SQL for a NULL operand, and a CHECK PASSES on NULL — which is why these | ||
| * two are what an `iff` can be built out of. | ||
| */ | ||
| isNull(): Expr; | ||
| isNotNull(): Expr; | ||
| /** Money is two physical columns; these are how a rule names one of them. */ | ||
@@ -81,5 +114,2 @@ readonly minor: ColumnExpr; | ||
| const literal = (value: unknown): string => | ||
| typeof value === 'string' ? `'${value.replaceAll("'", "''")}'` : String(value); | ||
| /** | ||
@@ -113,2 +143,37 @@ * The Postgres operator a `RegExp`'s flags mean — the second half of "one declaration, two | ||
| /** | ||
| * The SQL a `RegExp` becomes — or the refusal naming the construct that would have made the two | ||
| * halves mean different things. | ||
| * | ||
| * Nothing is TRANSLATED here and nothing ever should be: the string handed to `pattern.test` and | ||
| * the string spliced into the CHECK are the SAME string, and `unportableConstruct` is what makes | ||
| * that legal. Emitting a "close enough" POSIX rewrite of a JavaScript-only construct would ship two | ||
| * rules under one name, which is strictly worse than the `assert` a predicate already gives you. | ||
| * | ||
| * Flags are judged first: a flag is a property of the whole pattern and a construct is one position | ||
| * inside it, so the refusal an author can act on without reading an index goes out first. | ||
| * | ||
| * The source is spliced through `literal`, never quoted here, and a PATTERN is the sharpest case | ||
| * for why that rule is `@ultimat3/db`'s and not a doubled quote: measured on 18.4, `'dd' ~ '^\d+$'` | ||
| * is FALSE with `standard_conforming_strings` on and **TRUE** with it off, because the server | ||
| * compiles `^d+$` — a CHECK enforcing a pattern the author never wrote, with no error anywhere. | ||
| * A backslash is in almost every real pattern, so almost every real pattern depends on the `E'…'` | ||
| * half. `pg-invariant-pattern.live.test.ts` runs that exact pair under both settings. | ||
| */ | ||
| const patternSql = (pattern: RegExp): string => { | ||
| const operator = matchOperator(pattern); | ||
| const unportable = unportableConstruct(pattern.source); | ||
| const spelled = `/${pattern.source}/${pattern.flags}`; | ||
| if (unportable !== undefined) { | ||
| return refuseInvariant( | ||
| 'matches', | ||
| `${spelled} uses ${unportable.construct} at index ${unportable.at}, which ${unportable.why} — the CHECK and pattern.test() would answer differently for the same row`, | ||
| unportable.instead === undefined | ||
| ? `matches((value) => ${spelled}.test(value)) # app-only: the rule stays in TS and reports sql: null, so no CHECK claims to enforce it` | ||
| : `write ${unportable.instead} where ${spelled} has ${unportable.construct} — one meaning in both engines — then x db gen`, | ||
| ); | ||
| } | ||
| return `${operator} ${literal(pattern.source)}`; | ||
| }; | ||
| const check = ( | ||
@@ -166,6 +231,3 @@ paths: readonly (readonly string[])[], | ||
| // rather than during migration generation, where the entity name is all anyone would see. | ||
| const emitted = | ||
| pattern instanceof RegExp | ||
| ? `${matchOperator(pattern)} ${literal(pattern.source)}` | ||
| : undefined; | ||
| const emitted = pattern instanceof RegExp ? patternSql(pattern) : undefined; | ||
| return one( | ||
@@ -203,2 +265,12 @@ `${term.label} must match ${pattern instanceof RegExp ? pattern.source : pattern.name || 'the rule'}`, | ||
| isNull: () => | ||
| one(`${term.label} is not set`, (resolve) => `${term.sql(resolve)} is null`, isNullish), | ||
| isNotNull: () => | ||
| one( | ||
| `${term.label} is set`, | ||
| (resolve) => `${term.sql(resolve)} is not null`, | ||
| (value) => !isNullish(value), | ||
| ), | ||
| get minor() { | ||
@@ -272,2 +344,67 @@ return part(term, 'minor'); | ||
| /** | ||
| * `a` and `b` hold together or not at all — the biconditional, rendered `(a) = (b)`, which is what | ||
| * Postgres spells one as: `=` between two booleans IS iff there. | ||
| * | ||
| * A FUNCTION and not a method on `Expr`, for two reasons that both come from the type. `Expr` is | ||
| * exported, so a required member is a breaking change to anything implementing it structurally; and | ||
| * `kind: 'unique'` is an `Expr` whose `toSql` is a COLUMN LIST, so `c.unique([…]).iff(…)` would be a | ||
| * method that exists on the type and is meaningless for some of its values. Refusing that operand in | ||
| * one place beats putting the method where it cannot mean anything. Symmetric reads symmetric, too. | ||
| * | ||
| * **`=` and not `is not distinct from`, decided on a measurement.** With both operands total the two | ||
| * are identical for all four boolean pairs. They part when an operand is NULL — a predicate on a | ||
| * nullable column — and there `=` answers NULL, which a CHECK PASSES, while `is not distinct from` | ||
| * answers false, which a CHECK REFUSES. The app side reads a NULL operand as false either way, so | ||
| * the total form is the one that refuses a row TypeScript ACCEPTED: `(NULL) is not distinct from | ||
| * (false)` is false where `false === false` is true. That is the raw `23514` in place of | ||
| * `X_INVARIANT_VIOLATED` this whole file exists against, and it is why the more permissive spelling | ||
| * is the safer one. `pg-invariant-null.live.test.ts` measures both. | ||
| * | ||
| * So an operand that can be NULL leaves the CHECK permissive — the language's one existing | ||
| * disagreement, inherited here and not widened. `isNull()`/`isNotNull()` are total, which is what | ||
| * makes a rule built from them exact. | ||
| */ | ||
| export const iff = (left: Expr, right: Expr): Expr => { | ||
| for (const side of [left, right] as const) { | ||
| if (side.kind !== 'unique') continue; | ||
| // The columns it names, so the pasted line is the rule the author already meant to declare — | ||
| // never a `<name>` for them to fill in, which is the placeholder `refuse.test.ts` refuses. | ||
| // | ||
| // `JSON.stringify` and never `'${column}'`: a column path is a VALUE reaching TypeScript | ||
| // SOURCE, which is this file's own hazard one layer up. `columns: { "o'brien": text() }` is a | ||
| // legal declaration and `unique()` is reached untyped besides, so a quote ends the literal and | ||
| // the fix stops parsing; a backslash is the half doubling the quote would still have missed. | ||
| // `errors.ts`'s `asLiteral` is the same rule for the same reason. | ||
| const columns = side.paths.map((path) => path.join('.')); | ||
| const list = columns.map((column) => JSON.stringify(column)).join(', '); | ||
| const name = JSON.stringify(`${columns.join('_')}_unique`); | ||
| refuseInvariant( | ||
| 'iff', | ||
| `${side.message} is a unique constraint, whose SQL is a column list and not a predicate`, | ||
| `invariant(${name}, c.unique([${list}])) # uniqueness is its own invariant; iff takes two predicates, e.g. iff(c.status.eq('published'), c.publishedAt.isNotNull())`, | ||
| ); | ||
| } | ||
| const seen = new Set<string>(); | ||
| const paths: (readonly string[])[] = []; | ||
| for (const path of [...left.paths, ...right.paths]) { | ||
| const key = path.join('.'); | ||
| if (seen.has(key)) continue; | ||
| seen.add(key); | ||
| paths.push(path); | ||
| } | ||
| return check( | ||
| paths, | ||
| `${left.message} exactly when ${right.message}`, | ||
| (resolve) => { | ||
| // One app-only operand makes the WHOLE rule app-only: `(null) = (…)` is not a predicate, and | ||
| // emitting half of a biconditional would enforce something the author never wrote. | ||
| const a = left.toSql(resolve); | ||
| const b = right.toSql(resolve); | ||
| return a === null || b === null ? null : `(${a}) = (${b})`; | ||
| }, | ||
| (row) => left.holds(row) === right.holds(row), | ||
| ); | ||
| }; | ||
| /** | ||
| * The `c` an invariant is written against. Still a Proxy even though `InvariantColumns<C>` now | ||
@@ -274,0 +411,0 @@ * catches a typo at compile time: a JS caller, a dynamically built rule and a `satisfies()` column |
+1
-0
@@ -68,2 +68,3 @@ // The public surface of @ultimat3/entity. Explicit, never `export *`. | ||
| export type { ColumnExpr, Expr, InvariantColumns, Resolve } from './expr'; | ||
| export { iff } from './expr'; | ||
| /** The two DECLARED capabilities' refusals — a third-party driver raises the same ones. */ | ||
@@ -70,0 +71,0 @@ export type { IllegalTransition } from './feature-errors'; |
@@ -15,2 +15,3 @@ // Single responsibility: what a `Predicate` MEANS in the in-memory driver — equality, ordering and | ||
| import { instantMicros } from './instant'; | ||
| import { isNullish as isNull } from './is-null'; | ||
| import type { Predicate } from './tenancy'; | ||
@@ -38,5 +39,2 @@ import type { ColumnKind } from './types'; | ||
| /** Absent and NULL are one thing to a predicate: a column the projection left out is not a value. */ | ||
| const isNull = (value: unknown): boolean => value === null || value === undefined; | ||
| const sign = <T extends number | bigint | string>(left: T, right: T): number => | ||
@@ -43,0 +41,0 @@ left < right ? -1 : left > right ? 1 : 0; |
Sorry, the diff of this file is too big to display
705098
4.89%58
3.57%10628
4.15%854
3.77%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated