@ultimat3/entity 15.0.0 → 17.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 CHANGED
@@ -245,10 +245,12 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
245
245
  number, answers the read they did. `limit(rows)` was `next({ limit: rows })` and nothing else —
246
246
  no integer check, no positivity check, no ceiling — so an action taking `pageSize` as input and
247
247
  passing it through bound whatever a client sent, and one request could ask for five million rows.
248
- `assertPageSize` is `assertBatchable`'s three refusals in the other call, deliberately under the
248
+ `assertFinitePageSize` is `assertBatchable`'s three refusals in the other call, deliberately under the
249
249
  same code (`X_INVARIANT_VIOLATED`) because `limit(0)` and `inBatches(0)` are one mistake in two
250
250
  places. Called from **both** `limit()` on the chain (so the refusal lands on the line the author
251
- wrote) and `planFor` (so `findMany({ limit })` straight at the repository cannot route around it),
251
+ wrote) and both `plan()` builders, on the RESOLVED page — so `findMany({ limit })` straight at the
252
+ repository cannot route around it, and `bun run finite-bounds` can see the repair, which it could
253
+ not while the screen was spelled `assertPageSize` and took a parameter called `rows` —
252
254
  and `MAX_PAGE_SIZE` bounds `inBatches(size)` too — a batch IS a page, so the ceiling belongs to
253
255
  the range and not to one of the two calls.
254
256
  - **A repository pinned to its own client refuses to run inside a transaction**
@@ -481,6 +483,91 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
481
483
  identifier and says nothing, so two names sharing their first 63 bytes are one index on the
482
484
  server — the same silent collapse one layer down, and invisible to a drift check comparing
483
485
  DECLARED names, which still differ.
