Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/21163-autonumber-like-escape.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<unknown> => {
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);
}
34 changes: 31 additions & 3 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -7485,7 +7507,10 @@ export class SqlDriver implements IDataDriver {
tenantId: string | null,
suffix = '',
): Promise<number> {
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);
}
Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>;
}

async function localFace(format: string): Promise<Face> {
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<Face> {
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));
});
}
});
}
Loading