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. 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..9812585e73f --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-21163-autonumber-prefix-like-escape.test.ts @@ -0,0 +1,175 @@ +// 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 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 + * 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' })), + ); + }; + + 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(); + }); + + 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)]); + + 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 () => { + 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 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))); + + expect(await issue({ title: 'after the seeds' })).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'); + + expect(await issue({ region: 'north_east', title: 'first issued' })).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 7651a8fd553..18b363dd5f9 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -7475,6 +7475,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, @@ -7485,7 +7507,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); } @@ -7495,8 +7520,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)); + }); + } + }); +}