486
+ - **`isNull()`/`isNotNull()` are the ONLY total members of the invariant vocabulary, and `iff` is
487
+ built out of them — `As of 2026-08-25`.** Postgres' `IS NULL` answers true or false for every
488
+ input NULL included; every other operator here answers NULL for a NULL operand and **a CHECK
489
+ PASSES on NULL**, so the database is the more permissive half wherever a predicate reads a
490
+ nullable column. The app side reads an ABSENT key and a stored `null` as one value, which is
491
+ `is-null.ts` — one rule, because `memory-match.ts` and `containment.ts` each had a private copy
492
+ and `expr.ts` was about to be the third. `iff(a, b)` renders `(a) = (b)`, byte for byte the shape
493
+ `examples/dummy`'s hand-written `0001_init.sql:67` already holds.
494
+ **`=` and not `is not distinct from`, decided on a measurement, and the reasoning inverts the
495
+ obvious one.** With both operands total the two spellings are identical on all four boolean pairs
496
+ (measured, PG 18.4). They part only on a NULL operand, and there the TOTAL form is the DANGEROUS
497
+ one: `(NULL) is not distinct from (false)` is false and refuses the row, while TypeScript reads a
498
+ NULL operand as false and `false === false` ACCEPTS it — a raw `23514` in place of
499
+ `X_INVARIANT_VIOLATED`, which is the exact failure `matchOperator`'s flag refusal exists against.
500
+ `=` leaves the disagreement in the safe direction, where the app refuses first and no write ever
501
+ reaches a CHECK that would have refused it. `pg-invariant-null.live.test.ts` measures both
502
+ spellings on a real table, and `expr.test.ts` pins the permissive direction so a half-fix that
503
+ flips it fails loudly. **`iff` is a FUNCTION, not a method on `Expr`**: `Expr` is exported, so a
504
+ required member breaks a structural implementer, and `kind: 'unique'` is an `Expr` whose `toSql`
505
+ is a column LIST — a `.iff()` there would be a method that cannot mean anything for some values
506
+ of its own type. That operand is refused in one place, with the `c.unique([…])` invariant the
507
+ author meant spelled out from its own columns — **each path through `JSON.stringify`, `As of
508
+ 2026-08-25`**: a `fix:` is TypeScript to PASTE, so a column name reaching it is a value spliced
509
+ into source, and `'${column}'` produced `invariant('o'brien_unique', …)` on a name carrying a
510
+ quote. `columns: { "o'brien": text() }` is a legal declaration and `unique()` is reached untyped
511
+ by a JS caller besides; a backslash is the half that doubling the quote would still have missed.
512
+ The same defect as an unescaped pattern, one layer up in the error message. **One app-only operand makes the WHOLE rule
513
+ app-only** (`sql: null`, so `bindInvariant` lands it as `assert`): emitting half a biconditional
514
+ would enforce something nobody wrote.
515
+ - **A `matches()` pattern reaches the CHECK as the SAME STRING `pattern.test` runs, or it is
516
+ refused — `As of 2026-08-25`.** Nothing is translated and nothing ever may be: a "close enough"
517
+ POSIX rewrite of a JavaScript-only construct ships two rules under one name, which is worse than
518
+ the `assert` a predicate already gives you. What makes one string in front of two engines legal is
519
+ `pattern-portability.ts`, a scanner over the source that names the first construct ARE and
520
+ ECMAScript read differently, and every entry on it is a MEASUREMENT against a real server, not a
521
+ reading of the docs. The flagship: `'foo' ~ '\bfoo'` is FALSE on Postgres 18.4 and
522
+ `/\bfoo/.test('foo')` is true, because ARE reads `\b` as a BACKSPACE — both compile, neither
523
+ errors, and the CHECK enforces a rule the entity never wrote. So are `.` (matches a newline there
524
+ and never here), `\w` (the locale's alnum class, which matches `é`), `\s` (JavaScript adds
525
+ U+00A0), `[[:alpha:]]`, a leading `]` in a class, `\x` (three hex digits there, two here), `\A`
526
+ and `\Z`, and a named group. `\d` is IN, measured rather than assumed — POSIX fixes
527
+ `[[:digit:]]` at the ten ASCII digits, so `'٣'` and `'5'` are false on both sides. `\uwxyz` is in
528
+ for a reason that is not convenience: **Bun escapes a regex LITERAL's non-ASCII characters**,
529
+ `/^é$/.source` is `^\u00E9$` while `new RegExp('^é$').source` is `^é$`, so refusing the escape
530
+ would refuse every i18n pattern written the ordinary way. The refusal carries the portable
531
+ spelling where one exists and the app-only predicate where none does, and it lands at DECLARATION
532
+ beside `matchOperator`'s flag refusal, on the line that wrote it. `pg-invariant-pattern.live.test.ts`
533
+ runs both halves against a server: every kept construct must AGREE and every refused one must
534
+ still DISAGREE — so a future Postgres that grows JavaScript's `\b` turns the list red instead of
535
+ leaving a stale exclusion in place. **The kept half is only as broad as its table, and the table
536
+ was narrower than the claim from the day it landed, closed 2026-08-25**:
537
+ `pattern-portability.ts` called `(?<=` and `(?<!`
538
+ measured and neither had ever been run, and nor had a capturing group, a top-level `|`, `{n,}`,
539
+ `{n,m}`, `\t`/`\f`/`\v`, an escaped punctuation outside a bracket expression, a bare `]`/`}`, or
540
+ a trailing `-` in a class. All of them agree on 18.4 (73 pairs, 0 disagreements) and all of them
541
+ now have rows; every row is also asserted to be a construct `unportableConstruct` KEEPS, since a
542
+ row for a refused one measures something `matches()` can never emit. What remains unmechanised is
543
+ the direction no source can enumerate — a construct added to the kept set with no row here.
544
+ - **A declared string is spliced by `@ultimat3/db`'s `literal()` and by nothing in this package —
545
+ `As of 2026-08-25`, and doubling the quote is only HALF the rule.** `expr.ts` and
546
+ `column-values.ts` each carried `'${v.replaceAll("'", "''")}'`; a CHECK takes no bind parameters,
547
+ so a `matches()` pattern, a `contains()` needle and every `enumerated()` member an app declares
548
+ reach statement text unescaped against the one character that is not a quote. With
549
+ `standard_conforming_strings = off` — a SESSION setting, `SET`table by anyone — a backslash
550
+ escapes the character after it inside an ordinary literal: measured on 18.4, `'dd' ~ '^\d+$'` is
551
+ FALSE with the GUC on and **TRUE** with it off, because the server compiles `^d+$` and the CHECK
552
+ silently enforces a pattern nobody wrote; and `'\''` leaves the literal UNTERMINATED, so
553
+ following text becomes string data until the next `'` puts the remainder back into code position
554
+ (reproduced as `syntax error at or near "x') > 0 , '"`). `E'…'` fixes the dialect in the TEXT
555
+ rather than trusting the setting, and **only** when the value carries a backslash — without one
556
+ there is no escape mechanism to disagree about, so every CHECK already generated stays byte for
557
+ byte what it was and nothing regenerates; both tracked apps hold applied migrations whose
558
+ checksums are taken over that text.
559
+ **The rule lives in tier 1 and this package imports it down.** It was written here first, as
560
+ `sql-literal.ts`, and that file is deleted: `@ultimat3/db`'s `literal()` now carries the same
561
+ transformation and the same measurement, `packages/entity` already depends on `@ultimat3/db`, and
562
+ `bun run sql-literal-copies` refuses any module outside `packages/db/src/sql.ts` that turns `'`
563
+ into `''` — matched on the TRANSFORMATION, because the three copies were called `literal`,
564
+ `literalText` and an unnamed inline splice, and a name-based rule reads past the third exactly as
565
+ one spelled `RenderMode` read past `PwaRenderMode`. `expr.ts` keeps a four-type wrapper that
566
+ delegates and unwraps `.text` — `Invariant.sql` is a bare string and a `SqlFragment` cannot
567
+ survive that round trip — and it re-spells nothing. **The half that ratchet cannot see is a
568
+ producer DROPPING the call**: `` `'${value}'` `` doubles no quote, so it matches no rule, which is
569
+ why `expr.test.ts` pins all four splice sites (`contains`, `eq`, `matches`, `oneOf`) against a
570
+ quote-bearing and a backslash-bearing value. A fifth producer added without the call fails there.
484
571
  - **Every physical name is checked, including the DERIVED one — `As of 2026-08-24`, and it was a
