Skip to content

Commit 50aeeb8

Browse files
committed
feat(driver-turso): the remote face issues auto_number values from the shared persistent sequence
Disposition A of the remote-autonumber question, executed: a create, bulkCreate or upsert on the Turso REMOTE transport that leaves an auto_number slot empty now gets a generated value, atomically in the database, from the same `_objectstack_sequences` counter the local and embedded-replica faces draw from. - sql-driver: the rules a second face must share are lifted into protected members (`resolveSequenceTenantId`, `defineSequencesTable`, `maxAutonumberCounter`, `escapeLikePrefix`, the table name and retry budget). No behaviour change on the local faces. - turso-driver: `getNextSequenceValue` routes by transport; the remote face moves the counter in one statement (`UPDATE … RETURNING`, cold path `INSERT … ON CONFLICT DO UPDATE … RETURNING`), bootstraps from the data table by the shared reading, and carries the #5495 re-seed. A legacy (pre-key_hash) table is refused loudly, not keyed by a second rule. - remote-transport: `upsert` takes insert-only columns so a merge keeps the number already in the row. - The refusal suite is converted to the generation suite; the two resync boundary pins are rewritten to pin generation; a cross-process pin runs two writers in two processes against one database file. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9939854 commit 50aeeb8

12 files changed

Lines changed: 1478 additions & 833 deletions

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export type {
4444
// checking with it.
4545
SqlWindowFunctionSpec,
4646
SqlWindowFunctionQuery,
47+
// The autonumber reservation a write draws (#5495): what `fillAutoNumberFields`
48+
// reports and the collision re-seed consumes. Exported so a subclass that
49+
// issues numbers over another transport (`TursoDriver`'s remote face) can
50+
// override the re-seed primitives with their declared parameter type instead
51+
// of a structural copy of it.
52+
AutoNumberReservation,
4753
} from './sql-driver.js';
4854

4955
// [#5222] The cross-field `{ $field }` push-down conformance corpus: one

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

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7593,6 +7593,56 @@ export class SqlDriver implements IDataDriver {
75937593
.digest('hex');
75947594
}
75957595

