15.x → 16.0.0, entry by entry
One breaking entry, and it fires at entity() — before a migration exists, before anything
reaches a database. Nothing migrates, and an app whose patterns are already portable emits exactly
what it emitted at 15.x.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 | c.<col>.matches(/re/) refuses an unportable construct | your pattern uses \b, ., \w, \s, a backreference, a named group, an inline flag, a POSIX class, \A/\Z, \x, a leading ] in a class, or a non-ASCII range endpoint |
1. matches(/re/) refuses what the two engines read differently
At 15.x this accepted any RegExp and emitted a maybe-equivalent POSIX pattern. That is the
defect, not the refusal: matches(/\bfoo/) shipped a CHECK that compiled cleanly, errored nowhere,
and enforced a BACKSPACE — measured on 18.4, 'foo' ~ '\bfoo' is FALSE while /\bfoo/.test('foo')
is true. Two rules under one name, with nothing anywhere to report it.
Nothing is translated now either, and that is the mechanism: pattern.source is the string
.test() runs and the string spliced into the constraint. That is only safe because every
construct where the engines disagree is refused at declaration.
How to find them: run your entities — bun test, or x db gen. The refusal is at entity()
time, so one run surfaces every one, and each names the construct, its index, both readings, and
the repair.
| Construct | Measured on 18.4 | Write instead |
|---|---|---|
\b | 'foo' ~ '\bfoo' false — ARE reads BACKSPACE | a predicate: matches((v) => /\bfoo/.test(v)), app-only, sql: null |
. | 'a\nb' ~ 'a.b' true, JS false | [^\n\r] |
\w \W | 'é' ~ '^\w$' true — locale alnum class | [A-Za-z0-9_] |
\s \S | '\u00a0' ~ '^\s$' false, JS true | [ \t\n\r\f\v] |
\A \Z | anchor in ARE, a letter in JS | ^ / $ |
\x | 'Д' ~ '^\x414$' true — ARE takes 3 hex, JS 2 | a predicate |
leading ] in a class | []a] differs | \] |
\1–\9, (?<name>…), (?i), [[:alpha:]], non-ASCII range endpoint | backreference, named group, inline flag, POSIX class, collation-ordered range | a predicate |
Kept, and each measured to AGREE: literals, ^ $ | ( ) * + ?, lazy quantifiers, {n}/{n,}/{n,m},
(?: (?= (?! (?<= (?<!, \d \D \n \r \t \f \v, punctuation escapes, bracket expressions
with ASCII ranges, non-ASCII members, and \uwxyz at exactly four hex digits.
Two of those earn a note. \d is in by measurement, not by reading — POSIX pins [[:digit:]]
at the ten ASCII digits, and '٣'/'5' are false on both sides. \uwxyz is in because Bun
escapes a regex literal's non-ASCII characters (/^é$/.source is ^\u00E9$), so refusing it
would have refused every i18n pattern written the ordinary way.
The subset is falsifiable, not asserted. One live test runs every refused construct against a
real server and asserts the two engines still disagree; another runs the kept subset and asserts
they agree. A future Postgres that grows JavaScript's \b turns those red rather than leaving a
stale exclusion in place.
Not breaking, but you will see it
x db gen now emits drop index / drop constraint for a recorded index no entity declares.
At 15.x it emitted nothing, so those objects stayed on the database while the next sidecar stopped
recording them — and drift was green over it. Your first generation after upgrading may carry
drops you did not expect; read them, they are objects your entities genuinely no longer declare.
A recorded unique CONSTRAINT gets a drop constraint … if exists before the drop index,
because drop index on a constraint-backed index is 2BP01 and if exists does not suppress it.