485
572
  DDL injection.** `columnName` is `meta.name ?? snake(property)` and only the first branch reached
486
573
  `assertColumnName` for three majors, while `snake()` lower-cases and does nothing else. A column
@@ -1022,8 +1109,18 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
1022
1109
  legal list — because an unknown state has no outgoing moves either, and a check that skipped it
1023
1110
  reported a typo as "the row is terminal in `pendign`".
1024
1111
  - Never throw a bare `Error` — use `errors.ts`.
1025
- - Tests restore the process-global registry in `afterAll` (`clearRegistry()`): a leaked registry
1026
- breaks an unrelated package's tests, as it did in `@ultimat3/policy`.
1112
+ - **Tests restore the process-global registry in `afterAll` (`clearRegistry()`), and the hook is at
1113
+ FILE scope — a build error since 2026-08-25, because the prose form was violated by 19 of the 19
1114
+ live suites that had it.** A leaked registry breaks an unrelated package's tests, as it did in
1115
+ `@ultimat3/policy`. Bun evaluates a skipped file's module body and then runs no hook inside
1116
+ `describe.skipIf(true)` (measured in `live-registry-cleanup.test.ts`), so a `clearRegistry()`
1117
+ parked in the suite's teardown — beside `drop table`, where it reads as belonging — never ran in
1118
+ the ONE configuration a live suite is never deliberately run in: **36 entities stayed registered**
1119
+ across `packages/entity/src/*.live.test.ts` with `TEST_DATABASE_URL` unset, which is every CI run
1120
+ of the unit gate. An `if (!hasPostgres) return` above the call is the same hole by a second route.
1121
+ So the cleanup is its own top-level `afterAll(() => { clearRegistry(); })` at the end of the file,
1122
+ matched on exact text by `live-registry-cleanup.test.ts`, which also refuses a live suite that
1123
+ registers on import and imports no seam. Never put it back in the teardown.
1027
1124
 
