From 9b3f7d0ae8530ad05d4cb6511fc1fd5cf86f68a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:53:03 +0000 Subject: [PATCH 1/3] fix(driver-sql): declare the LIKE escape the autonumber prefix scan escapes 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 --- ...1163-autonumber-prefix-like-escape.test.ts | 160 ++++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 34 +++- ...urso-autonumber-prefix-like-escape.test.ts | 122 +++++++++++++ 3 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 packages/drivers/driver-sql/src/sql-driver-21163-autonumber-prefix-like-escape.test.ts create mode 100644 packages/drivers/driver-turso/src/turso-autonumber-prefix-like-escape.test.ts diff --git a/packages/drivers/driver-sql/src/sql-driver-21163-autonumber-prefix-like-escape.test.ts b/packages/drivers/driver-sql/src/sql-driver-21163-autonumber-prefix-like-escape.test.ts new file mode 100644 index 00000000000..3853159d2ef --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-21163-autonumber-prefix-like-escape.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21163] An autonumber prefix carrying a `LIKE` metacharacter seeds its + * counter from the data table's MAX — cold, and on the #5495 re-seed — on every + * dialect, SQLite included. + * + * # The defect + * + * `scanMaxNumericTail` is the one read of "the highest counter already stored + * under this prefix". It anchors the scan with `escapeLikePrefix(prefix) + '%'`, + * which escapes `\`, `%` and `_` with a backslash — and then compiled the + * predicate through Knex's `where(col, 'like', …)`, which declares NO `ESCAPE` + * clause on any dialect. Postgres and MySQL read a backslash as the default + * `LIKE` escape, so they were right by default. SQLite has no escape character + * unless one is declared, so `SO\_%` asked for the literal three characters + * `S`,`O`,`\` followed by any one character — and matched nothing. Measured on + * better-sqlite3 at `cb45469e` (this file's fixtures, before the fix): + * + * | format | stored | cold create issued | re-seed after rows 2..30 land | + * |:---|:---|:---|:---| + * | `SO_{0000}` | `SO_0007` | `SO_0001` | refused: UNIQUE constraint failed | + * | `SO%{0000}` | `SO%0007` | `SO%0001` | refused: UNIQUE constraint failed | + * | `SO\{0000}` | `SO\0007` | `SO\0001` | refused: UNIQUE constraint failed | + * | `{region}-{0000}`, region `north_east` | `north_east-0007` | `north_east-0001` | — | + * | `SO-{0000}` (control) | `SO-0007` | `SO-0008` | `SO-0031` | + * + * The `{region}` row is why this is not an exotic-format problem: the prefix is + * RENDERED, so a `_` arrives from data as readily as from the format — and + * snake_case is this platform's spelling for machine names. + * + * Cold, the counter starts under numbers already stored, so a later issue + * collides with them. On the re-seed path the scan answers 0, the forward-only + * re-seed cannot move the counter, and every retry collides again until the + * retry budget is spent: the #5495 storm, back for that format. + * + * # What is asserted + * + * The issued value — the caller-visible answer — on a cold bootstrap over a + * stored row, and on the create that follows a bypass write landing ABOVE a + * warm counter. Per dialect cell: SQLite always runs; live Postgres and MySQL + * run where provisioned (the `Temporal Conformance (live PG + MySQL)` job runs + * this whole package against both) and are declared un-run otherwise. On those + * two the backslash was already the default escape, so their cells pin that + * DECLARING it changed nothing there — they passed before the fix and must + * pass after it. + * + * The plain prefix is the control on every cell: it has nothing to escape, so + * it was right before and after, and a fix that broke the scan wholesale would + * redden it too. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +const TABLE = 'os21163_like_escape'; +const SEQUENCES_TABLE = '_objectstack_sequences'; + +interface PrefixCase { + /** Suite label. */ + label: string; + /** The declared format. */ + format: string; + /** The stored value for counter `n` under this format. */ + render: (n: number) => string; + /** True when the rendered prefix carries a character `escapeLikePrefix` escapes. */ + escaped: boolean; +} + +const pad4 = (n: number) => String(n).padStart(4, '0'); + +const STATIC_PREFIX_CASES: readonly PrefixCase[] = [ + { label: '`_` in the prefix', format: 'SO_{0000}', render: (n) => `SO_${pad4(n)}`, escaped: true }, + { label: '`%` in the prefix', format: 'SO%{0000}', render: (n) => `SO%${pad4(n)}`, escaped: true }, + { label: '`\\` in the prefix', format: 'SO\\{0000}', render: (n) => `SO\\${pad4(n)}`, escaped: true }, + { label: 'a plain prefix (control)', format: 'SO-{0000}', render: (n) => `SO-${pad4(n)}`, escaped: false }, +]; + +const objectWith = (format: string) => + ({ + name: TABLE, + fields: { + region: { type: 'string' }, + so_no: { type: 'autonumber', format, unique: true }, + title: { type: 'string' }, + }, + }) as any; + +function suite(cell: DialectCell) { + describe(`sql-driver — autonumber prefix LIKE escape (${cell.label}) [#21163]`, () => { + let driver: SqlDriver; + + const knex = () => (driver as any).knex; + + /** Drop the data table and this object's counter rows, so every test starts COLD. */ + const reset = async () => { + await knex().schema.dropTableIfExists(TABLE); + await knex()(SEQUENCES_TABLE) + .where('object', TABLE) + .del() + .catch(() => {}); + }; + + /** Land rows by a path that bypasses `fillAutoNumberFields`, as a seed replay does. */ + const bypassInsert = async (values: string[], region: string | null = null) => { + await knex()(TABLE).insert( + values.map((v) => ({ id: `bypass-${v}`, region, so_no: v, title: 'seed replay' })), + ); + }; + + beforeEach(async () => { + driver = new SqlDriver(cell.config()); + await reset(); + }); + + afterEach(async () => { + await reset(); + await driver.disconnect(); + }); + + for (const c of STATIC_PREFIX_CASES) { + const role = c.escaped ? 'red before the fix on SQLite' : 'control'; + + it(`${c.label}: a COLD bootstrap seeds from the stored MAX (${role})`, async () => { + await driver.initObjects([objectWith(c.format)]); + await bypassInsert([c.render(7)]); + + const created = await driver.create(TABLE, { title: 'first issued' }); + expect(created.so_no).toBe(c.render(8)); + }); + + it(`${c.label}: the #5495 re-seed moves a warm counter past rows a bypass write landed (${role})`, async () => { + await driver.initObjects([objectWith(c.format)]); + + // Warm the counter on an EMPTY table, so this test does not depend on + // the cold scan above: there is nothing for the bootstrap to find. + expect((await driver.create(TABLE, { title: 'warm' })).so_no).toBe(c.render(1)); + + // Rows 2..30 land above the counter. Nothing tells the sequence. + await bypassInsert(Array.from({ length: 29 }, (_, i) => c.render(i + 2))); + + const created = await driver.create(TABLE, { title: 'after the seeds' }); + expect(created.so_no).toBe(c.render(31)); + }); + } + + it('a `_` that arrives from DATA through `{field}` interpolation seeds from the stored MAX (red before the fix on SQLite)', async () => { + await driver.initObjects([objectWith('{region}-{0000}')]); + await bypassInsert(['north_east-0007'], 'north_east'); + + const created = await driver.create(TABLE, { region: 'north_east', title: 'first issued' }); + expect(created.so_no).toBe('north_east-0008'); + }); + }); +} + +for (const cell of DIALECT_CELLS) { + declareDialectCell(cell, 'autonumber prefix LIKE escape (#21163)', suite); +} diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index a7a0da5ef63..cb63656dd79 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -7527,6 +7527,28 @@ export class SqlDriver implements IDataDriver { * duplicate-record-number harm, self-inflicted. The predicate therefore stays * `prefix%` and the suffix is applied per row, where a non-match simply means * "different suffix, same counter". + * + * ## The escape the prefix is escaped FOR is declared, on every dialect (#21163) + * + * {@link escapeLikePrefix} writes a backslash before each `\`, `%` and `_`, + * which only means "literally" under a `LIKE` whose escape character IS that + * backslash. SQLite's `LIKE` has no escape character unless one is declared, + * and Knex's `where(col, 'like', …)` declares none on any dialect. Measured on + * better-sqlite3 before this change: the pattern `SO\_%` without `ESCAPE` + * matched nothing against a stored `SO_0007`, so on every SQLite face a + * format whose rendered prefix carries `_`, `%` or `\` — from the format's + * literal text OR from a `{field}` value such as `north_east` — scanned an + * empty partition: the cold bootstrap seeded the counter from 0 and the + * #5495 re-seed could not move it. Postgres and MySQL read a backslash as the + * default `LIKE` escape, so they were right by default rather than by + * declaration. + * + * The character is BOUND, never written as a literal — the same + * {@link LIKE_ESCAPE_CHARACTER} the filter compiler binds, for the reason + * given there: MySQL applies C escape syntax inside string literals, so a + * literal backslash is spelled differently per dialect while a bound value + * has one spelling everywhere. Turso's remote face sends its own statement + * to a SQLite engine only, and declares the same backslash there. */ protected async scanMaxNumericTail( queryRunner: Knex | Knex.Transaction, @@ -7537,7 +7559,10 @@ export class SqlDriver implements IDataDriver { tenantId: string | null, suffix = '', ): Promise { - let builder = queryRunner(tableName).select(field).where(field, 'like', `${this.escapeLikePrefix(prefix)}%`).whereNotNull(field); + let builder = queryRunner(tableName) + .select(field) + .whereRaw('?? like ? escape ?', [field, `${this.escapeLikePrefix(prefix)}%`, LIKE_ESCAPE_CHARACTER]) + .whereNotNull(field); if (tenantField && tenantId !== null) { builder = builder.where(tenantField, tenantId); } @@ -7547,8 +7572,11 @@ export class SqlDriver implements IDataDriver { /** * The rendered prefix as a `LIKE` anchor: `\`, `%` and `_` escaped with a - * backslash, so a prefix is matched literally. The predicate's pre-filter - * only — {@link maxAutonumberCounter} re-checks the prefix per row. + * backslash, so a prefix is matched literally — under a `LIKE` that declares + * that backslash as its `ESCAPE`, which every statement using this anchor + * must do (see {@link scanMaxNumericTail}; SQLite has no escape character + * otherwise). The predicate's pre-filter only — {@link maxAutonumberCounter} + * re-checks the prefix per row. */ protected escapeLikePrefix(prefix: string): string { return prefix.replace(/([\\%_])/g, '\\$1'); diff --git a/packages/drivers/driver-turso/src/turso-autonumber-prefix-like-escape.test.ts b/packages/drivers/driver-turso/src/turso-autonumber-prefix-like-escape.test.ts new file mode 100644 index 00000000000..919837e975b --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-autonumber-prefix-like-escape.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21163] The Turso faces, held to one answer for an autonumber prefix that + * carries a `LIKE` metacharacter. + * + * The data table's MAX is read by two statements built from ONE escape helper + * (`SqlDriver.escapeLikePrefix`, which writes a backslash before `\`, `%` and + * `_`), and a backslash only escapes under a `LIKE` that declares it: + * + * - **LOCAL (and embedded replica, same local engine)** inherits + * `SqlDriver.scanMaxNumericTail`, compiled through Knex. Knex declared no + * `ESCAPE`, and SQLite has no escape character otherwise, so `SO\_%` matched + * nothing against a stored `SO_0007` (measured on this face at `cb45469e`: + * the cold create issued `SO_0001`, and the re-seed after a bypass write + * could not move the counter, so the create was refused). Fixed in + * `driver-sql`, where the scan now binds the escape on every dialect. + * - **REMOTE** sends its own statement through the transport, and that one + * already declared `ESCAPE '\'` — so its legs below passed before the fix. + * They are here as the other half of the same claim: both faces read the + * one helper's output under a declared backslash, and answer the same + * number. Change the helper's escape character without moving both + * declarations and one half of this file reddens. + * + * Remote legs run over `makeLibsqlSqliteStub` — a real SQLite behind the + * `@libsql/client` interface — so the `LIKE` is evaluated by SQLite, not + * asserted as a string. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { TursoDriver } from './index.js'; +import { makeLibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; + +const TABLE = 'so_order'; + +const pad4 = (n: number) => String(n).padStart(4, '0'); + +const CASES = [ + { label: '`_` in the prefix', format: 'SO_{0000}', render: (n: number) => `SO_${pad4(n)}`, role: 'red before the fix on LOCAL' }, + { label: '`%` in the prefix', format: 'SO%{0000}', render: (n: number) => `SO%${pad4(n)}`, role: 'red before the fix on LOCAL' }, + { label: 'a plain prefix', format: 'SO-{0000}', render: (n: number) => `SO-${pad4(n)}`, role: 'control' }, +] as const; + +const objectWith = (format: string) => + ({ + name: TABLE, + fields: { + so_no: { type: 'autonumber', format, unique: true }, + title: { type: 'string' }, + }, + }) as any; + +interface Face { + driver: TursoDriver; + /** Land rows by a path that never enters `fillAutoNumberFields`. */ + bypassInsert(values: string[]): Promise; +} + +async function localFace(format: string): Promise { + const driver = new TursoDriver({ url: ':memory:' }); + expect(driver.transportMode).toBe('local'); + await driver.initObjects([objectWith(format)]); + return { + driver, + bypassInsert: async (values) => { + await (driver as any).knex(TABLE).insert(values.map((v) => ({ id: `bypass-${v}`, so_no: v, title: 'seed replay' }))); + }, + }; +} + +async function remoteFace(format: string): Promise { + const stub = makeLibsqlSqliteStub(); + const driver = new TursoDriver({ url: 'libsql://example.turso.io', client: stub as never }); + expect(driver.transportMode).toBe('remote'); + await driver.connect(); + await driver.initObjects([objectWith(format)]); + const insert = stub.raw.prepare(`insert into "${TABLE}" ("id", "so_no", "title") values (?, ?, ?)`); + return { + driver, + bypassInsert: async (values) => { + for (const v of values) insert.run(`bypass-${v}`, v, 'seed replay'); + }, + }; +} + +for (const [faceName, open] of [ + ['LOCAL', localFace], + ['REMOTE', remoteFace], +] as const) { + describe(`[#21163] TursoDriver ${faceName}: an autonumber prefix carrying a LIKE metacharacter`, () => { + let face: Face | undefined; + + afterEach(async () => { + await face?.driver.disconnect(); + face = undefined; + }); + + for (const c of CASES) { + const role = faceName === 'REMOTE' ? 'passed before the fix' : c.role; + + it(`${c.label}: a COLD bootstrap seeds from the stored MAX (${role})`, async () => { + face = await open(c.format); + await face.bypassInsert([c.render(7)]); + + const created = await face.driver.create(TABLE, { title: 'first issued' }); + expect(created.so_no).toBe(c.render(8)); + }); + + it(`${c.label}: the #5495 re-seed moves a warm counter past rows a bypass write landed (${role})`, async () => { + face = await open(c.format); + + // Warmed on an EMPTY table, so this leg does not lean on the cold scan. + expect((await face.driver.create(TABLE, { title: 'warm' })).so_no).toBe(c.render(1)); + + await face.bypassInsert(Array.from({ length: 29 }, (_, i) => c.render(i + 2))); + + const created = await face.driver.create(TABLE, { title: 'after the seeds' }); + expect(created.so_no).toBe(c.render(31)); + }); + } + }); +} From 7b7fcafbdff3eea4c56f75b93d72521224e42d2c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:01:03 +0000 Subject: [PATCH 2/3] chore(changeset): driver-sql patch for the autonumber prefix LIKE escape Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .changeset/21163-autonumber-like-escape.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/21163-autonumber-like-escape.md diff --git a/.changeset/21163-autonumber-like-escape.md b/.changeset/21163-autonumber-like-escape.md new file mode 100644 index 00000000000..10b3057e683 --- /dev/null +++ b/.changeset/21163-autonumber-like-escape.md @@ -0,0 +1,16 @@ +--- +'@objectstack/driver-sql': patch +--- + +fix(driver-sql): an autonumber format whose rendered prefix carries `_`, `%` or `\` seeds its counter from the stored MAX on SQLite + +Clause-②: no + +The SQL driver reads the highest counter already stored under an autonumber prefix in two places: the first issue of a counter (the cold bootstrap) and the re-seed after a create collides with a number that a seed replay, an import or direct SQL already wrote. Both escape the prefix's `\`, `%` and `_` with a backslash for a `LIKE` scan, but the scan declared no `ESCAPE` character, and SQLite's `LIKE` has none unless one is declared. On SQLite (better-sqlite3, and the Turso local and embedded-replica faces; the WebAssembly SQLite driver inherits the same scan) such a prefix therefore matched no stored row: + +- **Cold**, the counter started at 1 under numbers already stored. Measured: a format `SO_{0000}` over a stored `SO_0007` issued `SO_0001`. +- **On the re-seed**, the counter could not move, so every retry collided again and the create was refused once the retries ran out. + +The prefix is rendered, so the character can come from data as well as from the format: `{region}-{0000}` with a region value of `north_east` was affected in the same way. + +The scan now binds the driver's one `LIKE` escape character on every dialect, as the driver's filter `LIKE` already does. PostgreSQL and MySQL already used a backslash as their default `LIKE` escape, so the answer there does not change; a prefix with none of the three characters is not affected anywhere. Counters already seeded too low are not rewritten: the next collision on one now re-seeds it from the stored MAX, as on any other prefix. From 5b83098b9e7209f0a1bd54456933765e825aad12 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:03:26 +0000 Subject: [PATCH 3/3] test(driver-sql): read the issued autonumber back from the stored row 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 --- ...1163-autonumber-prefix-like-escape.test.ts | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-21163-autonumber-prefix-like-escape.test.ts b/packages/drivers/driver-sql/src/sql-driver-21163-autonumber-prefix-like-escape.test.ts index 3853159d2ef..9812585e73f 100644 --- a/packages/drivers/driver-sql/src/sql-driver-21163-autonumber-prefix-like-escape.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-21163-autonumber-prefix-like-escape.test.ts @@ -36,9 +36,16 @@ * * # What is asserted * - * The issued value — the caller-visible answer — on a cold bootstrap over a - * stored row, and on the create that follows a bypass write landing ABOVE a - * warm counter. Per dialect cell: SQLite always runs; live Postgres and MySQL + * The number the create issued, on a cold bootstrap over a stored row and on + * the create that follows a bypass write landing ABOVE a warm counter — read + * back from the stored row through the driver's own `findOne`, by an id the + * test chose. Not from `create`'s return value: on MySQL that is not the row. + * Knex does not support `.returning()` there, so `create` hands back the insert + * id (measured on a live MySQL 8.0.46: `0`, with the row stored correctly), and + * an assertion on it fails every case, controls included, whatever the scan + * does. The stored row is the one reading every cell can make. + * + * Per dialect cell: SQLite always runs; live Postgres and MySQL * run where provisioned (the `Temporal Conformance (live PG + MySQL)` job runs * this whole package against both) and are declared un-run otherwise. On those * two the backslash was already the default escape, so their cells pin that @@ -109,6 +116,17 @@ function suite(cell: DialectCell) { ); }; + let seq = 0; + + /** Create one row and answer the autonumber it was STORED with (see the header). */ + const issue = async (data: Record): Promise => { + const id = `issued-${++seq}`; + await driver.create(TABLE, { id, ...data }); + const row = await driver.findOne(TABLE, { where: { id } }); + expect(row, `row ${id} was not stored`).not.toBeNull(); + return row!.so_no; + }; + beforeEach(async () => { driver = new SqlDriver(cell.config()); await reset(); @@ -126,8 +144,7 @@ function suite(cell: DialectCell) { await driver.initObjects([objectWith(c.format)]); await bypassInsert([c.render(7)]); - const created = await driver.create(TABLE, { title: 'first issued' }); - expect(created.so_no).toBe(c.render(8)); + expect(await issue({ title: 'first issued' })).toBe(c.render(8)); }); it(`${c.label}: the #5495 re-seed moves a warm counter past rows a bypass write landed (${role})`, async () => { @@ -135,13 +152,12 @@ function suite(cell: DialectCell) { // Warm the counter on an EMPTY table, so this test does not depend on // the cold scan above: there is nothing for the bootstrap to find. - expect((await driver.create(TABLE, { title: 'warm' })).so_no).toBe(c.render(1)); + expect(await issue({ title: 'warm' })).toBe(c.render(1)); // Rows 2..30 land above the counter. Nothing tells the sequence. await bypassInsert(Array.from({ length: 29 }, (_, i) => c.render(i + 2))); - const created = await driver.create(TABLE, { title: 'after the seeds' }); - expect(created.so_no).toBe(c.render(31)); + expect(await issue({ title: 'after the seeds' })).toBe(c.render(31)); }); } @@ -149,8 +165,7 @@ function suite(cell: DialectCell) { await driver.initObjects([objectWith('{region}-{0000}')]); await bypassInsert(['north_east-0007'], 'north_east'); - const created = await driver.create(TABLE, { region: 'north_east', title: 'first issued' }); - expect(created.so_no).toBe('north_east-0008'); + expect(await issue({ region: 'north_east', title: 'first issued' })).toBe('north_east-0008'); }); }); }