Skip to content

Commit 9b3f7d0

Browse files
committed
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 <noreply@anthropic.com>
1 parent cb45469 commit 9b3f7d0

3 files changed

Lines changed: 313 additions & 3 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21163] An autonumber prefix carrying a `LIKE` metacharacter seeds its
5+
* counter from the data table's MAX — cold, and on the #5495 re-seed — on every
6+
* dialect, SQLite included.
7+
*
8+
* # The defect
9+
*
10+
* `scanMaxNumericTail` is the one read of "the highest counter already stored
11+
* under this prefix". It anchors the scan with `escapeLikePrefix(prefix) + '%'`,
12+
* which escapes `\`, `%` and `_` with a backslash — and then compiled the
13+
* predicate through Knex's `where(col, 'like', …)`, which declares NO `ESCAPE`
14+
* clause on any dialect. Postgres and MySQL read a backslash as the default
15+
* `LIKE` escape, so they were right by default. SQLite has no escape character
16+
* unless one is declared, so `SO\_%` asked for the literal three characters
17+
* `S`,`O`,`\` followed by any one character — and matched nothing. Measured on
18+
* better-sqlite3 at `cb45469e` (this file's fixtures, before the fix):
19+
*
20+
* | format | stored | cold create issued | re-seed after rows 2..30 land |
21+
* |:---|:---|:---|:---|
22+
* | `SO_{0000}` | `SO_0007` | `SO_0001` | refused: UNIQUE constraint failed |
23+
* | `SO%{0000}` | `SO%0007` | `SO%0001` | refused: UNIQUE constraint failed |
24+
* | `SO\{0000}` | `SO\0007` | `SO\0001` | refused: UNIQUE constraint failed |
25+
* | `{region}-{0000}`, region `north_east` | `north_east-0007` | `north_east-0001` | — |
26+
* | `SO-{0000}` (control) | `SO-0007` | `SO-0008` | `SO-0031` |
27+
*
28+
* The `{region}` row is why this is not an exotic-format problem: the prefix is
29+
* RENDERED, so a `_` arrives from data as readily as from the format — and
30+
* snake_case is this platform's spelling for machine names.
31+
*
32+
* Cold, the counter starts under numbers already stored, so a later issue
33+
* collides with them. On the re-seed path the scan answers 0, the forward-only
34+
* re-seed cannot move the counter, and every retry collides again until the
35+
* retry budget is spent: the #5495 storm, back for that format.
36+
*
37+
* # What is asserted
38+
*
39+
* The issued value — the caller-visible answer — on a cold bootstrap over a
40+
* stored row, and on the create that follows a bypass write landing ABOVE a
41+
* warm counter. Per dialect cell: SQLite always runs; live Postgres and MySQL
42+
* run where provisioned (the `Temporal Conformance (live PG + MySQL)` job runs
43+
* this whole package against both) and are declared un-run otherwise. On those
44+
* two the backslash was already the default escape, so their cells pin that
45+
* DECLARING it changed nothing there — they passed before the fix and must
46+
* pass after it.
47+
*
48+
* The plain prefix is the control on every cell: it has nothing to escape, so
49+
* it was right before and after, and a fix that broke the scan wholesale would
50+
* redden it too.
51+
*/
52+
53+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
54+
import { SqlDriver } from '../src/index.js';
55+
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';
56+
57+
const TABLE = 'os21163_like_escape';
58+
const SEQUENCES_TABLE = '_objectstack_sequences';
59+
60+
interface PrefixCase {
61+
/** Suite label. */
62+
label: string;
63+
/** The declared format. */
64+
format: string;
65+
/** The stored value for counter `n` under this format. */
66+
render: (n: number) => string;
67+
/** True when the rendered prefix carries a character `escapeLikePrefix` escapes. */
68+
escaped: boolean;
69+
}
70+
71+
const pad4 = (n: number) => String(n).padStart(4, '0');
72+
73+
const STATIC_PREFIX_CASES: readonly PrefixCase[] = [
74+
{ label: '`_` in the prefix', format: 'SO_{0000}', render: (n) => `SO_${pad4(n)}`, escaped: true },
75+
{ label: '`%` in the prefix', format: 'SO%{0000}', render: (n) => `SO%${pad4(n)}`, escaped: true },
76+
{ label: '`\\` in the prefix', format: 'SO\\{0000}', render: (n) => `SO\\${pad4(n)}`, escaped: true },
77+
{ label: 'a plain prefix (control)', format: 'SO-{0000}', render: (n) => `SO-${pad4(n)}`, escaped: false },
78+
];
79+
80+
const objectWith = (format: string) =>
81+
({
82+
name: TABLE,
83+
fields: {
84+
region: { type: 'string' },
85+
so_no: { type: 'autonumber', format, unique: true },
86+
title: { type: 'string' },
87+
},
88+
}) as any;
89+
90+
function suite(cell: DialectCell) {
91+
describe(`sql-driver — autonumber prefix LIKE escape (${cell.label}) [#21163]`, () => {
92+
let driver: SqlDriver;
93+
94+
const knex = () => (driver as any).knex;
95+
96+
/** Drop the data table and this object's counter rows, so every test starts COLD. */
97+
const reset = async () => {
98+
await knex().schema.dropTableIfExists(TABLE);
99+
await knex()(SEQUENCES_TABLE)
100+
.where('object', TABLE)
101+
.del()
102+
.catch(() => {});
103+
};
104+
105+
/** Land rows by a path that bypasses `fillAutoNumberFields`, as a seed replay does. */
106+
const bypassInsert = async (values: string[], region: string | null = null) => {
107+
await knex()(TABLE).insert(
108+
values.map((v) => ({ id: `bypass-${v}`, region, so_no: v, title: 'seed replay' })),
109+
);
110+
};
111+
112+
beforeEach(async () => {
113+
driver = new SqlDriver(cell.config());
114+
await reset();
115+
});
116+
117+
afterEach(async () => {
118+
await reset();
119+
await driver.disconnect();
120+
});
121+
122+
for (const c of STATIC_PREFIX_CASES) {
123+
const role = c.escaped ? 'red before the fix on SQLite' : 'control';
124+
125+
it(`${c.label}: a COLD bootstrap seeds from the stored MAX (${role})`, async () => {
126+
await driver.initObjects([objectWith(c.format)]);
127+
await bypassInsert([c.render(7)]);
128+
129+
const created = await driver.create(TABLE, { title: 'first issued' });
130+
expect(created.so_no).toBe(c.render(8));
131+
});
132+
133+
it(`${c.label}: the #5495 re-seed moves a warm counter past rows a bypass write landed (${role})`, async () => {
134+
await driver.initObjects([objectWith(c.format)]);
135+
136+
// Warm the counter on an EMPTY table, so this test does not depend on
137+
// the cold scan above: there is nothing for the bootstrap to find.
138+
expect((await driver.create(TABLE, { title: 'warm' })).so_no).toBe(c.render(1));
139+
140+
// Rows 2..30 land above the counter. Nothing tells the sequence.
141+
await bypassInsert(Array.from({ length: 29 }, (_, i) => c.render(i + 2)));
142+
143+
const created = await driver.create(TABLE, { title: 'after the seeds' });
144+
expect(created.so_no).toBe(c.render(31));
145+
});
146+
}
147+
148+
it('a `_` that arrives from DATA through `{field}` interpolation seeds from the stored MAX (red before the fix on SQLite)', async () => {
149+
await driver.initObjects([objectWith('{region}-{0000}')]);
150+
await bypassInsert(['north_east-0007'], 'north_east');
151+
152+
const created = await driver.create(TABLE, { region: 'north_east', title: 'first issued' });
153+
expect(created.so_no).toBe('north_east-0008');
154+
});
155+
});
156+
}
157+
158+
for (const cell of DIALECT_CELLS) {
159+
declareDialectCell(cell, 'autonumber prefix LIKE escape (#21163)', suite);
160+
}

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7527,6 +7527,28 @@ export class SqlDriver implements IDataDriver {
75277527
* duplicate-record-number harm, self-inflicted. The predicate therefore stays
75287528
* `prefix%` and the suffix is applied per row, where a non-match simply means
75297529
* "different suffix, same counter".
7530+
*
7531+
* ## The escape the prefix is escaped FOR is declared, on every dialect (#21163)
7532+
*
7533+
* {@link escapeLikePrefix} writes a backslash before each `\`, `%` and `_`,
7534+
* which only means "literally" under a `LIKE` whose escape character IS that
7535+
* backslash. SQLite's `LIKE` has no escape character unless one is declared,
7536+
* and Knex's `where(col, 'like', …)` declares none on any dialect. Measured on
7537+
* better-sqlite3 before this change: the pattern `SO\_%` without `ESCAPE`
7538+
* matched nothing against a stored `SO_0007`, so on every SQLite face a
7539+
* format whose rendered prefix carries `_`, `%` or `\` — from the format's
7540+
* literal text OR from a `{field}` value such as `north_east` — scanned an
7541+
* empty partition: the cold bootstrap seeded the counter from 0 and the
7542+
* #5495 re-seed could not move it. Postgres and MySQL read a backslash as the
7543+
* default `LIKE` escape, so they were right by default rather than by
7544+
* declaration.
7545+
*
7546+
* The character is BOUND, never written as a literal — the same
7547+
* {@link LIKE_ESCAPE_CHARACTER} the filter compiler binds, for the reason
7548+
* given there: MySQL applies C escape syntax inside string literals, so a
7549+
* literal backslash is spelled differently per dialect while a bound value
7550+
* has one spelling everywhere. Turso's remote face sends its own statement
7551+
* to a SQLite engine only, and declares the same backslash there.
75307552
*/
75317553
protected async scanMaxNumericTail(
75327554
queryRunner: Knex | Knex.Transaction,
@@ -7537,7 +7559,10 @@ export class SqlDriver implements IDataDriver {
75377559
tenantId: string | null,
75387560
suffix = '',
75397561
): Promise<number> {
7540-
let builder = queryRunner(tableName).select(field).where(field, 'like', `${this.escapeLikePrefix(prefix)}%`).whereNotNull(field);
7562+
let builder = queryRunner(tableName)
7563+
.select(field)
7564+
.whereRaw('?? like ? escape ?', [field, `${this.escapeLikePrefix(prefix)}%`, LIKE_ESCAPE_CHARACTER])
7565+
.whereNotNull(field);
75417566
if (tenantField && tenantId !== null) {
75427567
builder = builder.where(tenantField, tenantId);
75437568
}
@@ -7547,8 +7572,11 @@ export class SqlDriver implements IDataDriver {
75477572

75487573
/**
75497574
* The rendered prefix as a `LIKE` anchor: `\`, `%` and `_` escaped with a
7550-
* backslash, so a prefix is matched literally. The predicate's pre-filter
7551-
* only — {@link maxAutonumberCounter} re-checks the prefix per row.
7575+
* backslash, so a prefix is matched literally — under a `LIKE` that declares
7576+
* that backslash as its `ESCAPE`, which every statement using this anchor
7577+
* must do (see {@link scanMaxNumericTail}; SQLite has no escape character
7578+
* otherwise). The predicate's pre-filter only — {@link maxAutonumberCounter}
7579+
* re-checks the prefix per row.
75527580
*/
75537581
protected escapeLikePrefix(prefix: string): string {
75547582
return prefix.replace(/([\\%_])/g, '\\$1');
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21163] The Turso faces, held to one answer for an autonumber prefix that
5+
* carries a `LIKE` metacharacter.
6+
*
7+
* The data table's MAX is read by two statements built from ONE escape helper
8+
* (`SqlDriver.escapeLikePrefix`, which writes a backslash before `\`, `%` and
9+
* `_`), and a backslash only escapes under a `LIKE` that declares it:
10+
*
11+
* - **LOCAL (and embedded replica, same local engine)** inherits
12+
* `SqlDriver.scanMaxNumericTail`, compiled through Knex. Knex declared no
13+
* `ESCAPE`, and SQLite has no escape character otherwise, so `SO\_%` matched
14+
* nothing against a stored `SO_0007` (measured on this face at `cb45469e`:
15+
* the cold create issued `SO_0001`, and the re-seed after a bypass write
16+
* could not move the counter, so the create was refused). Fixed in
17+
* `driver-sql`, where the scan now binds the escape on every dialect.
18+
* - **REMOTE** sends its own statement through the transport, and that one
19+
* already declared `ESCAPE '\'` — so its legs below passed before the fix.
20+
* They are here as the other half of the same claim: both faces read the
21+
* one helper's output under a declared backslash, and answer the same
22+
* number. Change the helper's escape character without moving both
23+
* declarations and one half of this file reddens.
24+
*
25+
* Remote legs run over `makeLibsqlSqliteStub` — a real SQLite behind the
26+
* `@libsql/client` interface — so the `LIKE` is evaluated by SQLite, not
27+
* asserted as a string.
28+
*/
29+
30+
import { describe, it, expect, afterEach } from 'vitest';
31+
import { TursoDriver } from './index.js';
32+
import { makeLibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';
33+
34+
const TABLE = 'so_order';
35+
36+
const pad4 = (n: number) => String(n).padStart(4, '0');
37+
38+
const CASES = [
39+
{ label: '`_` in the prefix', format: 'SO_{0000}', render: (n: number) => `SO_${pad4(n)}`, role: 'red before the fix on LOCAL' },
40+
{ label: '`%` in the prefix', format: 'SO%{0000}', render: (n: number) => `SO%${pad4(n)}`, role: 'red before the fix on LOCAL' },
41+
{ label: 'a plain prefix', format: 'SO-{0000}', render: (n: number) => `SO-${pad4(n)}`, role: 'control' },
42+
] as const;
43+
44+
const objectWith = (format: string) =>
45+
({
46+
name: TABLE,
47+
fields: {
48+
so_no: { type: 'autonumber', format, unique: true },
49+
title: { type: 'string' },
50+
},
51+
}) as any;
52+
53+
interface Face {
54+
driver: TursoDriver;
55+
/** Land rows by a path that never enters `fillAutoNumberFields`. */
56+
bypassInsert(values: string[]): Promise<void>;
57+
}
58+
59+
async function localFace(format: string): Promise<Face> {
60+
const driver = new TursoDriver({ url: ':memory:' });
61+
expect(driver.transportMode).toBe('local');
62+
await driver.initObjects([objectWith(format)]);
63+
return {
64+
driver,
65+
bypassInsert: async (values) => {
66+
await (driver as any).knex(TABLE).insert(values.map((v) => ({ id: `bypass-${v}`, so_no: v, title: 'seed replay' })));
67+
},
68+
};
69+
}
70+
71+
async function remoteFace(format: string): Promise<Face> {
72+
const stub = makeLibsqlSqliteStub();
73+
const driver = new TursoDriver({ url: 'libsql://example.turso.io', client: stub as never });
74+
expect(driver.transportMode).toBe('remote');
75+
await driver.connect();
76+
await driver.initObjects([objectWith(format)]);
77+
const insert = stub.raw.prepare(`insert into "${TABLE}" ("id", "so_no", "title") values (?, ?, ?)`);
78+
return {
79+
driver,
80+
bypassInsert: async (values) => {
81+
for (const v of values) insert.run(`bypass-${v}`, v, 'seed replay');
82+
},
83+
};
84+
}
85+
86+
for (const [faceName, open] of [
87+
['LOCAL', localFace],
88+
['REMOTE', remoteFace],
89+
] as const) {
90+
describe(`[#21163] TursoDriver ${faceName}: an autonumber prefix carrying a LIKE metacharacter`, () => {
91+
let face: Face | undefined;
92+
93+
afterEach(async () => {
94+
await face?.driver.disconnect();
95+
face = undefined;
96+
});
97+
98+
for (const c of CASES) {
99+
const role = faceName === 'REMOTE' ? 'passed before the fix' : c.role;
100+
101+
it(`${c.label}: a COLD bootstrap seeds from the stored MAX (${role})`, async () => {
102+
face = await open(c.format);
103+
await face.bypassInsert([c.render(7)]);
104+
105+
const created = await face.driver.create(TABLE, { title: 'first issued' });
106+
expect(created.so_no).toBe(c.render(8));
107+
});
108+
109+
it(`${c.label}: the #5495 re-seed moves a warm counter past rows a bypass write landed (${role})`, async () => {
110+
face = await open(c.format);
111+
112+
// Warmed on an EMPTY table, so this leg does not lean on the cold scan.
113+
expect((await face.driver.create(TABLE, { title: 'warm' })).so_no).toBe(c.render(1));
114+
115+
await face.bypassInsert(Array.from({ length: 29 }, (_, i) => c.render(i + 2)));
116+
117+
const created = await face.driver.create(TABLE, { title: 'after the seeds' });
118+
expect(created.so_no).toBe(c.render(31));
119+
});
120+
}
121+
});
122+
}

0 commit comments

Comments
 (0)