1028
1125
  ## Files
1029
1126
 
@@ -1065,6 +1162,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
1065
1162
  | `n-plus-one.ts` | a repeated statement → the error whose `fix` is the preload or bulk call that ends it |
1066
1163
  | `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
1164
  | `type-pins.ts` | compile-time assertions `tsc` checks — the column proxy, `Invariant` variance, the branded id |
1165
+ | `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
1166
 
1069
1167
  ## Commands
1070
1168
 
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": "15.0.0",
3
+ "version": "17.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": "15.0.0",
35
- "@ultimat3/db": "15.0.0",
36
- "@ultimat3/schema": "15.0.0",
37
- "@ultimat3/time": "15.0.0"
34
+ "@ultimat3/core": "17.0.0",
35
+ "@ultimat3/db": "17.0.0",
36
+ "@ultimat3/schema": "17.0.0",
37
+ "@ultimat3/time": "17.0.0"
38
38
  }
39
39
  }
@@ -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
- const quote = (value: string): string => `'${value.replaceAll("'", "''")}'`;
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(quote).join(', ')})`;
37
+ `${name} in (${values.map((value) => literal(value).text).join(', ')})`;
@@ -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
- /** Absent and NULL are one thing, exactly as they are to every other predicate. */
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/errors.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // that fixes the situation — `X_DB_DRIFT` is the flagship: it names the table, the
3
3
  // column and the generator invocation.
4
4
  import { registerErrorCodes, UltimateError } from '@ultimat3/core';
5
+ import { shellInertIdentifier } from '@ultimat3/db';
5
6
 
6
7
  /** Codes this package declares and owns. */
7
8
  export const ENTITY_OWNED_ERROR_CODES = [
@@ -293,11 +294,26 @@ export const repoClientPinned = (entityName: string): EntityError =>
293
294
  fix: `setDbClient(client) at boot and build the repository with no client: — postgresDriver() then resolves the open transaction through db() — or run this call outside withTransaction()`,
294
295
  });
295
296
 
297
+ /**
298
+ * The contract's pinned wording. Mirror of `@ultimat3/db`'s `dbDrift()` (`drift-errors.ts`) —
299
+ * keep in sync; both screen through the same `shellInertIdentifier`, so the two lines are one
300
+ * text on both sides of the tier seam, and `errors.test.ts` asserts it rather than asking.
301
+ *
302
+ * The column is the CATALOG's, so it is data, and `x db gen "add C"` puts it inside SHELL DOUBLE
303
+ * QUOTES where `$(…)` and a backtick substitute before `x` is reached at all. The argument is a
304
+ * migration DESCRIPTION, not an identifier, so no quoted form makes a hostile name safe to pass —
305
+ * a refused one is left OUT of the command rather than escaped into it, and read off `cause`.
306
+ */
296
307
  export const dbDrift = (tableName: string, columnName: string): EntityError =>
297
308
  new EntityError({
298
309
  code: 'X_DB_DRIFT',
299
310
  cause: `table "${tableName}" has column "${columnName}" not present in any migration`,
300
- fix: `x db gen "add ${columnName}"`,
311
+ fix:
312
+ shellInertIdentifier(columnName) === null
313
+ ? 'x db gen "add the column named in this error" # its name carries a backtick, a ' +
314
+ 'dollar sign, a quote, a backslash or whitespace, so it is in the cause and not in ' +
315
+ 'this command'
316
+ : `x db gen "add ${columnName}"`,
301
317
  });
302
318
 
303
319
  export const notFound = (entityName: string, id: string): EntityError =>
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
- /** A `RegExp` reaches the database; a function is app-only. */
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;
@@ -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
+ }
package/src/plan.ts CHANGED
@@ -88,8 +88,8 @@ export const totalOrder = <Row>(
88
88
  * query, a third-party driver's caller — cannot route around it. One function, so the two can
89
89
  * never disagree about what a page may be.
90
90
  */
91
- export const assertPageSize = (entityName: string, rows: number): void => {
92
- if (Number.isSafeInteger(rows) && rows >= 1 && rows <= MAX_PAGE_SIZE) return;
91
+ export const assertFinitePageSize = (entityName: string, rows: number): number => {
92
+ if (Number.isSafeInteger(rows) && rows >= 1 && rows <= MAX_PAGE_SIZE) return rows;
93
93
  throw new EntityError({
94
94
  code: 'X_INVARIANT_VIOLATED',
95
95
  cause: `${entityName}.limit(${String(rows)}) — a page is a whole number of rows, at least one and at most ${MAX_PAGE_SIZE}`,
@@ -108,7 +108,6 @@ export const assertPageSize = (entityName: string, rows: number): void => {
108
108
  * `cursorFor` and `seekFrom` still call it: they are reached from a driver directly.
109
109
  */
110
110
  export const planFor = <Row>(entity: EntityCore<Row>, args: FindManyArgs): QueryPlan => {
111
- if (args.limit !== undefined) assertPageSize(entity.$name, args.limit);
112
111
  const orderBy = totalOrder(entity, args.orderBy ?? []);
113
112
  assertSeekable(entity, orderBy);
114
113
  const scoped =
@@ -119,7 +118,10 @@ export const planFor = <Row>(entity: EntityCore<Row>, args: FindManyArgs): Query
119
118
  entity: entity.$name,
120
119
  where: [...(args.where ?? []), ...scoped],
121
120
  orderBy,
122
- limit: args.limit ?? DEFAULT_PAGE_SIZE,
121
+ // Screened on the RESOLVED page, so `findMany({ limit })` straight at the repository — a
122
+ // generated client, a query, a third-party driver's caller — cannot route around the rule the
123
+ // chain's own `limit()` applies.
124
+ limit: assertFinitePageSize(entity.$name, args.limit ?? DEFAULT_PAGE_SIZE),
123
125
  ...(args.cursor === undefined || args.cursor === null ? {} : { cursor: args.cursor }),
124
126
  ...(args.select === undefined ? {} : { select: args.select }),
125
127
  };
package/src/query.ts CHANGED
@@ -9,7 +9,7 @@ import { assertBatchable, batchIterator } from './batch';
9
9
  import { entityNow } from './clock';
10
10
  import type { EntityCore } from './entity';
11
11
  import { searchUndeclared } from './feature-errors';
12
- import { assertPageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan';
12
+ import { assertFinitePageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan';
13
13
  import type { RelatedTables } from './preload';
14
14
  import { preloaded } from './preload';
15
15
  import type { Relation } from './relations';
@@ -305,7 +305,7 @@ const builder = <Source, Row>(
305
305
  // sized. `planFor` applies the identical guard, so a caller reaching the repository directly
306
306
  // cannot go round it.
307
307
  limit: (rows) => {
308
- assertPageSize(entity.$name, rows);
308
+ assertFinitePageSize(entity.$name, rows);
309
309
  return next({ limit: rows });
310
310
  },
311
311
 
@@ -400,7 +400,7 @@ const builder = <Source, Row>(
400
400
  where: state.where,
401
401
  orderBy: state.orderBy,
402
402
  // The page that will actually run, so an unnamed one still reads as the bound it has.
403
- limit: state.limit ?? DEFAULT_PAGE_SIZE,
403
+ limit: assertFinitePageSize(entity.$name, state.limit ?? DEFAULT_PAGE_SIZE),
404
404
  ...(state.cursor === null ? {} : { cursor: state.cursor }),
405
405
  // The projection actually sent, preload keys included: a plan that is safe to log is only
406
406
  // useful if it is the plan that ran.