7596+
// ── The sequence semantics a second face shares, stated once ──────────────
7597+
//
7598+
// `TursoDriver`'s REMOTE face issues record numbers over `@libsql/client`
7599+
// rather than through this class's Knex transaction, and an embedded-replica
7600+
// face and a remote face can point at ONE database. Two faces computing a
7601+
// different counter key, a different tenant bucket or a different data-table
7602+
// bootstrap would hand out colliding numbers on that database, so each rule
7603+
// below is a `protected` member that face CALLS — never a copy it keeps. The
7604+
// members are small on purpose: they are the whole of what decides which
7605+
// counter a value is drawn from and where a cold counter starts.
7606+
7607+
/** The sequence-counter table's name, for a face that spells its own statements against it. */
7608+
protected get sequencesTableName(): string {
7609+
return SEQUENCES_TABLE;
7610+
}
7611+
7612+
/** How many times a write re-seeds and retries on a provable autonumber collision (#5495). */
7613+
protected get autoNumberCollisionRetries(): number {
7614+
return AUTONUMBER_COLLISION_RETRIES;
7615+
}
7616+
7617+
/**
7618+
* The tenant bucket a counter row is keyed by: the row's tenant when the
7619+
* object HAS a tenant column and the row carries one, else the platform
7620+
* bucket `GLOBAL_TENANT`. One spelling for the issue path, the re-seed path
7621+
* and the counter identity, which used to carry it three times.
7622+
*/
7623+
protected resolveSequenceTenantId(tenantField: string | null, tenantId: string | null): string {
7624+
return tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT;
7625+
}
7626+
7627+
/**
7628+
* The column definitions of the `key_hash`-keyed sequences table — the ONE
7629+
* place the shape is spelled. {@link createSequencesTable} runs it through a
7630+
* live connection; a face with no Knex connection compiles the same builder
7631+
* to text (`knex.schema.createTable(name, t => this.defineSequencesTable(t)).toSQL()`)
7632+
* and sends that, so both faces create byte-for-byte the same table.
7633+
*/
7634+
protected defineSequencesTable(t: Knex.CreateTableBuilder): void {
7635+
t.string('key_hash', 64).notNullable().primary();
7636+
t.string('object').notNullable();
7637+
t.string('tenant_id').notNullable();
7638+
t.string('field').notNullable();
7639+
// Non-indexed, so it is free of the PK length limit — a long `{plan_no}`
7640+
// composite scope fits. 1024 is far above any realistic rendered prefix.
7641+
t.string('scope', 1024).notNullable().defaultTo('');
7642+
t.bigInteger('last_value').notNullable().defaultTo(0);
7643+
t.timestamp('updated_at').defaultTo(this.knex.fn.now());
7644+
}
7645+
75967646
/**
75977647
* Create the current `key_hash`-keyed sequences table shape. `runner` is the
75987648
* connection the DDL runs on (a fresh pooled connection by default, or the
@@ -7602,17 +7652,7 @@ export class SqlDriver implements IDataDriver {
76027652
table: string,
76037653
runner: Knex | Knex.Transaction = this.knex,
76047654
): Promise<void> {
7605-
await runner.schema.createTable(table, (t) => {
7606-
t.string('key_hash', 64).notNullable().primary();
7607-
t.string('object').notNullable();
7608-
t.string('tenant_id').notNullable();
7609-
t.string('field').notNullable();
7610-
// Non-indexed, so it is free of the PK length limit — a long `{plan_no}`
7611-
// composite scope fits. 1024 is far above any realistic rendered prefix.
7612-
t.string('scope', 1024).notNullable().defaultTo('');
7613-
t.bigInteger('last_value').notNullable().defaultTo(0);
7614-
t.timestamp('updated_at').defaultTo(this.knex.fn.now());
7615-
});
7655+
await runner.schema.createTable(table, (t) => this.defineSequencesTable(t));
76167656
}
76177657

76187658
/**
@@ -7743,16 +7783,34 @@ export class SqlDriver implements IDataDriver {
77437783
tenantId: string | null,
77447784
suffix = '',
77457785
): Promise<number> {
7746-
const escapedPrefix = prefix.replace(/([\\%_])/g, '\\$1');
7747-
let builder = queryRunner(tableName).select(field).where(field, 'like', `${escapedPrefix}%`).whereNotNull(field);
7786+
let builder = queryRunner(tableName).select(field).where(field, 'like', `${this.escapeLikePrefix(prefix)}%`).whereNotNull(field);
77487787
if (tenantField && tenantId !== null) {
77497788
builder = builder.where(tenantField, tenantId);
77507789
}
77517790
const rows = await builder;
7791+
return this.maxAutonumberCounter((rows as any[]).map((r) => (r as any)[field]), prefix, suffix);
7792+
}
7793+
7794+
/**
7795+
* The rendered prefix as a `LIKE` anchor: `\`, `%` and `_` escaped with a
7796+
* backslash, so a prefix is matched literally. The predicate's pre-filter
7797+
* only — {@link maxAutonumberCounter} re-checks the prefix per row.
7798+
*/
7799+
protected escapeLikePrefix(prefix: string): string {
7800+
return prefix.replace(/([\\%_])/g, '\\$1');
7801+
}
7802+
7803+
/**
7804+
* The bootstrap reading itself — the highest counter among the stored values
7805+
* of one counter's partition, under the anchored / unanchored rules the
7806+
* {@link scanMaxNumericTail} docblock states. Separated from the statement
7807+
* that fetches the values so a face that fetches them through another
7808+
* transport reads them by this one rule rather than by a copy of it.
7809+
*/
7810+
protected maxAutonumberCounter(values: Iterable<unknown>, prefix: string, suffix: string): number {
77527811
let maxN = 0;
77537812
const anchored = prefix !== '' || suffix !== '';
7754-
for (const r of rows as any[]) {
7755-
const v: string = (r as any)[field];
7813+
for (const v of values) {
77567814
if (typeof v !== 'string') continue;
77577815
let n: number;
77587816
if (anchored) {
@@ -7893,7 +7951,7 @@ export class SqlDriver implements IDataDriver {
78937951
// locking on a second one (SQLite pool max=1). `initObjects` normally warms
78947952
// this up front, making the call a no-op — this only bites the lazy path.
78957953
await this.ensureSequencesTable(parentTrx);
7896-
const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT;
7954+
const resolvedTenantId = this.resolveSequenceTenantId(tenantField, tenantId);
78977955
if (scope !== '' && !this.sequencesHasKeyHash) {
78987956
// The legacy sequences table could not be migrated to the key_hash shape,
78997957
// so it cannot represent per-scope counters. Fail with a clear, actionable
@@ -8153,8 +8211,7 @@ export class SqlDriver implements IDataDriver {
81538211
*/
81548212
protected async resyncSequenceToDataMax(reservation: AutoNumberReservation): Promise<void> {
81558213
await this.ensureSequencesTable();
8156-
const resolvedTenantId =
8157-
reservation.tenantField && reservation.tenantId ? String(reservation.tenantId) : GLOBAL_TENANT;
8214+
const resolvedTenantId = this.resolveSequenceTenantId(reservation.tenantField, reservation.tenantId);
81588215
const key = this.sequencesHasKeyHash
81598216
? { key_hash: this.sequenceKeyHash(reservation.tableName, resolvedTenantId, reservation.field, reservation.scope) }
81608217
: { object: reservation.tableName, tenant_id: resolvedTenantId, field: reservation.field };
@@ -8188,8 +8245,7 @@ export class SqlDriver implements IDataDriver {
81888245
* that drew from it. See {@link bulkCreate}.
81898246
*/
81908247
protected autoNumberCounterKey(reservation: AutoNumberReservation): string {
8191-
const resolvedTenantId =
8192-
reservation.tenantField && reservation.tenantId ? String(reservation.tenantId) : GLOBAL_TENANT;
8248+
const resolvedTenantId = this.resolveSequenceTenantId(reservation.tenantField, reservation.tenantId);
81938249
return this.sequenceKeyHash(reservation.tableName, resolvedTenantId, reservation.field, reservation.scope);
81948250
}
81958251

‎packages/drivers/driver-turso/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"devDependencies": {
4848
"@types/node": "^26.2.0",
4949
"better-sqlite3": "^13.0.3",
50+
"tsx": "^4.23.12",
5051
"typescript": "^6.0.3",
5152
"vitest": "^4.1.11"
5253
},

‎packages/drivers/driver-turso/src/remote-transport.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1595,11 +1595,21 @@ export class RemoteTransport {
15951595
return rows[0] ?? null;
15961596
}
15971597

1598+
/**
1599+
* `insertOnlyColumns` — columns written on the INSERT leg and left alone on
1600+
* the MERGE leg, so a value the caller (or the driver, on its behalf) put on
1601+
* the row for a NEW row never overwrites the one an existing row already
1602+
* holds. The transport is told WHICH columns, not WHY: the driver decides
1603+
* from the schema it holds and this class does not (the same division
1604+
* `setFilterColumnSql` and `setTenantFieldResolver` draw). A column named
1605+
* here that is also a merge key is simply a merge key.
1606+
*/
15981607
async upsert(
15991608
object: string,
16001609
data: Record<string, unknown>,
16011610
conflictKeys?: string[],
16021611
table: string = object,
1612+
insertOnlyColumns: readonly string[] = [],
16031613
): Promise<Record<string, unknown>> {
16041614
await this.ensureConnected();
16051615

@@ -1618,7 +1628,7 @@ export class RemoteTransport {
16181628
const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ['id'];
16191629

16201630
// Build ON CONFLICT ... DO UPDATE SET
1621-
const updateCols = columns.filter((c) => !mergeKeys.includes(c));
1631+
const updateCols = columns.filter((c) => !mergeKeys.includes(c) && !insertOnlyColumns.includes(c));
16221632
const updateClauses = updateCols.map((col) => `"${col}" = excluded."${col}"`).join(', ');
16231633

16241634
let sql = `INSERT INTO ${this.tableSql(table)} (${columns.map((c) => `"${c}"`).join(', ')}) VALUES (${placeholders})`;

‎packages/drivers/driver-turso/src/turso-autonumber-batch-resync.test.ts‎

Lines changed: 49 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,33 +15,37 @@
1515
* - **LOCAL (and replica, same local engine)** falls through to
1616
* `super.bulkCreate` / `super.upsert` and with them the re-seed. Asserted
1717
* below on rows.
18-
* - **REMOTE** hands the batch to `RemoteTransport.bulkCreate`, which builds
19-
* its own INSERT and never enters `fillAutoNumberFields` — so it has neither
20-
* the defect nor the fix. On that face `auto_number` is only a column-type
21-
* mapping and no sequence machinery exists to be stale; the boundary is
22-
* pinned as an ASSERTION rather than a comment, so wiring autonumber into
23-
* the remote transport later cannot silently inherit this file's green.
24-
* (That gap was #6944's, not this card's — and #6944 has since answered it.)
18+
* - **REMOTE** used to hand the batch to `RemoteTransport.bulkCreate`, which
19+
* builds its own INSERT and never entered `fillAutoNumberFields` — so it had
20+
* neither the defect nor the fix: on that face `auto_number` was only a
21+
* column-type mapping and no sequence machinery existed to be stale. The
22+
* boundary was pinned as an ASSERTION rather than a comment, so wiring
23+
* autonumber into the remote transport later could not silently inherit
24+
* this file's green. (That gap was #6944's; #6944 answered it with a
25+
* refusal, and #21113 answered it with generation.)
2526
*
26-
* # ⚠️ [#6944] The pin below was rewritten, not deleted
27+
* # ⚠️ [#6944 → #21113] The pin below was rewritten twice, never deleted
2728
*
2829
* This file's REMOTE case pinned an ABSENCE: `RemoteTransport` has no autonumber
2930
* surface on the batch path any more than on the single-row one, and a remote
3031
* `bulkCreate` therefore resolved with `[null, null]` in the slots. #6944
3132
* carried out triage's disposition B and made that face refuse LOUDLY
3233
* (`NOT_IMPLEMENTED`/501), raised on `TursoDriver` because
33-
* `RemoteTransport.create(object, data)` cannot see a field type. The absence
34-
* half is still true and still worth guarding; the "and therefore nothing
35-
* happens" half is not.
34+
* `RemoteTransport.create(object, data)` cannot see a field type. #21113 then
35+
* opened the appetite door (measured demand on the hosted product) and made
36+
* the driver ISSUE the numbers on this face, one row at a time through its own
37+
* `create` — the transport still sees no field type and still gets a row that
38+
* already carries its number. The absence half is still true and still worth
39+
* guarding; "and therefore nothing happens" is not, and neither is "and
40+
* therefore it refuses".
3641
*
3742
* Deleting the pin would remove the guard against a silent half-implementation
3843
* inside the transport; leaving it untouched would keep a green assertion
3944
* standing next to a claim that no longer holds. So it keeps the surface probe
40-
* verbatim and gains the refusal — asserted on `bulkCreate` specifically,
41-
* because Turso OVERRIDES it and `RemoteTransport.bulkCreate` loops its OWN
42-
* `create`, so "`create` refuses" is not on its own an answer about this path
43-
* either. The refusal's own suite is
44-
* `turso-remote-autonumber-refusal.test.ts`.
45+
* verbatim and gains the generation AND the re-seed — asserted on `bulkCreate`
46+
* specifically, because Turso OVERRIDES it, so "`create` generates" is not on
47+
* its own an answer about this path. The generation's own suite is
48+
* `turso-remote-autonumber-generation.test.ts`.
4549
*/
4650

4751
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
@@ -131,26 +135,28 @@ describe('[#6943] TursoDriver batch/upsert autonumber re-seed', () => {
131135
expect(merged.title).toBe('edited');
132136
});
133137

134-
it('REMOTE: the transport that bypasses this path still has no autonumber machinery to re-seed', async () => {
138+
it('REMOTE: the transport still has no autonumber machinery of its own, on the batch path either', async () => {
135139
const remote = new TursoDriver({ url: 'libsql://example.turso.io', authToken: 'placeholder' });
136140
expect(remote.transportMode).toBe('remote');
137141

138142
// The boundary, stated as a fact about the code rather than about a live
139143
// connection: `RemoteTransport` has no autonumber surface at all, on the
140-
// batch path any more than the single-row one. #6944 did not add one — it
141-
// refused one layer up — so this half is unchanged, and it remains what a
142-
// future half-implementation inside the transport has to go through.
144+
// batch path any more than the single-row one. Neither #6944 nor #21113
145+
// added one — both acted one layer up, on the driver — so this half is
146+
// unchanged, and it remains what a future half-implementation inside the
147+
// transport has to go through.
143148
const transportSurface = Object.getOwnPropertyNames(
144149
Object.getPrototypeOf((remote as any).remoteTransport),
145150
);
146151
expect(transportSurface).toContain('bulkCreate');
147152
expect(transportSurface.some((m) => /autonumber|sequence/i.test(m))).toBe(false);
148153
});
149154

150-
it('REMOTE: [#6944] and having none, it refuses the batch rather than writing nothing', async () => {
155+
it('REMOTE: [#21113] and the driver numbers the batch on this face too — and re-seeds it after a seed replay', async () => {
156+
const stub = makeLibsqlSqliteStub();
151157
const remote = new TursoDriver({
152158
url: 'libsql://example.turso.io',
153-
client: makeLibsqlSqliteStub() as never,
159+
client: stub as never,
154160
});
155161
await remote.connect();
156162
await remote.initObjects([
@@ -164,20 +170,27 @@ describe('[#6943] TursoDriver batch/upsert autonumber re-seed', () => {
164170
} as any,
165171
]);
166172

167-
// ⚠️ `code` AND `status`, never a bare `toThrow` (ADR-0112, #6144).
168-
const err = await remote
169-
.bulkCreate('crm_case', [
170-
{ organization_id: 'orgA', title: 'b1' },
171-
{ organization_id: 'orgA', title: 'b2' },
172-
])
173-
.then(
174-
(rows) => {
175-
throw new Error(`expected a refusal, got ${JSON.stringify(rows)}`);
176-
},
177-
(e) => e as Error & { code?: string; status?: number },
178-
);
179-
expect(err.code).toBe('NOT_IMPLEMENTED');
180-
expect(err.status).toBe(501);
173+
const first = await remote.bulkCreate('crm_case', [
174+
{ organization_id: 'orgA', title: 'b1' },
175+
{ organization_id: 'orgA', title: 'b2' },
176+
]);
177+
expect((first as any[]).map((r) => r.case_number)).toEqual(['CASE-00001', 'CASE-00002']);
178+
179+
// The LOCAL half's scenario, on the remote face: rows 3..10 land by a path
180+
// that never enters `fillAutoNumberFields`, so the counter sits at 2 while
181+
// the table holds 10. The next batch is served above it, on the first call.
182+
const insert = stub.raw.prepare(
183+
'insert into "crm_case" ("id", "organization_id", "case_number", "title") values (?, ?, ?, ?)',
184+
);
185+
for (let n = 3; n <= 10; n++) insert.run(`bypass-${n}`, 'orgA', `CASE-${String(n).padStart(5, '0')}`, `row ${n}`);
186+
187+
const after = await remote.bulkCreate('crm_case', [
188+
{ organization_id: 'orgA', title: 'b3' },
189+
{ organization_id: 'orgA', title: 'b4' },
190+
]);
191+
expect((after as any[]).map((r) => r.case_number)).toEqual(['CASE-00011', 'CASE-00012']);
192+
const count = (stub.raw.prepare('select count(*) as c from "crm_case"').all() as Array<{ c: number }>)[0].c;
193+
expect(count).toBe(12);
181194

182195
await remote.disconnect();
183196
});

0 commit comments

Comments
 (0)