@ultimat3/entity 14.0.0 → 16.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 +98 -2
- package/README.md +31 -0
- package/package.json +5 -5
- package/src/column-values.ts +11 -3
- package/src/containment.ts +1 -2
- package/src/expr.ts +145 -8
- package/src/index.ts +1 -0
- package/src/is-null.ts +15 -0
- package/src/memory-match.ts +1 -3
- package/src/pattern-portability.ts +286 -0
package/CLAUDE.md
CHANGED
|
@@ -481,6 +481,91 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
481
481
|
identifier and says nothing, so two names sharing their first 63 bytes are one index on the
|
|
482
482
|
server — the same silent collapse one layer down, and invisible to a drift check comparing
|
|
483
483
|
DECLARED names, which still differ.
|
|
484
|
+
- **`isNull()`/`isNotNull()` are the ONLY total members of the invariant vocabulary, and `iff` is
|
|
485
|
+
built out of them — `As of 2026-08-25`.** Postgres' `IS NULL` answers true or false for every
|
|
486
|
+
input NULL included; every other operator here answers NULL for a NULL operand and **a CHECK
|
|
487
|
+
PASSES on NULL**, so the database is the more permissive half wherever a predicate reads a
|
|
488
|
+
nullable column. The app side reads an ABSENT key and a stored `null` as one value, which is
|
|
489
|
+
`is-null.ts` — one rule, because `memory-match.ts` and `containment.ts` each had a private copy
|
|
490
|
+
and `expr.ts` was about to be the third. `iff(a, b)` renders `(a) = (b)`, byte for byte the shape
|
|
491
|
+
`examples/dummy`'s hand-written `0001_init.sql:67` already holds.
|
|
492
|
+
**`=` and not `is not distinct from`, decided on a measurement, and the reasoning inverts the
|
|
493
|
+
obvious one.** With both operands total the two spellings are identical on all four boolean pairs
|
|
494
|
+
(measured, PG 18.4). They part only on a NULL operand, and there the TOTAL form is the DANGEROUS
|
|
495
|
+
one: `(NULL) is not distinct from (false)` is false and refuses the row, while TypeScript reads a
|
|
496
|
+
NULL operand as false and `false === false` ACCEPTS it — a raw `23514` in place of
|
|
497
|
+
`X_INVARIANT_VIOLATED`, which is the exact failure `matchOperator`'s flag refusal exists against.
|
|
498
|
+
`=` leaves the disagreement in the safe direction, where the app refuses first and no write ever
|
|
499
|
+
reaches a CHECK that would have refused it. `pg-invariant-null.live.test.ts` measures both
|
|
500
|
+
spellings on a real table, and `expr.test.ts` pins the permissive direction so a half-fix that
|
|
501
|
+
flips it fails loudly. **`iff` is a FUNCTION, not a method on `Expr`**: `Expr` is exported, so a
|
|
502
|
+
required member breaks a structural implementer, and `kind: 'unique'` is an `Expr` whose `toSql`
|
|
503
|
+
is a column LIST — a `.iff()` there would be a method that cannot mean anything for some values
|
|
504
|
+
of its own type. That operand is refused in one place, with the `c.unique([…])` invariant the
|
|
505
|
+
author meant spelled out from its own columns — **each path through `JSON.stringify`, `As of
|
|
506
|
+
2026-08-25`**: a `fix:` is TypeScript to PASTE, so a column name reaching it is a value spliced
|
|
507
|
+
into source, and `'${column}'` produced `invariant('o'brien_unique', …)` on a name carrying a
|
|
508
|
+
quote. `columns: { "o'brien": text() }` is a legal declaration and `unique()` is reached untyped
|
|
509
|
+
by a JS caller besides; a backslash is the half that doubling the quote would still have missed.
|
|
510
|
+
The same defect as an unescaped pattern, one layer up in the error message. **One app-only operand makes the WHOLE rule
|
|
511
|
+
app-only** (`sql: null`, so `bindInvariant` lands it as `assert`): emitting half a biconditional
|
|
512
|
+
would enforce something nobody wrote.
|
|
513
|
+
- **A `matches()` pattern reaches the CHECK as the SAME STRING `pattern.test` runs, or it is
|
|
514
|
+
refused — `As of 2026-08-25`.** Nothing is translated and nothing ever may be: a "close enough"
|
|
515
|
+
POSIX rewrite of a JavaScript-only construct ships two rules under one name, which is worse than
|
|
516
|
+
the `assert` a predicate already gives you. What makes one string in front of two engines legal is
|
|
517
|
+
`pattern-portability.ts`, a scanner over the source that names the first construct ARE and
|
|
518
|
+
ECMAScript read differently, and every entry on it is a MEASUREMENT against a real server, not a
|
|
519
|
+
reading of the docs. The flagship: `'foo' ~ '\bfoo'` is FALSE on Postgres 18.4 and
|
|
520
|
+
`/\bfoo/.test('foo')` is true, because ARE reads `\b` as a BACKSPACE — both compile, neither
|
|
521
|
+
errors, and the CHECK enforces a rule the entity never wrote. So are `.` (matches a newline there
|
|
522
|
+
and never here), `\w` (the locale's alnum class, which matches `é`), `\s` (JavaScript adds
|
|
523
|
+
U+00A0), `[[:alpha:]]`, a leading `]` in a class, `\x` (three hex digits there, two here), `\A`
|
|
524
|
+
and `\Z`, and a named group. `\d` is IN, measured rather than assumed — POSIX fixes
|
|
525
|
+
`[[:digit:]]` at the ten ASCII digits, so `'٣'` and `'5'` are false on both sides. `\uwxyz` is in
|
|
526
|
+
for a reason that is not convenience: **Bun escapes a regex LITERAL's non-ASCII characters**,
|
|
527
|
+
`/^é$/.source` is `^\u00E9$` while `new RegExp('^é$').source` is `^é$`, so refusing the escape
|
|
528
|
+
would refuse every i18n pattern written the ordinary way. The refusal carries the portable
|
|
529
|
+
spelling where one exists and the app-only predicate where none does, and it lands at DECLARATION
|
|
530
|
+
beside `matchOperator`'s flag refusal, on the line that wrote it. `pg-invariant-pattern.live.test.ts`
|
|
531
|
+
runs both halves against a server: every kept construct must AGREE and every refused one must
|
|
532
|
+
still DISAGREE — so a future Postgres that grows JavaScript's `\b` turns the list red instead of
|
|
533
|
+
leaving a stale exclusion in place. **The kept half is only as broad as its table, and the table
|
|
534
|
+
was narrower than the claim from the day it landed, closed 2026-08-25**:
|
|
535
|
+
`pattern-portability.ts` called `(?<=` and `(?<!`
|
|
536
|
+
measured and neither had ever been run, and nor had a capturing group, a top-level `|`, `{n,}`,
|
|
537
|
+
`{n,m}`, `\t`/`\f`/`\v`, an escaped punctuation outside a bracket expression, a bare `]`/`}`, or
|
|
538
|
+
a trailing `-` in a class. All of them agree on 18.4 (73 pairs, 0 disagreements) and all of them
|
|
539
|
+
now have rows; every row is also asserted to be a construct `unportableConstruct` KEEPS, since a
|
|
540
|
+
row for a refused one measures something `matches()` can never emit. What remains unmechanised is
|
|
541
|
+
the direction no source can enumerate — a construct added to the kept set with no row here.
|
|
542
|
+
- **A declared string is spliced by `@ultimat3/db`'s `literal()` and by nothing in this package —
|
|
543
|
+
`As of 2026-08-25`, and doubling the quote is only HALF the rule.** `expr.ts` and
|
|
544
|
+
`column-values.ts` each carried `'${v.replaceAll("'", "''")}'`; a CHECK takes no bind parameters,
|
|
545
|
+
so a `matches()` pattern, a `contains()` needle and every `enumerated()` member an app declares
|
|
546
|
+
reach statement text unescaped against the one character that is not a quote. With
|
|
547
|
+
`standard_conforming_strings = off` — a SESSION setting, `SET`table by anyone — a backslash
|
|
548
|
+
escapes the character after it inside an ordinary literal: measured on 18.4, `'dd' ~ '^\d+$'` is
|
|
549
|
+
FALSE with the GUC on and **TRUE** with it off, because the server compiles `^d+$` and the CHECK
|
|
550
|
+
silently enforces a pattern nobody wrote; and `'\''` leaves the literal UNTERMINATED, so
|
|
551
|
+
following text becomes string data until the next `'` puts the remainder back into code position
|
|
552
|
+
(reproduced as `syntax error at or near "x') > 0 , '"`). `E'…'` fixes the dialect in the TEXT
|
|
553
|
+
rather than trusting the setting, and **only** when the value carries a backslash — without one
|
|
554
|
+
there is no escape mechanism to disagree about, so every CHECK already generated stays byte for
|
|
555
|
+
byte what it was and nothing regenerates; both tracked apps hold applied migrations whose
|
|
556
|
+
checksums are taken over that text.
|
|
557
|
+
**The rule lives in tier 1 and this package imports it down.** It was written here first, as
|
|
558
|
+
`sql-literal.ts`, and that file is deleted: `@ultimat3/db`'s `literal()` now carries the same
|
|
559
|
+
transformation and the same measurement, `packages/entity` already depends on `@ultimat3/db`, and
|
|
560
|
+
`bun run sql-literal-copies` refuses any module outside `packages/db/src/sql.ts` that turns `'`
|
|
561
|
+
into `''` — matched on the TRANSFORMATION, because the three copies were called `literal`,
|
|
562
|
+
`literalText` and an unnamed inline splice, and a name-based rule reads past the third exactly as
|
|
563
|
+
one spelled `RenderMode` read past `PwaRenderMode`. `expr.ts` keeps a four-type wrapper that
|
|
564
|
+
delegates and unwraps `.text` — `Invariant.sql` is a bare string and a `SqlFragment` cannot
|
|
565
|
+
survive that round trip — and it re-spells nothing. **The half that ratchet cannot see is a
|
|
566
|
+
producer DROPPING the call**: `` `'${value}'` `` doubles no quote, so it matches no rule, which is
|
|
567
|
+
why `expr.test.ts` pins all four splice sites (`contains`, `eq`, `matches`, `oneOf`) against a
|
|
568
|
+
quote-bearing and a backslash-bearing value. A fifth producer added without the call fails there.
|
|
484
569
|
- **Every physical name is checked, including the DERIVED one — `As of 2026-08-24`, and it was a
|
|
485
570
|
DDL injection.** `columnName` is `meta.name ?? snake(property)` and only the first branch reached
|
|
486
571
|
`assertColumnName` for three majors, while `snake()` lower-cases and does nothing else. A column
|
|
@@ -1022,8 +1107,18 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
1022
1107
|
legal list — because an unknown state has no outgoing moves either, and a check that skipped it
|
|
1023
1108
|
reported a typo as "the row is terminal in `pendign`".
|
|
1024
1109
|
- Never throw a bare `Error` — use `errors.ts`.
|
|
1025
|
-
- Tests restore the process-global registry in `afterAll` (`clearRegistry()`)
|
|
1026
|
-
|
|
1110
|
+
- **Tests restore the process-global registry in `afterAll` (`clearRegistry()`), and the hook is at
|
|
1111
|
+
FILE scope — a build error since 2026-08-25, because the prose form was violated by 19 of the 19
|
|
1112
|
+
live suites that had it.** A leaked registry breaks an unrelated package's tests, as it did in
|
|
1113
|
+
`@ultimat3/policy`. Bun evaluates a skipped file's module body and then runs no hook inside
|
|
1114
|
+
`describe.skipIf(true)` (measured in `live-registry-cleanup.test.ts`), so a `clearRegistry()`
|
|
1115
|
+
parked in the suite's teardown — beside `drop table`, where it reads as belonging — never ran in
|
|
1116
|
+
the ONE configuration a live suite is never deliberately run in: **36 entities stayed registered**
|
|
1117
|
+
across `packages/entity/src/*.live.test.ts` with `TEST_DATABASE_URL` unset, which is every CI run
|
|
1118
|
+
of the unit gate. An `if (!hasPostgres) return` above the call is the same hole by a second route.
|
|
1119
|
+
So the cleanup is its own top-level `afterAll(() => { clearRegistry(); })` at the end of the file,
|
|
1120
|
+
matched on exact text by `live-registry-cleanup.test.ts`, which also refuses a live suite that
|
|
1121
|
+
registers on import and imports no seam. Never put it back in the teardown.
|
|
1027
1122
|
|
|
1028
1123
|
## Files
|
|
1029
1124
|
|
|
@@ -1065,6 +1160,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
1065
1160
|
| `n-plus-one.ts` | a repeated statement → the error whose `fix` is the preload or bulk call that ends it |
|
|
1066
1161
|
| `seed.ts` | `defineSeed` — the replayable fixture graph: `insert` (the seed's own ids), `upsert` (a natural key), the sentinel reads, and the tier table `x db seed` refuses from |
|
|
1067
1162
|
| `type-pins.ts` | compile-time assertions `tsc` checks — the column proxy, `Invariant` variance, the branded id |
|
|
1163
|
+
| `live-registry-cleanup.test.ts` | the build error behind the registry rule above: a live suite that registers on import clears unconditionally, in a top-level hook a skip cannot swallow |
|
|
1068
1164
|
|
|
1069
1165
|
## Commands
|
|
1070
1166
|
|
package/README.md
CHANGED
|
@@ -155,6 +155,37 @@ A rule written as a JS predicate — `c.slug.matches(isValidSlug)`, `c.satisfies
|
|
|
155
155
|
still runs on write, reports `kind: 'assert'` and `sql: null`, and is what `x verify` warns
|
|
156
156
|
about: a rule the database does not know is a rule a migration script can violate.
|
|
157
157
|
|
|
158
|
+
A `RegExp` is not a predicate and does reach the database: `c.slug.matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)`
|
|
159
|
+
is `kind: 'check'` and emits `slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'`. The **same string** runs in both
|
|
160
|
+
places — nothing is translated — so a construct the two engines read differently is REFUSED where it
|
|
161
|
+
is written, with the portable spelling in the `fix` where one exists (`\w` -> `[A-Za-z0-9_]`, `.` ->
|
|
162
|
+
`[^\n\r]`) and the app-only predicate where none does (`\b` is a word boundary to `.test()` and a
|
|
163
|
+
BACKSPACE to Postgres). `i` becomes `~*`; every other flag is refused.
|
|
164
|
+
|
|
165
|
+
`isNull()` / `isNotNull()` are the only pair in the vocabulary TOTAL over NULL — `IS NULL` answers
|
|
166
|
+
true or false for every input, and the app side reads an ABSENT key and a stored `null` as one value.
|
|
167
|
+
`iff(a, b)` is the biconditional over two predicates, rendered `(a) = (b)`, which is what Postgres
|
|
168
|
+
spells one as:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { entity, enumerated, iff, invariant, timestamp, uuid } from '@ultimat3/entity';
|
|
172
|
+
|
|
173
|
+
export const publishable = entity('publishable', {
|
|
174
|
+
columns: {
|
|
175
|
+
id: uuid().primaryKey(),
|
|
176
|
+
status: enumerated(['draft', 'published']).default('draft'),
|
|
177
|
+
publishedAt: timestamp().nullable(),
|
|
178
|
+
},
|
|
179
|
+
invariants: (c) => [
|
|
180
|
+
invariant('post_publish_coherent', iff(c.status.eq('published'), c.publishedAt.isNotNull())),
|
|
181
|
+
// check ((status = 'published') = (published_at is not null))
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
A `satisfies(fn, [...])` that could be written this way should be: only one of the two reaches the
|
|
187
|
+
database, and `x verify` reports the other as a rule the database does not know.
|
|
188
|
+
|
|
158
189
|
## One typed handle
|
|
159
190
|
|
|
160
191
|
```ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "16.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": "16.0.0",
|
|
35
|
+
"@ultimat3/db": "16.0.0",
|
|
36
|
+
"@ultimat3/schema": "16.0.0",
|
|
37
|
+
"@ultimat3/time": "16.0.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/column-values.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// the CHECK a closed set of values emits. Here rather than in `columns.ts` so `enum-column.ts` can
|
|
3
3
|
// read them without importing the file that imports it.
|
|
4
4
|
|
|
5
|
+
import { literal } from '@ultimat3/db';
|
|
5
6
|
import { describeValue } from '@ultimat3/schema';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -21,9 +22,16 @@ import { describeValue } from '@ultimat3/schema';
|
|
|
21
22
|
*/
|
|
22
23
|
export const got = (value: unknown): string => `got ${describeValue(value)}`;
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The value list of a closed set, quoted by `@ultimat3/db`'s `literal()` — the framework's one
|
|
27
|
+
* splice rule, imported downward rather than restated. This file carried its own
|
|
28
|
+
* `'${v.replaceAll("'", "''")}'` and `expr.ts` carried the same line; two copies of an escape is two
|
|
29
|
+
* places a hardening has to land, and only one of them ever does. The members are an APP's own
|
|
30
|
+
* `enumerated([...])` array, so they reach `create table` as text nothing validated.
|
|
31
|
+
*
|
|
32
|
+
* `.text` because a column's `check` is a bare string all the way to the DDL, not a `SqlFragment`.
|
|
33
|
+
*/
|
|
26
34
|
export const oneOf =
|
|
27
35
|
(values: readonly string[]) =>
|
|
28
36
|
(name: string): string =>
|
|
29
|
-
`${name} in (${values.map(
|
|
37
|
+
`${name} in (${values.map((value) => literal(value).text).join(', ')})`;
|
package/src/containment.ts
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
// Every rule here is Postgres', reproduced rather than approximated. Where the two could not be
|
|
8
8
|
// made to agree the operator is refused instead (`memory-match.ts`), never guessed at.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
const isNull = (value: unknown): boolean => value === null || value === undefined;
|
|
10
|
+
import { isNullish as isNull } from './is-null';
|
|
12
11
|
|
|
13
12
|
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
14
13
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
package/src/expr.ts
CHANGED
|
@@ -7,11 +7,31 @@
|
|
|
7
7
|
// A JS predicate (`matches(isValidSlug)`, `satisfies(fn, [...])`) cannot be translated to SQL.
|
|
8
8
|
// It still runs in the app, and reports `sql: null` so `x verify` can warn that the database
|
|
9
9
|
// does not know this rule — silently pretending it reached Postgres would be worse.
|
|
10
|
+
//
|
|
11
|
+
// A `RegExp` is the other case and it DOES reach SQL, because nothing about it is translated: the
|
|
12
|
+
// source `pattern.test` runs is the source spliced into the CHECK. `pattern-portability.ts` is what
|
|
13
|
+
// makes that legal, and `@ultimat3/db`'s `literal()` is what keeps the splice inside its own quotes.
|
|
10
14
|
|
|
15
|
+
import { literal as sqlLiteral } from '@ultimat3/db';
|
|
11
16
|
import { invariantViolated } from './errors';
|
|
17
|
+
import { isNullish } from './is-null';
|
|
18
|
+
import { unportableConstruct } from './pattern-portability';
|
|
12
19
|
import { refuseInvariant } from './refuse';
|
|
13
20
|
import type { ColumnMap } from './types';
|
|
14
21
|
|
|
22
|
+
/**
|
|
23
|
+
* A declared operand as SQL TEXT. The escape itself is `@ultimat3/db`'s `literal()` — tier 1 owns
|
|
24
|
+
* that rule and this is an ordinary downward import — and nothing here re-spells it; the wrapper
|
|
25
|
+
* exists for two narrower reasons.
|
|
26
|
+
*
|
|
27
|
+
* It takes the four types a CHECK operand can be, rather than `unknown`: every call below already
|
|
28
|
+
* knows which one it holds, and a widened parameter would put `String(someObject)` into statement
|
|
29
|
+
* text as `[object Object]`. And it unwraps `.text`, because `Invariant.sql` is a bare string that
|
|
30
|
+
* `@ultimat3/db` re-renders at DDL time — a `SqlFragment` cannot survive that round trip.
|
|
31
|
+
*/
|
|
32
|
+
const literal = (value: string | number | boolean | bigint): string =>
|
|
33
|
+
typeof value === 'string' ? sqlLiteral(value).text : String(value);
|
|
34
|
+
|
|
15
35
|
export type Row = Readonly<Record<string, unknown>>;
|
|
16
36
|
|
|
17
37
|
/** Property path -> physical column name. */
|
|
@@ -38,11 +58,24 @@ export interface ColumnExpr {
|
|
|
38
58
|
trimmed(): ColumnExpr;
|
|
39
59
|
minLength(length: number): Expr;
|
|
40
60
|
contains(value: string): Expr;
|
|
41
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* A `RegExp` reaches the database as `~`/`~*` over its own source; a function is app-only. A
|
|
63
|
+
* construct the two engines read differently is refused at declaration, never emitted as a
|
|
64
|
+
* lookalike — `pattern-portability.ts` names the subset and why each exclusion is in it.
|
|
65
|
+
*/
|
|
42
66
|
matches(pattern: RegExp | ((value: string) => boolean)): Expr;
|
|
43
67
|
atLeast(value: number | bigint): Expr;
|
|
44
68
|
eq(value: string | number | boolean | bigint | ColumnExpr): Expr;
|
|
45
69
|
isTrue(): Expr;
|
|
70
|
+
/**
|
|
71
|
+
* `col is null` / `col is not null`, and the only pair in this vocabulary that is TOTAL over
|
|
72
|
+
* NULL in both halves: Postgres' `IS NULL` answers true or false for every input including NULL,
|
|
73
|
+
* and the app side reads absent and `null` as one value (`is-null.ts`). Every other operator
|
|
74
|
+
* here answers NULL in SQL for a NULL operand, and a CHECK PASSES on NULL — which is why these
|
|
75
|
+
* two are what an `iff` can be built out of.
|
|
76
|
+
*/
|
|
77
|
+
isNull(): Expr;
|
|
78
|
+
isNotNull(): Expr;
|
|
46
79
|
/** Money is two physical columns; these are how a rule names one of them. */
|
|
47
80
|
readonly minor: ColumnExpr;
|
|
48
81
|
readonly currency: ColumnExpr;
|
|
@@ -75,9 +108,6 @@ const walk = (row: Row, path: readonly string[]): unknown =>
|
|
|
75
108
|
row,
|
|
76
109
|
);
|
|
77
110
|
|
|
78
|
-
const literal = (value: unknown): string =>
|
|
79
|
-
typeof value === 'string' ? `'${value.replaceAll("'", "''")}'` : String(value);
|
|
80
|
-
|
|
81
111
|
/**
|
|
82
112
|
* The Postgres operator a `RegExp`'s flags mean — the second half of "one declaration, two
|
|
83
113
|
* enforcement points". `toSql` used to emit `~ <pattern.source>` and nothing else, so
|
|
@@ -106,6 +136,41 @@ const matchOperator = (pattern: RegExp): string => {
|
|
|
106
136
|
return pattern.ignoreCase ? '~*' : '~';
|
|
107
137
|
};
|
|
108
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The SQL a `RegExp` becomes — or the refusal naming the construct that would have made the two
|
|
141
|
+
* halves mean different things.
|
|
142
|
+
*
|
|
143
|
+
* Nothing is TRANSLATED here and nothing ever should be: the string handed to `pattern.test` and
|
|
144
|
+
* the string spliced into the CHECK are the SAME string, and `unportableConstruct` is what makes
|
|
145
|
+
* that legal. Emitting a "close enough" POSIX rewrite of a JavaScript-only construct would ship two
|
|
146
|
+
* rules under one name, which is strictly worse than the `assert` a predicate already gives you.
|
|
147
|
+
*
|
|
148
|
+
* Flags are judged first: a flag is a property of the whole pattern and a construct is one position
|
|
149
|
+
* inside it, so the refusal an author can act on without reading an index goes out first.
|
|
150
|
+
*
|
|
151
|
+
* The source is spliced through `literal`, never quoted here, and a PATTERN is the sharpest case
|
|
152
|
+
* for why that rule is `@ultimat3/db`'s and not a doubled quote: measured on 18.4, `'dd' ~ '^\d+$'`
|
|
153
|
+
* is FALSE with `standard_conforming_strings` on and **TRUE** with it off, because the server
|
|
154
|
+
* compiles `^d+$` — a CHECK enforcing a pattern the author never wrote, with no error anywhere.
|
|
155
|
+
* A backslash is in almost every real pattern, so almost every real pattern depends on the `E'…'`
|
|
156
|
+
* half. `pg-invariant-pattern.live.test.ts` runs that exact pair under both settings.
|
|
157
|
+
*/
|
|
158
|
+
const patternSql = (pattern: RegExp): string => {
|
|
159
|
+
const operator = matchOperator(pattern);
|
|
160
|
+
const unportable = unportableConstruct(pattern.source);
|
|
161
|
+
const spelled = `/${pattern.source}/${pattern.flags}`;
|
|
162
|
+
if (unportable !== undefined) {
|
|
163
|
+
return refuseInvariant(
|
|
164
|
+
'matches',
|
|
165
|
+
`${spelled} uses ${unportable.construct} at index ${unportable.at}, which ${unportable.why} — the CHECK and pattern.test() would answer differently for the same row`,
|
|
166
|
+
unportable.instead === undefined
|
|
167
|
+
? `matches((value) => ${spelled}.test(value)) # app-only: the rule stays in TS and reports sql: null, so no CHECK claims to enforce it`
|
|
168
|
+
: `write ${unportable.instead} where ${spelled} has ${unportable.construct} — one meaning in both engines — then x db gen`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return `${operator} ${literal(pattern.source)}`;
|
|
172
|
+
};
|
|
173
|
+
|
|
109
174
|
const check = (
|
|
110
175
|
paths: readonly (readonly string[])[],
|
|
111
176
|
message: string,
|
|
@@ -158,10 +223,7 @@ const expr = (term: Term): ColumnExpr => {
|
|
|
158
223
|
// Read at DECLARATION, not inside `toSql`: an unsupported flag is the author's mistake and
|
|
159
224
|
// the entity file is where it is repaired, so the refusal lands on the line that wrote it
|
|
160
225
|
// rather than during migration generation, where the entity name is all anyone would see.
|
|
161
|
-
const emitted =
|
|
162
|
-
pattern instanceof RegExp
|
|
163
|
-
? `${matchOperator(pattern)} ${literal(pattern.source)}`
|
|
164
|
-
: undefined;
|
|
226
|
+
const emitted = pattern instanceof RegExp ? patternSql(pattern) : undefined;
|
|
165
227
|
return one(
|
|
166
228
|
`${term.label} must match ${pattern instanceof RegExp ? pattern.source : pattern.name || 'the rule'}`,
|
|
167
229
|
(resolve) => (emitted === undefined ? null : `${term.sql(resolve)} ${emitted}`),
|
|
@@ -194,6 +256,16 @@ const expr = (term: Term): ColumnExpr => {
|
|
|
194
256
|
(value) => value === true,
|
|
195
257
|
),
|
|
196
258
|
|
|
259
|
+
isNull: () =>
|
|
260
|
+
one(`${term.label} is not set`, (resolve) => `${term.sql(resolve)} is null`, isNullish),
|
|
261
|
+
|
|
262
|
+
isNotNull: () =>
|
|
263
|
+
one(
|
|
264
|
+
`${term.label} is set`,
|
|
265
|
+
(resolve) => `${term.sql(resolve)} is not null`,
|
|
266
|
+
(value) => !isNullish(value),
|
|
267
|
+
),
|
|
268
|
+
|
|
197
269
|
get minor() {
|
|
198
270
|
return part(term, 'minor');
|
|
199
271
|
},
|
|
@@ -261,6 +333,71 @@ const satisfies = (predicate: RowPredicate, columns: readonly string[]): Expr =>
|
|
|
261
333
|
) === true,
|
|
262
334
|
);
|
|
263
335
|
|
|
336
|
+
/**
|
|
337
|
+
* `a` and `b` hold together or not at all — the biconditional, rendered `(a) = (b)`, which is what
|
|
338
|
+
* Postgres spells one as: `=` between two booleans IS iff there.
|
|
339
|
+
*
|
|
340
|
+
* A FUNCTION and not a method on `Expr`, for two reasons that both come from the type. `Expr` is
|
|
341
|
+
* exported, so a required member is a breaking change to anything implementing it structurally; and
|
|
342
|
+
* `kind: 'unique'` is an `Expr` whose `toSql` is a COLUMN LIST, so `c.unique([…]).iff(…)` would be a
|
|
343
|
+
* method that exists on the type and is meaningless for some of its values. Refusing that operand in
|
|
344
|
+
* one place beats putting the method where it cannot mean anything. Symmetric reads symmetric, too.
|
|
345
|
+
*
|
|
346
|
+
* **`=` and not `is not distinct from`, decided on a measurement.** With both operands total the two
|
|
347
|
+
* are identical for all four boolean pairs. They part when an operand is NULL — a predicate on a
|
|
348
|
+
* nullable column — and there `=` answers NULL, which a CHECK PASSES, while `is not distinct from`
|
|
349
|
+
* answers false, which a CHECK REFUSES. The app side reads a NULL operand as false either way, so
|
|
350
|
+
* the total form is the one that refuses a row TypeScript ACCEPTED: `(NULL) is not distinct from
|
|
351
|
+
* (false)` is false where `false === false` is true. That is the raw `23514` in place of
|
|
352
|
+
* `X_INVARIANT_VIOLATED` this whole file exists against, and it is why the more permissive spelling
|
|
353
|
+
* is the safer one. `pg-invariant-null.live.test.ts` measures both.
|
|
354
|
+
*
|
|
355
|
+
* So an operand that can be NULL leaves the CHECK permissive — the language's one existing
|
|
356
|
+
* disagreement, inherited here and not widened. `isNull()`/`isNotNull()` are total, which is what
|
|
357
|
+
* makes a rule built from them exact.
|
|
358
|
+
*/
|
|
359
|
+
export const iff = (left: Expr, right: Expr): Expr => {
|
|
360
|
+
for (const side of [left, right] as const) {
|
|
361
|
+
if (side.kind !== 'unique') continue;
|
|
362
|
+
// The columns it names, so the pasted line is the rule the author already meant to declare —
|
|
363
|
+
// never a `<name>` for them to fill in, which is the placeholder `refuse.test.ts` refuses.
|
|
364
|
+
//
|
|
365
|
+
// `JSON.stringify` and never `'${column}'`: a column path is a VALUE reaching TypeScript
|
|
366
|
+
// SOURCE, which is this file's own hazard one layer up. `columns: { "o'brien": text() }` is a
|
|
367
|
+
// legal declaration and `unique()` is reached untyped besides, so a quote ends the literal and
|
|
368
|
+
// the fix stops parsing; a backslash is the half doubling the quote would still have missed.
|
|
369
|
+
// `errors.ts`'s `asLiteral` is the same rule for the same reason.
|
|
370
|
+
const columns = side.paths.map((path) => path.join('.'));
|
|
371
|
+
const list = columns.map((column) => JSON.stringify(column)).join(', ');
|
|
372
|
+
const name = JSON.stringify(`${columns.join('_')}_unique`);
|
|
373
|
+
refuseInvariant(
|
|
374
|
+
'iff',
|
|
375
|
+
`${side.message} is a unique constraint, whose SQL is a column list and not a predicate`,
|
|
376
|
+
`invariant(${name}, c.unique([${list}])) # uniqueness is its own invariant; iff takes two predicates, e.g. iff(c.status.eq('published'), c.publishedAt.isNotNull())`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const seen = new Set<string>();
|
|
380
|
+
const paths: (readonly string[])[] = [];
|
|
381
|
+
for (const path of [...left.paths, ...right.paths]) {
|
|
382
|
+
const key = path.join('.');
|
|
383
|
+
if (seen.has(key)) continue;
|
|
384
|
+
seen.add(key);
|
|
385
|
+
paths.push(path);
|
|
386
|
+
}
|
|
387
|
+
return check(
|
|
388
|
+
paths,
|
|
389
|
+
`${left.message} exactly when ${right.message}`,
|
|
390
|
+
(resolve) => {
|
|
391
|
+
// One app-only operand makes the WHOLE rule app-only: `(null) = (…)` is not a predicate, and
|
|
392
|
+
// emitting half of a biconditional would enforce something the author never wrote.
|
|
393
|
+
const a = left.toSql(resolve);
|
|
394
|
+
const b = right.toSql(resolve);
|
|
395
|
+
return a === null || b === null ? null : `(${a}) = (${b})`;
|
|
396
|
+
},
|
|
397
|
+
(row) => left.holds(row) === right.holds(row),
|
|
398
|
+
);
|
|
399
|
+
};
|
|
400
|
+
|
|
264
401
|
/**
|
|
265
402
|
* The `c` an invariant is written against. Still a Proxy even though `InvariantColumns<C>` now
|
|
266
403
|
* catches a typo at compile time: a JS caller, a dynamically built rule and a `satisfies()` column
|
package/src/index.ts
CHANGED
|
@@ -65,6 +65,7 @@ export {
|
|
|
65
65
|
writeUnfiltered,
|
|
66
66
|
} from './errors';
|
|
67
67
|
export type { ColumnExpr, Expr, InvariantColumns, Resolve } from './expr';
|
|
68
|
+
export { iff } from './expr';
|
|
68
69
|
/** The two DECLARED capabilities' refusals — a third-party driver raises the same ones. */
|
|
69
70
|
export type { IllegalTransition } from './feature-errors';
|
|
70
71
|
export {
|
package/src/is-null.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Single responsibility: what this package means by NULL when it looks at a row. One rule, because
|
|
2
|
+
// three files had their own copy of it — `memory-match.ts`, `containment.ts` and, the moment
|
|
3
|
+
// `isNull()` joined the invariant vocabulary, `expr.ts`.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Absent and NULL are one value. `undefined` is a key nobody typed or a column a projection left
|
|
7
|
+
* out; `null` is one the row spelled. Postgres cannot tell them apart and neither may anything
|
|
8
|
+
* reading a row here.
|
|
9
|
+
*
|
|
10
|
+
* A copy is not free: the rule decides whether a row the caller never NAMED a column on is the same
|
|
11
|
+
* row as one that stored `null`, and the table holds NULL for both. `===` made them two, and
|
|
12
|
+
* `eq null` then skipped the absent row while `neq null` answered it — the opposite of the same
|
|
13
|
+
* predicate in production.
|
|
14
|
+
*/
|
|
15
|
+
export const isNullish = (value: unknown): boolean => value === null || value === undefined;
|
package/src/memory-match.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { EntityCore } from './entity';
|
|
|
12
12
|
import { EntityError } from './errors';
|
|
13
13
|
import { searchInMemory } from './feature-errors';
|
|
14
14
|
import { instantMicros } from './instant';
|
|
15
|
+
import { isNullish as isNull } from './is-null';
|
|
15
16
|
import type { Predicate } from './tenancy';
|
|
16
17
|
import type { ColumnKind } from './types';
|
|
17
18
|
|
|
@@ -34,9 +35,6 @@ import type { ColumnKind } from './types';
|
|
|
34
35
|
*/
|
|
35
36
|
const DECIMAL_TEXT: ReadonlySet<ColumnKind> = new Set<ColumnKind>(['bigint', 'numeric']);
|
|
36
37
|
|
|
37
|
-
/** Absent and NULL are one thing to a predicate: a column the projection left out is not a value. */
|
|
38
|
-
const isNull = (value: unknown): boolean => value === null || value === undefined;
|
|
39
|
-
|
|
40
38
|
const sign = <T extends number | bigint | string>(left: T, right: T): number =>
|
|
41
39
|
left < right ? -1 : left > right ? 1 : 0;
|
|
42
40
|
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Single responsibility: decide whether every construct in a regex source means the SAME thing to
|
|
2
|
+
// `RegExp.prototype.test` and to Postgres' `~`. Nothing here escapes, emits or refuses — it names
|
|
3
|
+
// the first construct that does not, and `expr.ts` turns that into the refusal.
|
|
4
|
+
//
|
|
5
|
+
// WHY it has to exist: `matches(/…/)` puts ONE string in front of two engines, and JavaScript's
|
|
6
|
+
// RegExp and Postgres' ARE are not the same language. `\b` is a word boundary in one and a
|
|
7
|
+
// BACKSPACE character in the other; both compile, neither errors, and the CHECK enforces a rule the
|
|
8
|
+
// app never wrote. Refusing the construct is the only outcome that keeps "one declaration, two
|
|
9
|
+
// enforcement points" true.
|
|
10
|
+
|
|
11
|
+
/** The first construct in a pattern the two engines disagree about. */
|
|
12
|
+
export interface UnportablePattern {
|
|
13
|
+
/** As it appears in the source: `\b`, `.`, `[:`, `a-é`. */
|
|
14
|
+
readonly construct: string;
|
|
15
|
+
/** Offset of `construct` in the source, so the refusal can point at it. */
|
|
16
|
+
readonly at: number;
|
|
17
|
+
/** One sentence naming BOTH readings — never "unsupported". */
|
|
18
|
+
readonly why: string;
|
|
19
|
+
/** A spelling that means the same thing in both, when one exists. */
|
|
20
|
+
readonly instead: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A cursor past the construct just read, or the refusal that ends the scan. */
|
|
24
|
+
type Step = number | UnportablePattern;
|
|
25
|
+
|
|
26
|
+
const isRefusal = (step: Step): step is UnportablePattern => typeof step !== 'number';
|
|
27
|
+
|
|
28
|
+
const refuse = (
|
|
29
|
+
construct: string,
|
|
30
|
+
at: number,
|
|
31
|
+
why: string,
|
|
32
|
+
instead: string | undefined,
|
|
33
|
+
): UnportablePattern => ({ construct, at, why, instead });
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `\` + one of these is the same character class or control character in both engines. `\d` earns
|
|
37
|
+
* its place by measurement, not by reading: POSIX fixes `[[:digit:]]` at the ten ASCII digits in
|
|
38
|
+
* every locale, and `'٣' ~ '^\d$'` and `'5' ~ '^\d$'` are both false on a UTF-8 server, exactly as
|
|
39
|
+
* `/^\d$/` is. `\w` and `\s` are the two that look like they belong here and do not.
|
|
40
|
+
*/
|
|
41
|
+
const PORTABLE_ESCAPES = new Set(['d', 'D', 'n', 'r', 't', 'f', 'v']);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The escapes that compile on both sides and mean different things. Measured on PostgreSQL 18.4,
|
|
45
|
+
* UTF8, `en_US.utf8` — the pairs are in `pg-invariant-pattern.live.test.ts`, which re-runs them
|
|
46
|
+
* against whatever server is configured so a future Postgres cannot quietly change one.
|
|
47
|
+
*
|
|
48
|
+
* A `Map` and not a frozen record: the key is data read from a pattern, and `TABLE[key]` on an
|
|
49
|
+
* object literal answers an `Object.prototype` member for `constructor` and `toString`.
|
|
50
|
+
*/
|
|
51
|
+
const DIVERGENT_ESCAPES = new Map<string, readonly [why: string, instead: string | undefined]>([
|
|
52
|
+
['b', ['is a word boundary in JavaScript and a BACKSPACE character in Postgres', undefined]],
|
|
53
|
+
['B', ['is a non-word-boundary in JavaScript and a literal backslash in Postgres', undefined]],
|
|
54
|
+
[
|
|
55
|
+
'w',
|
|
56
|
+
[
|
|
57
|
+
"is [A-Za-z0-9_] in JavaScript and the LOCALE's alphanumeric class in Postgres, which matches é",
|
|
58
|
+
'[A-Za-z0-9_]',
|
|
59
|
+
],
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
'W',
|
|
63
|
+
[
|
|
64
|
+
"is [^A-Za-z0-9_] in JavaScript and the complement of the LOCALE's alphanumeric class in Postgres",
|
|
65
|
+
'[^A-Za-z0-9_]',
|
|
66
|
+
],
|
|
67
|
+
],
|
|
68
|
+
[
|
|
69
|
+
's',
|
|
70
|
+
[
|
|
71
|
+
'matches U+00A0 and the Unicode separators in JavaScript, which Postgres’ [[:space:]] does not',
|
|
72
|
+
'[ \\t\\n\\r\\f\\v]',
|
|
73
|
+
],
|
|
74
|
+
],
|
|
75
|
+
['S', ['is the complement of a class the two engines do not agree on', '[^ \\t\\n\\r\\f\\v]']],
|
|
76
|
+
['A', ['anchors the start of the string in Postgres and is the letter A in JavaScript', '^']],
|
|
77
|
+
['Z', ['anchors the end of the string in Postgres and is the letter Z in JavaScript', '$']],
|
|
78
|
+
['y', ['is a word boundary in Postgres and the letter y in JavaScript', undefined]],
|
|
79
|
+
['Y', ['is a non-word-boundary in Postgres and the letter Y in JavaScript', undefined]],
|
|
80
|
+
['m', ['anchors a word start in Postgres and is the letter m in JavaScript', undefined]],
|
|
81
|
+
['M', ['anchors a word end in Postgres and is the letter M in JavaScript', undefined]],
|
|
82
|
+
['a', ['is BEL in Postgres and the letter a in JavaScript', undefined]],
|
|
83
|
+
['e', ['is ESC in Postgres and the letter e in JavaScript', undefined]],
|
|
84
|
+
['x', ['reads up to three hex digits in Postgres and exactly two in JavaScript', undefined]],
|
|
85
|
+
['U', ['is an 8-digit codepoint in Postgres and the letter U in JavaScript', undefined]],
|
|
86
|
+
['c', ['is a control escape the two engines delimit differently', undefined]],
|
|
87
|
+
['k', ['names a group Postgres cannot declare', undefined]],
|
|
88
|
+
['p', ['is a Unicode property in JavaScript and an error in Postgres', undefined]],
|
|
89
|
+
['P', ['is a negated Unicode property in JavaScript and an error in Postgres', undefined]],
|
|
90
|
+
['0', ['is NUL in JavaScript and no Postgres text can hold one', undefined]],
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
const BACKREFERENCE = [
|
|
94
|
+
'is a backreference, and the two engines number their groups differently once a lookaround is',
|
|
95
|
+
'involved',
|
|
96
|
+
].join(' ');
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The group openings that survive both engines. `(?:` `(?=` `(?!` `(?<=` `(?<!` are measured to
|
|
100
|
+
* agree — each has a row in `pg-invariant-pattern.live.test.ts`'s agreement table, the two
|
|
101
|
+
* LOOKBEHINDS only since 2026-08-25: this sentence shipped naming five and the table ran three, so
|
|
102
|
+
* it was broader than the evidence for as long as it existed. The whole rest of the `(?` family is
|
|
103
|
+
* refused, because ARE has no named groups at all — `(?<year>…)` is a server-side `invalid regular
|
|
104
|
+
* expression` — and its inline directors (`(?i)`) are not JavaScript syntax.
|
|
105
|
+
*
|
|
106
|
+
* Greediness needs no rule: `~` and `.test()` both answer whether a match EXISTS, and with
|
|
107
|
+
* backreferences refused no amount of greedy-vs-lazy backtracking can change that answer. So
|
|
108
|
+
* `a+?b` stays in.
|
|
109
|
+
*/
|
|
110
|
+
const GROUP_OPENINGS = ['(?:', '(?=', '(?!', '(?<=', '(?<!'] as const;
|
|
111
|
+
|
|
112
|
+
/** `{n}` `{n,}` `{n,m}`, the three forms both engines read the same way. */
|
|
113
|
+
const QUANTIFIER = /^\{\d+(?:,\d*)?\}/;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `\uwxyz` is EXACTLY four hex digits in ARE and exactly four in JavaScript without the `u` flag —
|
|
117
|
+
* measured to agree, including inside a bracket expression. It is in the subset for a reason that
|
|
118
|
+
* is not convenience: **Bun returns a regex LITERAL's non-ASCII characters escaped**,
|
|
119
|
+
* `/^é$/.source` is `^\u00E9$`, so refusing the escape would refuse every pattern an i18n rule
|
|
120
|
+
* writes. Anything shorter is `invalid escape` on the server and the letter `u` in JavaScript.
|
|
121
|
+
*/
|
|
122
|
+
const HEX4 = /^[0-9A-Fa-f]{4}/;
|
|
123
|
+
|
|
124
|
+
const isAscii = (char: string): boolean => (char.codePointAt(0) ?? 0) < 0x80;
|
|
125
|
+
|
|
126
|
+
const NUL = refuse(
|
|
127
|
+
'\\0',
|
|
128
|
+
0,
|
|
129
|
+
'is a null byte, which no Postgres text value can hold — the statement never reaches the server',
|
|
130
|
+
undefined,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
function escapeAt(source: string, at: number): Step {
|
|
134
|
+
const next = source[at + 1];
|
|
135
|
+
if (next === undefined) {
|
|
136
|
+
return refuse('\\', at, 'ends the pattern, so there is nothing for it to escape', undefined);
|
|
137
|
+
}
|
|
138
|
+
const divergent = DIVERGENT_ESCAPES.get(next);
|
|
139
|
+
if (divergent !== undefined) return refuse(`\\${next}`, at, divergent[0], divergent[1]);
|
|
140
|
+
if (next === 'u') {
|
|
141
|
+
return HEX4.test(source.slice(at + 2))
|
|
142
|
+
? at + 6
|
|
143
|
+
: refuse(
|
|
144
|
+
'\\u',
|
|
145
|
+
at,
|
|
146
|
+
'is a codepoint escape Postgres reads as exactly four hex digits and JavaScript reads as the letter u when there are fewer',
|
|
147
|
+
undefined,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (PORTABLE_ESCAPES.has(next)) return at + 2;
|
|
151
|
+
if (next >= '1' && next <= '9') return refuse(`\\${next}`, at, BACKREFERENCE, undefined);
|
|
152
|
+
// Postgres reads `\` + any remaining ALPHANUMERIC as a special it has not been taught here, and
|
|
153
|
+
// JavaScript reads it as the letter — the `\b` shape, one letter along. `\` + punctuation is that
|
|
154
|
+
// character literally in both, which is what keeps `\.` `\$` `\-` `\'` `\]` in the subset.
|
|
155
|
+
if (/[0-9A-Za-z]/.test(next)) {
|
|
156
|
+
return refuse(
|
|
157
|
+
`\\${next}`,
|
|
158
|
+
at,
|
|
159
|
+
'is an escape Postgres reads as a special and JavaScript reads as the letter',
|
|
160
|
+
undefined,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (!isAscii(next) || next < ' ') {
|
|
164
|
+
return refuse(
|
|
165
|
+
`\\${next}`,
|
|
166
|
+
at,
|
|
167
|
+
'escapes a character outside printable ASCII, where the two engines are not measured to agree',
|
|
168
|
+
undefined,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return at + 2;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function groupAt(source: string, at: number): Step {
|
|
175
|
+
if (source[at + 1] !== '?') return at + 1;
|
|
176
|
+
const opening = GROUP_OPENINGS.find((form) => source.startsWith(form, at));
|
|
177
|
+
if (opening !== undefined) return at + opening.length;
|
|
178
|
+
return refuse(
|
|
179
|
+
source.slice(at, at + 4),
|
|
180
|
+
at,
|
|
181
|
+
'is a group form Postgres has no syntax for — it has no named groups and no inline flags',
|
|
182
|
+
undefined,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const LEADING_BRACKET_WHY =
|
|
187
|
+
'opens a class whose first ] Postgres reads as a MEMBER and JavaScript reads as the close of an empty class';
|
|
188
|
+
|
|
189
|
+
const RANGE_WHY =
|
|
190
|
+
'is a range whose endpoints Postgres orders by the database COLLATION and JavaScript orders by code point';
|
|
191
|
+
|
|
192
|
+
function bracketAt(source: string, at: number): Step {
|
|
193
|
+
let cursor = source[at + 1] === '^' ? at + 2 : at + 1;
|
|
194
|
+
// `[]a]` is the literal `]` plus `a` to Postgres and an EMPTY class followed by `a]` to
|
|
195
|
+
// JavaScript — measured to disagree, and `\]` is the one spelling both read as a member.
|
|
196
|
+
if (source[cursor] === ']') return refuse('[]', at, LEADING_BRACKET_WHY, '\\]');
|
|
197
|
+
/** The last member that could be the lower end of a range; `undefined` after one is consumed. */
|
|
198
|
+
let previous: string | undefined;
|
|
199
|
+
while (cursor < source.length) {
|
|
200
|
+
const char = source[cursor] ?? '';
|
|
201
|
+
if (char === ']') return cursor + 1;
|
|
202
|
+
if (char === '\u0000') return { ...NUL, at: cursor };
|
|
203
|
+
if (char === '[') {
|
|
204
|
+
const kind = source[cursor + 1] ?? '';
|
|
205
|
+
if (kind === ':' || kind === '.' || kind === '=') {
|
|
206
|
+
return refuse(
|
|
207
|
+
`[${kind}`,
|
|
208
|
+
cursor,
|
|
209
|
+
'opens a POSIX class, collating element or equivalence class, none of which JavaScript has',
|
|
210
|
+
undefined,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
previous = char;
|
|
214
|
+
cursor += 1;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (char === '\\') {
|
|
218
|
+
const step = escapeAt(source, cursor);
|
|
219
|
+
if (isRefusal(step)) return step;
|
|
220
|
+
previous = undefined;
|
|
221
|
+
cursor = step;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const upper = source[cursor + 1];
|
|
225
|
+
if (char === '-' && previous !== undefined && upper !== undefined && upper !== ']') {
|
|
226
|
+
if (upper === '\\') {
|
|
227
|
+
const step = escapeAt(source, cursor + 1);
|
|
228
|
+
if (isRefusal(step)) return step;
|
|
229
|
+
if (!isAscii(previous)) return refuse(`${previous}-\\`, cursor - 1, RANGE_WHY, undefined);
|
|
230
|
+
previous = undefined;
|
|
231
|
+
cursor = step;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (!isAscii(previous) || !isAscii(upper)) {
|
|
235
|
+
return refuse(`${previous}-${upper}`, cursor - 1, RANGE_WHY, undefined);
|
|
236
|
+
}
|
|
237
|
+
previous = undefined;
|
|
238
|
+
cursor += 2;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
previous = char;
|
|
242
|
+
cursor += 1;
|
|
243
|
+
}
|
|
244
|
+
return refuse('[', at, 'opens a bracket expression that is never closed', undefined);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The first construct in `source` the two engines read differently, or `undefined` when the whole
|
|
249
|
+
* pattern is in the subset they agree on.
|
|
250
|
+
*
|
|
251
|
+
* Only the POSTGRES direction is judged: `matches` is handed a built `RegExp`, so JavaScript has
|
|
252
|
+
* already accepted the source by the time this runs.
|
|
253
|
+
*/
|
|
254
|
+
export function unportableConstruct(source: string): UnportablePattern | undefined {
|
|
255
|
+
let at = 0;
|
|
256
|
+
/** Whether a `{n}` here would be a quantifier at all — it needs something to repeat. */
|
|
257
|
+
let repeatable = false;
|
|
258
|
+
while (at < source.length) {
|
|
259
|
+
const char = source[at] ?? '';
|
|
260
|
+
if (char === '\u0000') return { ...NUL, at };
|
|
261
|
+
let step: Step = at + 1;
|
|
262
|
+
if (char === '\\') step = escapeAt(source, at);
|
|
263
|
+
else if (char === '[') step = bracketAt(source, at);
|
|
264
|
+
else if (char === '(') step = groupAt(source, at);
|
|
265
|
+
else if (char === '{') {
|
|
266
|
+
// `/^{2}$/` compiles in JavaScript under Annex B and is `invalid regular expression` on the
|
|
267
|
+
// server, so the migration is the thing that fails. Refusing here moves it to the line that
|
|
268
|
+
// wrote it — and a `{` with nothing before it is that same error, which is why the quantifier
|
|
269
|
+
// has to be judged in position and not by its own shape.
|
|
270
|
+
const quantifier = repeatable ? QUANTIFIER.exec(source.slice(at)) : null;
|
|
271
|
+
step =
|
|
272
|
+
quantifier === null
|
|
273
|
+
? refuse('{', at, 'opens no quantifier Postgres can read', '\\{')
|
|
274
|
+
: at + quantifier[0].length;
|
|
275
|
+
} else if (char === '.') {
|
|
276
|
+
// `'a\nb' ~ 'a.b'` is TRUE and `/a.b/.test('a\nb')` is false: ARE's `.` matches a newline.
|
|
277
|
+
step = refuse('.', at, 'matches a newline in Postgres and never in JavaScript', '[^\\n\\r]');
|
|
278
|
+
}
|
|
279
|
+
if (isRefusal(step)) return step;
|
|
280
|
+
// `^`, `$`, `|` and an opening `(` leave nothing to repeat; everything else does, including a
|
|
281
|
+
// `)` that closed a group and a `]` that closed a class.
|
|
282
|
+
repeatable = char !== '^' && char !== '$' && char !== '|' && char !== '(';
|
|
283
|
+
at = step;
|
|
284
|
+
}
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|