fix(driver-sql): an autonumber prefix carrying _, % or \ seeds its counter from the stored MAX on SQLite (#21163) - #21206
Conversation
…scapes for scanMaxNumericTail escapes the rendered prefix's backslash, percent and underscore with a backslash, then compiled `where(col, 'like', ...)` through Knex, which declares no ESCAPE clause. SQLite has no LIKE escape character unless one is declared, so on every SQLite face a prefix carrying one of those characters scanned an empty partition: the cold bootstrap seeded from 0 and the collision re-seed could not move the counter. The scan now binds the driver's one LIKE_ESCAPE_CHARACTER on every dialect, as the filter compiler already does. Pins: driver-sql over the dialect cells (sqlite; live pg/mysql where provisioned) and the driver-turso local and remote faces. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8a2e0e19f71eac31cdb820e77df8704ac699d91c && git checkout 8a2e0e19f71eac31cdb820e77df8704ac699d91c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 097ef802700e109aa98a89c2991041516476b6f9 f71f31b29b93ec50a2f3dd02fb18c3e65135c94f && git checkout -B drift-repro 097ef802700e109aa98a89c2991041516476b6f9 && git merge --no-ff f71f31b29b93ec50a2f3dd02fb18c3e65135c94f
node scripts/docs-audit/affected-docs.mjs --json 097ef802700e109aa98a89c2991041516476b6f9
|
|
CI red on this PR's own pins:
Generated by Claude Code |
… on every dialect cell The live mysql cell failed every case, controls included: knex does not support `.returning()` on MySQL, so `SqlDriver.create` hands back the insert id (measured `0` on a live MySQL 8.0.46) instead of the row, and the pin asserted `create`'s return value. The row itself was stored with the right number. The pin now creates with an id it chose and reads the autonumber back through the driver's `findOne`, a reading every cell can make. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
|
Generated by Claude Code |
Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #21163 (body and all five comments, rulings and the three os-dev-reports 5935918486, 5936913914, 5937340497 included), PR #21206 (body, file list, net diff against ① Derived judgments1. One scan, one helper, one bound escape — right. 2. The answer changes only on the SQLite faces — right. SQLite's 3. The pins prove what they claim — right.
4. Gate verdicts on the head (the check-runs API, read 2026-10-01T18:06:21Z; an earlier reading at 2026-10-01T18:00:45Z had ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #21163
Clause-②: no
What changed
SqlDriver.scanMaxNumericTail(packages/drivers/driver-sql/src/sql-driver.ts) is the one read of the highest counter already stored under an autonumber prefix. Its two callers are the cold bootstrap ingetNextSequenceValueand the #5495 re-seedresyncSequenceToDataMax. It escapes the rendered prefix withescapeLikePrefix(a backslash before\,%and_). It then compiledwhere(field, 'like', pattern)through Knex, which declares noESCAPEclause on any dialect.The scan now reads:
LIKE_ESCAPE_CHARACTERis the driver's existing bound escape character, the one the filter compiler'sLIKE ... ESCAPE ?already binds. It is bound and never written as a literal, because MySQL applies C escape syntax inside string literals. There is one escape helper (escapeLikePrefix, unchanged) and one declared escape character, the same on every dialect. The method's docblock records why. One file of production code changed. The Turso remote face's own statement (turso-driver.ts, which already declaresESCAPEwith a backslash) is untouched.Why
SQLite's
LIKEhas no escape character unless one is declared. Postgres and MySQL use a backslash by default. So on the SQLite faces, a prefix carrying_,%or\matched no stored row. Measured atcb45469ewith this PR's pins and the pre-fix builder restored:SO_{0000}SO_0007SO_0001UNIQUE constraint failed(onSO_0005, retries spent)SO%{0000}SO%0007SO%0001SO\{0000}SO\0007SO\0001{region}-{0000}, regionnorth_eastnorth_east-0007north_east-0001SO-{0000}(control)SO-0007SO-0008SO-0031The
{region}row shows that the trigger does not need an unusual format. The prefix is rendered, so a_can come from a field value such as a snake_case machine name.Pins
packages/drivers/driver-sql/src/sql-driver-21163-autonumber-prefix-like-escape.test.ts: one suite per dialect cell throughdeclareDialectCell. SQLite always runs. Live Postgres and MySQL run where provisioned (Temporal Conformance (live PG + MySQL)runs this whole package withOS_EXPECT_LIVE_DIALECT_MATRIX=1) and are declared un-run otherwise. Each cell covers_,%and\prefixes plus a plain control, each cold and on the Autonumber counter neither syncs to MAX(existing) per tenant nor re-checks on collision — warm-DB creates 409 in bursts, each failure burning a number (25 retries observed) #5495 re-seed, plus the{field}-borne_.packages/drivers/driver-turso/src/turso-autonumber-prefix-like-escape.test.ts: the LOCAL face (url: ':memory:') and the REMOTE face (overmakeLibsqlSqliteStub, a real SQLite behind the libsql client), each with_,%and a plain control, cold and on re-seed. The REMOTE legs passed before this fix because that statement already declared its escape. They hold both faces to one answer from the one helper.The assertion is the issued autonumber as stored, read back through the driver's
findOneby an id the test chose. On MySQLcreatereturns the insert id rather than the row, so its return value is not a reading every cell can make. Never a SQL string.Reverse verification (fix committed first, at
9b3f7d0a)Mutation:
node scripts/ablation-replace.mjsreplaced the newwhereRaw(...)with the pre-fixwhere(field, 'like', ...)(anchor 1 to 0, blobcb63656dto5f8edc7a). Restore: blob equal to HEAD,git diff HEADempty, whole-treegit status --porcelainclean.{field}case (expected 'SO_0001' to be 'SO_0008',UNIQUE constraint failed). The two controls passed.node scripts/ablation-dist-preflight.mjs @objectstack/driver-sql '?? like ? escape ?' --absentconfirmed the fix was absent from all 6 built files. Then the turso pin: 4 failed / 8 passed. LOCAL_and%failed cold and on re-seed. LOCAL control and all 6 REMOTE legs passed.'where(field, "like"' --absentconfirmed the mutation was gone from dist (tree clean). Pins: driver-sql 9 passed / 2 skipped (live cells unprovisioned), turso 12 passed. The pristine dist reading (default mode) found?? like ? escape ?present indist/index.jsanddist/index.mjs.Live Postgres (local PG 16,
initdb+pg_ctlon a scratch port, torn down after)So declaring the escape does not change behavior on Postgres, which already used a backslash by default. MySQL: NOT MEASURED locally because this container has no MySQL server. The same file's live mysql cell runs in
Temporal Conformance (live PG + MySQL).Verification (at HEAD
7b7fcafb)Patch round 1 (
5b83098b, seat edit): the live MySQL cell failed oncreatereturn values (insert id0on MySQL). The pin now reads the stored row. The wholedriver-sqlsuite, CI-shaped against a local live PostgreSQL 16 and MySQL 8.0.46: 219 files, 5108 passed / 1 skipped. Reverse verification on the reworked pin: 7 failed, all in the SQLite cell, 0 in live PostgreSQL and live MySQL.pnpm --filter @objectstack/driver-sql test: exit 0, 208 files passed / 11 skipped, 3433 tests passed / 192 skipped.pnpm --filter @objectstack/driver-turso test: exit 0, 84 files passed, 2243 tests passed / 33 skipped.typecheckon both packages: exit 0.tsc --listFilesOnlyincludes both new test files in each package's program.pnpm check:driver-conformance: identical before the first edit (atcb45469e) and after the last commit: 50 covered cells, 0 in the DEBT ledger, 0 exempt. Dialect axis: 8 suites, 0 in the DIALECT ledger.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths) derived 63 commands. 61 exited 0.check:type-check-debthit a 280 s per-command cap on the first run and exited 0 on a re-run (re-measure OK in 211 s, self-test OK). Two exited 3 (PREREQUISITE NOT MET, NOT MEASURED):check:dual-build-cjs-loadsneeds a whole-repo build, andcheck:lean-entry-closureneeds objectql's dist, which is outside this diff's closure. For the first one, both changed packages' CJS builds were loaded directly withrequireand loaded OK.--ranreconciliation: 63 derived, 61 run, 2 NOT-MEASURED, 0 UNRUN.Lint was narrowed to the 3 changed TS files:
eslint --no-inline-config --format jsongave 3 files, 0 errors, 0 warnings. Population:eslint.config.mjslints**/*.{ts,...}outside its never-linted build dirs, and no file reported as ignored. Invariance: the config enables no type-aware linting (noparserOptions.project), so this diff cannot move the verdict on any untouched file. Repo-widepnpm lintis CI's.Acceptance notes
escapeLikeComparand(the filter compiler's module function) andescapeLikePrefix(this method) escape the identical set: a backslash before each of\,%and_. They were not merged. Triage namedescapeLikePrefixas this path's one helper, and nothing here needed a merge.SqliteWasmDriverextendsSqlDriverand inherits the scan. A one-off probe after the fix (not committed) issuedSO_0008over a storedSO_0007on that face. That face has no permanent pin here, and its pre-fix answer was not measured.f71f31b2, seat edit): the branch mergedorigin/mainat097ef802, a clean merge with the same 4 files in the net diff. It carries the sibling fix(driver-turso)!: the remote filter compiler refuses the JSON-column family and answers $contains by membership (#21178) #21208 (862f12c0b,driver-turso), and it re-ran theType Check · source gateslane that had timed out at5b83098b. Every check is green on this head.Generated by Claude Code