Skip to content

Commit c6b6889

Browse files
fix(driver-sql): an autonumber prefix carrying _, % or \ seeds its counter from the stored MAX on SQLite (#21163) (#21206)
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 in `getNextSequenceValue` and the #5495 re-seed `resyncSequenceToDataMax`. It escapes the rendered prefix with `escapeLikePrefix` (a backslash before `\`, `%` and `_`). It then compiled `where(field, 'like', pattern)` through Knex, which declares no `ESCAPE` clause on any dialect. The scan now reads: ```ts .whereRaw('?? like ? escape ?', [field, `${this.escapeLikePrefix(prefix)}%`, LIKE_ESCAPE_CHARACTER]) ``` `LIKE_ESCAPE_CHARACTER` is the driver's existing bound escape character, the one the filter compiler's `LIKE ... 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 declares `ESCAPE` with a backslash) is untouched. ## Why SQLite's `LIKE` has 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 at `cb45469e` with this PR's pins and the pre-fix builder restored: | format | stored before the create | cold create issued | create after rows 2..30 land above a warm counter | |:---|:---|:---|:---| | `SO_{0000}` | `SO_0007` | `SO_0001` | refused: `UNIQUE constraint failed` (on `SO_0005`, retries spent) | | `SO%{0000}` | `SO%0007` | `SO%0001` | refused, same | | `SO\{0000}` | `SO\0007` | `SO\0001` | refused, same | | `{region}-{0000}`, region `north_east` | `north_east-0007` | `north_east-0001` | not pinned | | `SO-{0000}` (control) | `SO-0007` | `SO-0008` | `SO-0031` | The `{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 through `declareDialectCell`. SQLite always runs. Live Postgres and MySQL run where provisioned (`Temporal Conformance (live PG + MySQL)` runs this whole package with `OS_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 #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 (over `makeLibsqlSqliteStub`, 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 `findOne` by an id the test chose. On MySQL `create` returns 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.mjs` replaced the new `whereRaw(...)` with the pre-fix `where(field, 'like', ...)` (anchor 1 to 0, blob `cb63656d` to `5f8edc7a`). Restore: blob equal to HEAD, `git diff HEAD` empty, whole-tree `git status --porcelain` clean. - driver-sql pin over src: **7 failed / 2 passed**. All three escaped prefixes failed cold and on re-seed, and so did the `{field}` case (`expected 'SO_0001' to be 'SO_0008'`, `UNIQUE constraint failed`). The two controls passed. - driver-sql rebuilt, and `node scripts/ablation-dist-preflight.mjs @objectstack/driver-sql '?? like ? escape ?' --absent` confirmed 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. - Restore leg: rebuilt, and preflight `'where(field, "like"' --absent` confirmed 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 in `dist/index.js` and `dist/index.mjs`. ### Live Postgres (local PG 16, `initdb` + `pg_ctl` on a scratch port, torn down after) - With the fix: 18 passed / 1 skipped (9 sqlite + 9 live postgres; mysql un-run). - With the pre-fix builder restored: 7 failed, all in the sqlite cell. 0 failed in live postgres. 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 on `create` return values (insert id `0` on MySQL). The pin now reads the stored row. The whole `driver-sql` suite, 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. - `typecheck` on both packages: exit 0. `tsc --listFilesOnly` includes both new test files in each package's program. - `pnpm check:driver-conformance`: identical before the first edit (at `cb45469e`) 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-debt` hit 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-loads` needs a whole-repo build, and `check:lean-entry-closure` needs objectql's dist, which is outside this diff's closure. For the first one, both changed packages' CJS builds were loaded directly with `require` and loaded OK. `--ran` reconciliation: 63 derived, 61 run, 2 NOT-MEASURED, 0 UNRUN. - Lint was narrowed to the 3 changed TS files: `eslint --no-inline-config --format json` gave 3 files, 0 errors, 0 warnings. Population: `eslint.config.mjs` lints `**/*.{ts,...}` outside its never-linted build dirs, and no file reported as ignored. Invariance: the config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. Repo-wide `pnpm lint` is CI's. ## Acceptance notes - `escapeLikeComparand` (the filter compiler's module function) and `escapeLikePrefix` (this method) escape the identical set: a backslash before each of `\`, `%` and `_`. They were not merged. Triage named `escapeLikePrefix` as this path's one helper, and nothing here needed a merge. - The Turso remote face writes its escape as an SQL literal. That is correct, because it only ever talks to SQLite. Its new REMOTE legs are the guard that keeps the two faces' escape characters equal. - `SqliteWasmDriver` extends `SqlDriver` and inherits the scan. A one-off probe after the fix (not committed) issued `SO_0008` over a stored `SO_0007` on that face. That face has no permanent pin here, and its pre-fix answer was not measured. - Counters that an earlier release already seeded too low are not rewritten by this change. The next collision on such a counter now re-seeds it from the stored MAX, as on any other prefix. - Patch round 2 (`f71f31b2`, seat edit): the branch merged `origin/main` at `097ef802`, a clean merge with the same 4 files in the net diff. It carries the sibling #21208 (`862f12c0b`, `driver-turso`), and it re-ran the `Type Check · source gates` lane that had timed out at `5b83098b`. Every check is green on this head. --- _Generated by [Claude Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 682873d commit c6b6889

4 files changed

Lines changed: 344 additions & 3 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
fix(driver-sql): an autonumber format whose rendered prefix carries `_`, `%` or `\` seeds its counter from the stored MAX on SQLite
6+
7+
Clause-②: no
8+
9+
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:
10+
11+
- **Cold**, the counter started at 1 under numbers already stored. Measured: a format `SO_{0000}` over a stored `SO_0007` issued `SO_0001`.
12+
- **On the re-seed**, the counter could not move, so every retry collided again and the create was refused once the retries ran out.
13+
14+
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.
15+
16+
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.
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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 number the create issued, on a cold bootstrap over a stored row and on
40+
* the create that follows a bypass write landing ABOVE a warm counter — read
41+
* back from the stored row through the driver's own `findOne`, by an id the
42+
* test chose. Not from `create`'s return value: on MySQL that is not the row.
43+
* Knex does not support `.returning()` there, so `create` hands back the insert
44+
* id (measured on a live MySQL 8.0.46: `0`, with the row stored correctly), and
45+
* an assertion on it fails every case, controls included, whatever the scan
46+
* does. The stored row is the one reading every cell can make.
47+
*
48+
* Per dialect cell: SQLite always runs; live Postgres and MySQL
49+
* run where provisioned (the `Temporal Conformance (live PG + MySQL)` job runs
50+
* this whole package against both) and are declared un-run otherwise. On those
51+
* two the backslash was already the default escape, so their cells pin that
52+
* DECLARING it changed nothing there — they passed before the fix and must
53+
* pass after it.
54+
*
55+
* The plain prefix is the control on every cell: it has nothing to escape, so
56+
* it was right before and after, and a fix that broke the scan wholesale would
57+
* redden it too.
58+
*/
59+
60+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
61+
import { SqlDriver } from '../src/index.js';
62+
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';
63+
64+
const TABLE = 'os21163_like_escape';
65+
const SEQUENCES_TABLE = '_objectstack_sequences';
66+
67+
interface PrefixCase {
68+
/** Suite label. */
69+
label: string;
70+
/** The declared format. */
71+
format: string;
72+
/** The stored value for counter `n` under this format. */
73+
render: (n: number) => string;
74+
/** True when the rendered prefix carries a character `escapeLikePrefix` escapes. */
75+
escaped: boolean;
76+
}
77+
78+
const pad4 = (n: number) => String(n).padStart(4, '0');
79+
80+
const STATIC_PREFIX_CASES: readonly PrefixCase[] = [
81+
{ label: '`_` in the prefix', format: 'SO_{0000}', render: (n) => `SO_${pad4(n)}`, escaped: true },
82+
{ label: '`%` in the prefix', format: 'SO%{0000}', render: (n) => `SO%${pad4(n)}`, escaped: true },
83+
{ label: '`\\` in the prefix', format: 'SO\\{0000}', render: (n) => `SO\\${pad4(n)}`, escaped: true },
84+
{ label: 'a plain prefix (control)', format: 'SO-{0000}', render: (n) => `SO-${pad4(n)}`, escaped: false },
85+
];
86+
87+
const objectWith = (format: string) =>
88+
({
89+
name: TABLE,
90+
fields: {
91+
region: { type: 'string' },
92+
so_no: { type: 'autonumber', format, unique: true },
93+
title: { type: 'string' },
94+
},
95+
}) as any;
96+
97+
function suite(cell: DialectCell) {
98+
describe(`sql-driver — autonumber prefix LIKE escape (${cell.label}) [#21163]`, () => {
99+
let driver: SqlDriver;
100+
101+
const knex = () => (driver as any).knex;
102+
103+
/** Drop the data table and this object's counter rows, so every test starts COLD. */
104+
const reset = async () => {
105+
await knex().schema.dropTableIfExists(TABLE);
106+
await knex()(SEQUENCES_TABLE)
107+
.where('object', TABLE)
108+
.del()
109+
.catch(() => {});
110+
};
111+
112+
/** Land rows by a path that bypasses `fillAutoNumberFields`, as a seed replay does. */
113+
const bypassInsert = async (values: string[], region: string | null = null) => {
114+
await knex()(TABLE).insert(
115+
values.map((v) => ({ id: `bypass-${v}`, region, so_no: v, title: 'seed replay' })),
116+
);
117+
};
118+
119+
let seq = 0;
120+
121+
/** Create one row and answer the autonumber it was STORED with (see the header). */
122+
const issue = async (data: Record<string, unknown>): Promise<unknown> => {
123+
const id = `issued-${++seq}`;
124+
await driver.create(TABLE, { id, ...data });
125+
const row = await driver.findOne(TABLE, { where: { id } });
126+
expect(row, `row ${id} was not stored`).not.toBeNull();
127+
return row!.so_no;
128+
};
129+
130+
beforeEach(async () => {
131+
driver = new SqlDriver(cell.config());
132+
await reset();
133+
});
134+
135+
afterEach(async () => {
136+
await reset();
137+
await driver.disconnect();
138+
});
139+
140+
for (const c of STATIC_PREFIX_CASES) {
141+
const role = c.escaped ? 'red before the fix on SQLite' : 'control';
142+
143+
it(`${c.label}: a COLD bootstrap seeds from the stored MAX (${role})`, async () => {
144+
await driver.initObjects([objectWith(c.format)]);
145+
await bypassInsert([c.render(7)]);
146+
147+
expect(await issue({ title: 'first issued' })).toBe(c.render(8));
148+
});
149+
150+
it(`${c.label}: the #5495 re-seed moves a warm counter past rows a bypass write landed (${role})`, async () => {
151+
await driver.initObjects([objectWith(c.format)]);
152+
153+
// Warm the counter on an EMPTY table, so this test does not depend on
154+
// the cold scan above: there is nothing for the bootstrap to find.
155+
expect(await issue({ title: 'warm' })).toBe(c.render(1));
156+
157+
// Rows 2..30 land above the counter. Nothing tells the sequence.
158+
await bypassInsert(Array.from({ length: 29 }, (_, i) => c.render(i + 2)));
159+
160+
expect(await issue({ title: 'after the seeds' })).toBe(c.render(31));
161+
});
162+
}
163+
164+
it('a `_` that arrives from DATA through `{field}` interpolation seeds from the stored MAX (red before the fix on SQLite)', async () => {
165+
await driver.initObjects([objectWith('{region}-{0000}')]);
166+
await bypassInsert(['north_east-0007'], 'north_east');
167+
168+
expect(await issue({ region: 'north_east', title: 'first issued' })).toBe('north_east-0008');
169+
});
170+
});
171+
}
172+
173+
for (const cell of DIALECT_CELLS) {
174+
declareDialectCell(cell, 'autonumber prefix LIKE escape (#21163)', suite);
175+
}

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

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7477,6 +7477,28 @@ export class SqlDriver implements IDataDriver {
74777477
* duplicate-record-number harm, self-inflicted. The predicate therefore stays
74787478
* `prefix%` and the suffix is applied per row, where a non-match simply means
74797479
* "different suffix, same counter".
7480+
*
7481+
* ## The escape the prefix is escaped FOR is declared, on every dialect (#21163)
7482+
*
7483+
* {@link escapeLikePrefix} writes a backslash before each `\`, `%` and `_`,
7484+
* which only means "literally" under a `LIKE` whose escape character IS that
7485+
* backslash. SQLite's `LIKE` has no escape character unless one is declared,
7486+
* and Knex's `where(col, 'like', …)` declares none on any dialect. Measured on
7487+
* better-sqlite3 before this change: the pattern `SO\_%` without `ESCAPE`
7488+
* matched nothing against a stored `SO_0007`, so on every SQLite face a
7489+
* format whose rendered prefix carries `_`, `%` or `\` — from the format's
7490+
* literal text OR from a `{field}` value such as `north_east` — scanned an
7491+
* empty partition: the cold bootstrap seeded the counter from 0 and the
7492+
* #5495 re-seed could not move it. Postgres and MySQL read a backslash as the
7493+
* default `LIKE` escape, so they were right by default rather than by
7494+
* declaration.
7495+
*
7496+
* The character is BOUND, never written as a literal — the same
7497+
* {@link LIKE_ESCAPE_CHARACTER} the filter compiler binds, for the reason
7498+
* given there: MySQL applies C escape syntax inside string literals, so a
7499+
* literal backslash is spelled differently per dialect while a bound value
7500+
* has one spelling everywhere. Turso's remote face sends its own statement
7501+
* to a SQLite engine only, and declares the same backslash there.
74807502
*/
74817503
protected async scanMaxNumericTail(
74827504
queryRunner: Knex | Knex.Transaction,
@@ -7487,7 +7509,10 @@ export class SqlDriver implements IDataDriver {
74877509
tenantId: string | null,
74887510
suffix = '',
74897511
): Promise<number> {
7490-
let builder = queryRunner(tableName).select(field).where(field, 'like', `${this.escapeLikePrefix(prefix)}%`).whereNotNull(field);
7512+
let builder = queryRunner(tableName)
7513+
.select(field)
7514+
.whereRaw('?? like ? escape ?', [field, `${this.escapeLikePrefix(prefix)}%`, LIKE_ESCAPE_CHARACTER])
7515+
.whereNotNull(field);
74917516
if (tenantField && tenantId !== null) {
74927517
builder = builder.where(tenantField, tenantId);
74937518
}
@@ -7497,8 +7522,11 @@ export class SqlDriver implements IDataDriver {
74977522

74987523
/**
74997524
* The rendered prefix as a `LIKE` anchor: `\`, `%` and `_` escaped with a
7500-
* backslash, so a prefix is matched literally. The predicate's pre-filter
7501-
* only — {@link maxAutonumberCounter} re-checks the prefix per row.
7525+
* backslash, so a prefix is matched literally — under a `LIKE` that declares
7526+
* that backslash as its `ESCAPE`, which every statement using this anchor
7527+
* must do (see {@link scanMaxNumericTail}; SQLite has no escape character
7528+
* otherwise). The predicate's pre-filter only — {@link maxAutonumberCounter}
7529+
* re-checks the prefix per row.
75027530
*/
75037531
protected escapeLikePrefix(prefix: string): string {
75047532
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)