From 50aeeb8c6529236e2f3d04668b7b4cb2f4b8f93a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 1 Oct 2026 08:49:02 +0000 Subject: [PATCH 1/7] feat(driver-turso): the remote face issues auto_number values from the shared persistent sequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/drivers/driver-sql/src/index.ts | 6 + packages/drivers/driver-sql/src/sql-driver.ts | 96 ++- packages/drivers/driver-turso/package.json | 1 + .../driver-turso/src/remote-transport.ts | 12 +- .../src/turso-autonumber-batch-resync.test.ts | 85 ++- .../src/turso-autonumber-resync.test.ts | 82 ++- .../drivers/driver-turso/src/turso-driver.ts | 657 ++++++++++++------ ...urso-remote-autonumber-concurrency.test.ts | 228 ++++++ ...turso-remote-autonumber-generation.test.ts | 601 ++++++++++++++++ .../turso-remote-autonumber-refusal.test.ts | 537 -------------- .../src/turso-update-missing-id.test.ts | 3 +- pnpm-lock.yaml | 3 + 12 files changed, 1478 insertions(+), 833 deletions(-) create mode 100644 packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts create mode 100644 packages/drivers/driver-turso/src/turso-remote-autonumber-generation.test.ts delete mode 100644 packages/drivers/driver-turso/src/turso-remote-autonumber-refusal.test.ts diff --git a/packages/drivers/driver-sql/src/index.ts b/packages/drivers/driver-sql/src/index.ts index cb0a71d2536..646e2804b87 100644 --- a/packages/drivers/driver-sql/src/index.ts +++ b/packages/drivers/driver-sql/src/index.ts @@ -44,6 +44,12 @@ export type { // checking with it. SqlWindowFunctionSpec, SqlWindowFunctionQuery, + // The autonumber reservation a write draws (#5495): what `fillAutoNumberFields` + // reports and the collision re-seed consumes. Exported so a subclass that + // issues numbers over another transport (`TursoDriver`'s remote face) can + // override the re-seed primitives with their declared parameter type instead + // of a structural copy of it. + AutoNumberReservation, } from './sql-driver.js'; // [#5222] The cross-field `{ $field }` push-down conformance corpus: one diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 60b9ceaf529..4ef51078a5f 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -7593,6 +7593,56 @@ export class SqlDriver implements IDataDriver { .digest('hex'); } + // ── The sequence semantics a second face shares, stated once ────────────── + // + // `TursoDriver`'s REMOTE face issues record numbers over `@libsql/client` + // rather than through this class's Knex transaction, and an embedded-replica + // face and a remote face can point at ONE database. Two faces computing a + // different counter key, a different tenant bucket or a different data-table + // bootstrap would hand out colliding numbers on that database, so each rule + // below is a `protected` member that face CALLS — never a copy it keeps. The + // members are small on purpose: they are the whole of what decides which + // counter a value is drawn from and where a cold counter starts. + + /** The sequence-counter table's name, for a face that spells its own statements against it. */ + protected get sequencesTableName(): string { + return SEQUENCES_TABLE; + } + + /** How many times a write re-seeds and retries on a provable autonumber collision (#5495). */ + protected get autoNumberCollisionRetries(): number { + return AUTONUMBER_COLLISION_RETRIES; + } + + /** + * The tenant bucket a counter row is keyed by: the row's tenant when the + * object HAS a tenant column and the row carries one, else the platform + * bucket `GLOBAL_TENANT`. One spelling for the issue path, the re-seed path + * and the counter identity, which used to carry it three times. + */ + protected resolveSequenceTenantId(tenantField: string | null, tenantId: string | null): string { + return tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT; + } + + /** + * The column definitions of the `key_hash`-keyed sequences table — the ONE + * place the shape is spelled. {@link createSequencesTable} runs it through a + * live connection; a face with no Knex connection compiles the same builder + * to text (`knex.schema.createTable(name, t => this.defineSequencesTable(t)).toSQL()`) + * and sends that, so both faces create byte-for-byte the same table. + */ + protected defineSequencesTable(t: Knex.CreateTableBuilder): void { + t.string('key_hash', 64).notNullable().primary(); + t.string('object').notNullable(); + t.string('tenant_id').notNullable(); + t.string('field').notNullable(); + // Non-indexed, so it is free of the PK length limit — a long `{plan_no}` + // composite scope fits. 1024 is far above any realistic rendered prefix. + t.string('scope', 1024).notNullable().defaultTo(''); + t.bigInteger('last_value').notNullable().defaultTo(0); + t.timestamp('updated_at').defaultTo(this.knex.fn.now()); + } + /** * Create the current `key_hash`-keyed sequences table shape. `runner` is the * connection the DDL runs on (a fresh pooled connection by default, or the @@ -7602,17 +7652,7 @@ export class SqlDriver implements IDataDriver { table: string, runner: Knex | Knex.Transaction = this.knex, ): Promise { - await runner.schema.createTable(table, (t) => { - t.string('key_hash', 64).notNullable().primary(); - t.string('object').notNullable(); - t.string('tenant_id').notNullable(); - t.string('field').notNullable(); - // Non-indexed, so it is free of the PK length limit — a long `{plan_no}` - // composite scope fits. 1024 is far above any realistic rendered prefix. - t.string('scope', 1024).notNullable().defaultTo(''); - t.bigInteger('last_value').notNullable().defaultTo(0); - t.timestamp('updated_at').defaultTo(this.knex.fn.now()); - }); + await runner.schema.createTable(table, (t) => this.defineSequencesTable(t)); } /** @@ -7743,16 +7783,34 @@ export class SqlDriver implements IDataDriver { tenantId: string | null, suffix = '', ): Promise { - const escapedPrefix = prefix.replace(/([\\%_])/g, '\\$1'); - let builder = queryRunner(tableName).select(field).where(field, 'like', `${escapedPrefix}%`).whereNotNull(field); + let builder = queryRunner(tableName).select(field).where(field, 'like', `${this.escapeLikePrefix(prefix)}%`).whereNotNull(field); if (tenantField && tenantId !== null) { builder = builder.where(tenantField, tenantId); } const rows = await builder; + return this.maxAutonumberCounter((rows as any[]).map((r) => (r as any)[field]), prefix, suffix); + } + + /** + * 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. + */ + protected escapeLikePrefix(prefix: string): string { + return prefix.replace(/([\\%_])/g, '\\$1'); + } + + /** + * The bootstrap reading itself — the highest counter among the stored values + * of one counter's partition, under the anchored / unanchored rules the + * {@link scanMaxNumericTail} docblock states. Separated from the statement + * that fetches the values so a face that fetches them through another + * transport reads them by this one rule rather than by a copy of it. + */ + protected maxAutonumberCounter(values: Iterable, prefix: string, suffix: string): number { let maxN = 0; const anchored = prefix !== '' || suffix !== ''; - for (const r of rows as any[]) { - const v: string = (r as any)[field]; + for (const v of values) { if (typeof v !== 'string') continue; let n: number; if (anchored) { @@ -7893,7 +7951,7 @@ export class SqlDriver implements IDataDriver { // locking on a second one (SQLite pool max=1). `initObjects` normally warms // this up front, making the call a no-op — this only bites the lazy path. await this.ensureSequencesTable(parentTrx); - const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT; + const resolvedTenantId = this.resolveSequenceTenantId(tenantField, tenantId); if (scope !== '' && !this.sequencesHasKeyHash) { // The legacy sequences table could not be migrated to the key_hash shape, // so it cannot represent per-scope counters. Fail with a clear, actionable @@ -8153,8 +8211,7 @@ export class SqlDriver implements IDataDriver { */ protected async resyncSequenceToDataMax(reservation: AutoNumberReservation): Promise { await this.ensureSequencesTable(); - const resolvedTenantId = - reservation.tenantField && reservation.tenantId ? String(reservation.tenantId) : GLOBAL_TENANT; + const resolvedTenantId = this.resolveSequenceTenantId(reservation.tenantField, reservation.tenantId); const key = this.sequencesHasKeyHash ? { key_hash: this.sequenceKeyHash(reservation.tableName, resolvedTenantId, reservation.field, reservation.scope) } : { object: reservation.tableName, tenant_id: resolvedTenantId, field: reservation.field }; @@ -8188,8 +8245,7 @@ export class SqlDriver implements IDataDriver { * that drew from it. See {@link bulkCreate}. */ protected autoNumberCounterKey(reservation: AutoNumberReservation): string { - const resolvedTenantId = - reservation.tenantField && reservation.tenantId ? String(reservation.tenantId) : GLOBAL_TENANT; + const resolvedTenantId = this.resolveSequenceTenantId(reservation.tenantField, reservation.tenantId); return this.sequenceKeyHash(reservation.tableName, resolvedTenantId, reservation.field, reservation.scope); } diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json index 8e0ac4d6fac..99542c837bb 100644 --- a/packages/drivers/driver-turso/package.json +++ b/packages/drivers/driver-turso/package.json @@ -47,6 +47,7 @@ "devDependencies": { "@types/node": "^26.2.0", "better-sqlite3": "^13.0.3", + "tsx": "^4.23.12", "typescript": "^6.0.3", "vitest": "^4.1.11" }, diff --git a/packages/drivers/driver-turso/src/remote-transport.ts b/packages/drivers/driver-turso/src/remote-transport.ts index e1f1c45f18f..23cdc39099a 100644 --- a/packages/drivers/driver-turso/src/remote-transport.ts +++ b/packages/drivers/driver-turso/src/remote-transport.ts @@ -1595,11 +1595,21 @@ export class RemoteTransport { return rows[0] ?? null; } + /** + * `insertOnlyColumns` — columns written on the INSERT leg and left alone on + * the MERGE leg, so a value the caller (or the driver, on its behalf) put on + * the row for a NEW row never overwrites the one an existing row already + * holds. The transport is told WHICH columns, not WHY: the driver decides + * from the schema it holds and this class does not (the same division + * `setFilterColumnSql` and `setTenantFieldResolver` draw). A column named + * here that is also a merge key is simply a merge key. + */ async upsert( object: string, data: Record, conflictKeys?: string[], table: string = object, + insertOnlyColumns: readonly string[] = [], ): Promise> { await this.ensureConnected(); @@ -1618,7 +1628,7 @@ export class RemoteTransport { const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ['id']; // Build ON CONFLICT ... DO UPDATE SET - const updateCols = columns.filter((c) => !mergeKeys.includes(c)); + const updateCols = columns.filter((c) => !mergeKeys.includes(c) && !insertOnlyColumns.includes(c)); const updateClauses = updateCols.map((col) => `"${col}" = excluded."${col}"`).join(', '); let sql = `INSERT INTO ${this.tableSql(table)} (${columns.map((c) => `"${c}"`).join(', ')}) VALUES (${placeholders})`; diff --git a/packages/drivers/driver-turso/src/turso-autonumber-batch-resync.test.ts b/packages/drivers/driver-turso/src/turso-autonumber-batch-resync.test.ts index 852a87e8aa9..ca0de405546 100644 --- a/packages/drivers/driver-turso/src/turso-autonumber-batch-resync.test.ts +++ b/packages/drivers/driver-turso/src/turso-autonumber-batch-resync.test.ts @@ -15,33 +15,37 @@ * - **LOCAL (and replica, same local engine)** falls through to * `super.bulkCreate` / `super.upsert` and with them the re-seed. Asserted * below on rows. - * - **REMOTE** hands the batch to `RemoteTransport.bulkCreate`, which builds - * its own INSERT and never enters `fillAutoNumberFields` — so it has neither - * the defect nor the fix. On that face `auto_number` is only a column-type - * mapping and no sequence machinery exists to be stale; the boundary is - * pinned as an ASSERTION rather than a comment, so wiring autonumber into - * the remote transport later cannot silently inherit this file's green. - * (That gap was #6944's, not this card's — and #6944 has since answered it.) + * - **REMOTE** used to hand the batch to `RemoteTransport.bulkCreate`, which + * builds its own INSERT and never entered `fillAutoNumberFields` — so it had + * neither the defect nor the fix: on that face `auto_number` was only a + * column-type mapping and no sequence machinery existed to be stale. The + * boundary was pinned as an ASSERTION rather than a comment, so wiring + * autonumber into the remote transport later could not silently inherit + * this file's green. (That gap was #6944's; #6944 answered it with a + * refusal, and #21113 answered it with generation.) * - * # ⚠️ [#6944] The pin below was rewritten, not deleted + * # ⚠️ [#6944 → #21113] The pin below was rewritten twice, never deleted * * This file's REMOTE case pinned an ABSENCE: `RemoteTransport` has no autonumber * surface on the batch path any more than on the single-row one, and a remote * `bulkCreate` therefore resolved with `[null, null]` in the slots. #6944 * carried out triage's disposition B and made that face refuse LOUDLY * (`NOT_IMPLEMENTED`/501), raised on `TursoDriver` because - * `RemoteTransport.create(object, data)` cannot see a field type. The absence - * half is still true and still worth guarding; the "and therefore nothing - * happens" half is not. + * `RemoteTransport.create(object, data)` cannot see a field type. #21113 then + * opened the appetite door (measured demand on the hosted product) and made + * the driver ISSUE the numbers on this face, one row at a time through its own + * `create` — the transport still sees no field type and still gets a row that + * already carries its number. The absence half is still true and still worth + * guarding; "and therefore nothing happens" is not, and neither is "and + * therefore it refuses". * * Deleting the pin would remove the guard against a silent half-implementation * inside the transport; leaving it untouched would keep a green assertion * standing next to a claim that no longer holds. So it keeps the surface probe - * verbatim and gains the refusal — asserted on `bulkCreate` specifically, - * because Turso OVERRIDES it and `RemoteTransport.bulkCreate` loops its OWN - * `create`, so "`create` refuses" is not on its own an answer about this path - * either. The refusal's own suite is - * `turso-remote-autonumber-refusal.test.ts`. + * verbatim and gains the generation AND the re-seed — asserted on `bulkCreate` + * specifically, because Turso OVERRIDES it, so "`create` generates" is not on + * its own an answer about this path. The generation's own suite is + * `turso-remote-autonumber-generation.test.ts`. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; @@ -131,15 +135,16 @@ describe('[#6943] TursoDriver batch/upsert autonumber re-seed', () => { expect(merged.title).toBe('edited'); }); - it('REMOTE: the transport that bypasses this path still has no autonumber machinery to re-seed', async () => { + it('REMOTE: the transport still has no autonumber machinery of its own, on the batch path either', async () => { const remote = new TursoDriver({ url: 'libsql://example.turso.io', authToken: 'placeholder' }); expect(remote.transportMode).toBe('remote'); // The boundary, stated as a fact about the code rather than about a live // connection: `RemoteTransport` has no autonumber surface at all, on the - // batch path any more than the single-row one. #6944 did not add one — it - // refused one layer up — so this half is unchanged, and it remains what a - // future half-implementation inside the transport has to go through. + // batch path any more than the single-row one. Neither #6944 nor #21113 + // added one — both acted one layer up, on the driver — so this half is + // unchanged, and it remains what a future half-implementation inside the + // transport has to go through. const transportSurface = Object.getOwnPropertyNames( Object.getPrototypeOf((remote as any).remoteTransport), ); @@ -147,10 +152,11 @@ describe('[#6943] TursoDriver batch/upsert autonumber re-seed', () => { expect(transportSurface.some((m) => /autonumber|sequence/i.test(m))).toBe(false); }); - it('REMOTE: [#6944] and having none, it refuses the batch rather than writing nothing', async () => { + it('REMOTE: [#21113] and the driver numbers the batch on this face too — and re-seeds it after a seed replay', async () => { + const stub = makeLibsqlSqliteStub(); const remote = new TursoDriver({ url: 'libsql://example.turso.io', - client: makeLibsqlSqliteStub() as never, + client: stub as never, }); await remote.connect(); await remote.initObjects([ @@ -164,20 +170,27 @@ describe('[#6943] TursoDriver batch/upsert autonumber re-seed', () => { } as any, ]); - // ⚠️ `code` AND `status`, never a bare `toThrow` (ADR-0112, #6144). - const err = await remote - .bulkCreate('crm_case', [ - { organization_id: 'orgA', title: 'b1' }, - { organization_id: 'orgA', title: 'b2' }, - ]) - .then( - (rows) => { - throw new Error(`expected a refusal, got ${JSON.stringify(rows)}`); - }, - (e) => e as Error & { code?: string; status?: number }, - ); - expect(err.code).toBe('NOT_IMPLEMENTED'); - expect(err.status).toBe(501); + const first = await remote.bulkCreate('crm_case', [ + { organization_id: 'orgA', title: 'b1' }, + { organization_id: 'orgA', title: 'b2' }, + ]); + expect((first as any[]).map((r) => r.case_number)).toEqual(['CASE-00001', 'CASE-00002']); + + // The LOCAL half's scenario, on the remote face: rows 3..10 land by a path + // that never enters `fillAutoNumberFields`, so the counter sits at 2 while + // the table holds 10. The next batch is served above it, on the first call. + const insert = stub.raw.prepare( + 'insert into "crm_case" ("id", "organization_id", "case_number", "title") values (?, ?, ?, ?)', + ); + for (let n = 3; n <= 10; n++) insert.run(`bypass-${n}`, 'orgA', `CASE-${String(n).padStart(5, '0')}`, `row ${n}`); + + const after = await remote.bulkCreate('crm_case', [ + { organization_id: 'orgA', title: 'b3' }, + { organization_id: 'orgA', title: 'b4' }, + ]); + expect((after as any[]).map((r) => r.case_number)).toEqual(['CASE-00011', 'CASE-00012']); + const count = (stub.raw.prepare('select count(*) as c from "crm_case"').all() as Array<{ c: number }>)[0].c; + expect(count).toBe(12); await remote.disconnect(); }); diff --git a/packages/drivers/driver-turso/src/turso-autonumber-resync.test.ts b/packages/drivers/driver-turso/src/turso-autonumber-resync.test.ts index cd388a68c5f..2603a235da9 100644 --- a/packages/drivers/driver-turso/src/turso-autonumber-resync.test.ts +++ b/packages/drivers/driver-turso/src/turso-autonumber-resync.test.ts @@ -20,29 +20,35 @@ * autonumber into the remote transport later cannot silently inherit this * file's green. * - * # ⚠️ [#6944] What that pin says now — rewritten, not deleted + * # ⚠️ [#6944 → #21113] What that pin says now — rewritten twice, never deleted * * When this file was written the remote face was SILENTLY ABSENT: a create * resolved and left NULL in the slot. The pin below said exactly that — * `RemoteTransport` carries no autonumber surface at all. #6944 carried out * triage's disposition B and made that face refuse LOUDLY instead - * (`NOT_IMPLEMENTED`/501), raised on `TursoDriver` rather than on the transport, - * because `RemoteTransport.create(object, data)` cannot see a field type. + * (`NOT_IMPLEMENTED`/501), raised on `TursoDriver` rather than on the + * transport, because `RemoteTransport.create(object, data)` cannot see a + * field type. #21113 then opened the appetite door disposition B had kept shut + * (measured demand on the hosted product) and made that face ISSUE the number + * — again on `TursoDriver`, for the same reason, from the same persistent + * `_objectstack_sequences` counter this file's LOCAL half re-seeds. * - * So the shape moved from ABSENT to EXPLICITLY REFUSED, and this pin had two - * wrong options and one right one: + * So the shape moved ABSENT → EXPLICITLY REFUSED → GENERATED, and at each step + * this pin had two wrong options and one right one: * - * - DELETE it — and lose the only guard that stops a future half-implementation + * - DELETE it — and lose the only guard that stops a half-implementation * landing quietly inside the transport; - * - LEAVE it unchanged — and keep a green assertion whose surrounding claim, - * "that face simply doesn't have this", is no longer the whole truth. + * - LEAVE it unchanged — and keep a green assertion whose surrounding claim + * is no longer the whole truth. * - * It is therefore rewritten to pin BOTH halves of the fact as it now stands: the - * transport still carries no autonumber surface (the original guard, verbatim), - * and the driver now answers with a wire identity instead of writing nothing - * (the new fact). The refusal's own suite is - * `turso-remote-autonumber-refusal.test.ts`; what belongs here is only the - * boundary this file's LOCAL half is held against. + * It is therefore rewritten to pin BOTH halves of the fact as it now stands: + * the transport still carries no autonumber surface (the original guard, + * verbatim), and the driver now issues the number AND re-seeds on this face + * too — the very defect this file is about, which the remote face "neither had + * nor received" while it issued nothing. The generation's own suite is + * `turso-remote-autonumber-generation.test.ts`; what belongs here is only the + * boundary this file's LOCAL half is held against, and the remote half of the + * same re-seed. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; @@ -90,28 +96,30 @@ describe('[#5495] TursoDriver autonumber re-seed', () => { expect(created.case_number).toBe('CASE-00031'); }); - it('REMOTE: the transport that bypasses this path still has no autonumber machinery to re-seed', async () => { + it('REMOTE: the transport still has no autonumber machinery of its own', async () => { const remote = new TursoDriver({ url: 'libsql://example.turso.io', authToken: 'placeholder' }); expect(remote.transportMode).toBe('remote'); // The boundary, stated as a fact about the code rather than about a live - // connection: `RemoteTransport` has no autonumber surface at all. #6944 did - // not add one — it refused one layer up — so this half is unchanged, and it - // remains what a future half-implementation inside the transport has to go - // through. + // connection: `RemoteTransport` has no autonumber surface at all. Neither + // #6944 nor #21113 added one — both acted one layer up, on the driver, + // which hands the transport a row already carrying its number — so this + // half is unchanged, and it remains what a future half-implementation + // inside the transport has to go through. const transportSurface = Object.getOwnPropertyNames( Object.getPrototypeOf((remote as any).remoteTransport), ); expect(transportSurface.some((m) => /autonumber|sequence/i.test(m))).toBe(false); }); - it('REMOTE: [#6944] and having none, it refuses the create rather than writing nothing', async () => { - // The other half of the same boundary, and the half that moved. Needs a - // client because the refusal reads `autoNumberFields`, which only remote - // schema-sync populates — the same registration `find()` depends on. + it('REMOTE: [#21113] and the driver issues the number on this face too — and re-seeds it after a seed replay', async () => { + // The other half of the same boundary, and the half that moved twice. + // Needs a client because generation reads `autoNumberFields`, which only + // remote schema-sync populates — the same registration `find()` depends on. + const stub = makeLibsqlSqliteStub(); const remote = new TursoDriver({ url: 'libsql://example.turso.io', - client: makeLibsqlSqliteStub() as never, + client: stub as never, }); await remote.connect(); await remote.initObjects([ @@ -125,19 +133,19 @@ describe('[#5495] TursoDriver autonumber re-seed', () => { } as any, ]); - // ⚠️ `code` AND `status`, never a bare `toThrow` (ADR-0112, #6144): the - // point of this pin is the wire identity, and a bare throw assertion would - // be satisfied by any error a later refactor happens to raise here. - const err = await remote - .create('crm_case', { organization_id: 'orgA', title: 'first' }) - .then( - (row) => { - throw new Error(`expected a refusal, got ${JSON.stringify(row)}`); - }, - (e) => e as Error & { code?: string; status?: number }, - ); - expect(err.code).toBe('NOT_IMPLEMENTED'); - expect(err.status).toBe(501); + const first = await remote.create('crm_case', { organization_id: 'orgA', title: 'first' }); + expect(first.case_number).toBe('CASE-00001'); + + // The LOCAL half's scenario, on the remote face: rows 2..30 land by a path + // that never enters `fillAutoNumberFields`, so the counter sits at 1 while + // the table holds 30. The next create is served, at 31, on the first call. + const insert = stub.raw.prepare( + 'insert into "crm_case" ("id", "organization_id", "case_number", "title") values (?, ?, ?, ?)', + ); + for (let n = 2; n <= 30; n++) insert.run(`bypass-${n}`, 'orgA', `CASE-${String(n).padStart(5, '0')}`, `row ${n}`); + + const created = await remote.create('crm_case', { organization_id: 'orgA', title: 'after the replay' }); + expect(created.case_number).toBe('CASE-00031'); await remote.disconnect(); }); diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 381bc8188ca..9d5efdce452 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -30,6 +30,8 @@ import { SqlDriver, + GLOBAL_TENANT, + type AutoNumberReservation, type IntrospectedSchema, type ManagedDriftEntry, type SqlDriverConfig, @@ -232,7 +234,9 @@ export interface TursoDriverConfig { // ── Autonumber on the remote face ──────────────────────────────────────────── /** - * [#6944] A record number this face cannot issue — refused, not written as NULL. + * [#6944 → #21113] A record number this face ISSUES — from the same persistent + * `_objectstack_sequences` counter the other two faces draw from, atomically + * in the database, over the remote transport. * * # What was measured, and where the knowledge lives * @@ -240,9 +244,9 @@ export interface TursoDriverConfig { * inherit `SqlDriver.create`, which calls `fillAutoNumberFields` and issues the * number from the persistent `_objectstack_sequences` table. Remote overrides * the write path to `RemoteTransport`, which builds its own `INSERT` and never - * enters that method — so on this face `auto_number` was only a column mapped to - * `TEXT`, and the slot the engine deliberately left empty stayed empty. Measured - * on `main` @ `2f3e79351`, remote, `case_number: { type: 'autonumber' }`: + * entered that method — so on this face `auto_number` was only a column mapped + * to `TEXT`, and the slot the engine deliberately left empty stayed empty. + * Measured on `main` @ `2f3e79351`, remote, `case_number: { type: 'autonumber' }`: * * ``` * create -> RESOLVED case_number=null @@ -254,76 +258,139 @@ export interface TursoDriverConfig { * The engine cannot catch this for the caller: `supports.autonumber` is `true` * here (inherited through `...super.supports`), so `engine.ts` defers generation * to the driver entirely and never runs its own fallback — see the driver table - * in its `generateAutoNumbers` docblock, which already records `driver-turso` as + * in its `generateAutoNumbers` docblock, which records `driver-turso` as * "inherited, no fallback path". A declared capability that boots and quietly - * delivers nothing is the shape #3724 ruled on, and triage applied that ruling - * here (2026-08-09): **disposition B, explicit refusal**. Implementing autonumber - * on this transport (A) stays behind the appetite door for want of measured - * demand — it is deferred, not overlooked. - * - * ⚠️ The refusal is raised HERE, on the driver, and not inside - * `RemoteTransport`, because the transport cannot see what it would need to - * decide: `RemoteTransport.create(object, data)` takes no schema, caches none - * (`syncSchema` reads one and keeps nothing), and so cannot tell an - * `auto_number` column from any other `TEXT` one. The driver can: + * delivers nothing is the shape #3724 ruled on; triage applied that ruling here + * (2026-08-09) as **disposition B, explicit refusal** (`NOT_IMPLEMENTED`/501, + * raised on this driver), and left **A, implement autonumber on remote**, + * behind the appetite door "for want of measured demand", recording that the + * capability bit "flips *with* the implementation, not before it". + * + * The demand was then measured: a published app on the hosted product, in a + * release check (objectstack-ai/cloud#2531 — `POST /api/v1/data/crm_account` + * answered `501 NOT_IMPLEMENTED` on a hosted tenant, whose database is on this + * transport, so no object declaring an `auto_number` field could get a new + * record there). The door's own condition was met, and A shipped: the refusal + * became generation, and `supports.autonumber: true` on this face became TRUE + * rather than knowingly false. + * + * ⚠️ Generation happens HERE, on the driver, and not inside `RemoteTransport`, + * for the reason the refusal was raised here: the transport cannot see what it + * would need to decide. `RemoteTransport.create(object, data)` takes no schema + * and caches none (`syncSchema` reads one and keeps nothing), so it cannot tell + * an `auto_number` column from any other `TEXT` one. The driver can: * `registerRemoteFieldMetadata` → `SqlDriver.registerExternalObject` classifies - * every field at remote schema-sync time and keys `autoNumberFields` strictly by - * OBJECT name — measured populated in remote mode, tenant field and all. So the - * knowledge exists exactly one layer above the statement builder, and that is - * the layer that refuses. + * every field at remote schema-sync time and keys `autoNumberFields` by object + * name — measured populated in remote mode, tenant field and all. So the slots + * are filled one layer above the statement builder, and the transport receives + * a row that already carries its number, exactly as it receives a caller- + * supplied one. + * + * # One semantics, not two — what is SHARED with the other faces + * + * An embedded-replica face and a remote face can point at ONE database, so a + * second copy of any of these would hand out colliding numbers on it: + * + * - the format rendering and precedence — `resolveAutonumberFormat` at + * registration, `renderAutonumber` / `missingFieldValues` in + * `fillAutoNumberFields`, which this face now CALLS for every write (the + * empty-slot predicate, the `{field}` refusal, the tenant resolution, the + * scope / prefix / suffix of the probe, the reservation report: all its); + * - the counter identity — `SqlDriver.sequenceKeyHash` over + * `(table, tenant, field, scope)` and `resolveSequenceTenantId`; + * - the table — `SqlDriver.defineSequencesTable`, compiled to text by the + * connection-less Knex this face already holds (#20054) and sent as-is; + * - the bootstrap — `SqlDriver.maxAutonumberCounter`, the one reading of the + * data table's MAX, suffix included (#6468), over rows this face fetches; + * - the collision re-seed (#5495) — `collidingAutoNumberReservations` with + * this face's `autoNumberValueExists` and `resyncSequenceToDataMax`. + * + * What is this face's own is only HOW the counter moves, because a Knex + * transaction (`SqlDriver.getNextSequenceValue`: lock the row, read, update) is + * an interactive transaction held across HTTP round trips here, and `forUpdate` + * is a no-op on SQLite anyway. The remote face moves it in ONE statement: * - * # Why NOT_IMPLEMENTED / 501 rather than 400 - * - * The same two-class taxonomy this package already applies to aggregate - * functions (#5907) and date buckets (#6212), and ADR-0112 for the vocabulary: - * `autonumber` is a field type `@objectstack/spec` declares and this very - * driver's other faces generate, so the caller's object definition is spelled - * correctly and the request contains no mistake. The gap is the backend's. + * ``` + * warm: UPDATE _objectstack_sequences SET last_value = last_value + 1 + * WHERE key_hash = ? RETURNING last_value + * cold: (scan the data table's MAX, in JS, by the shared reading) then + * INSERT … VALUES (…, max + 1) ON CONFLICT (key_hash) + * DO UPDATE SET last_value = last_value + 1 RETURNING last_value + * ``` * - * # What is deliberately NOT refused + * Each is a single SQLite statement, serialised by the database's write lock + * whoever holds the connection — two processes cannot both read `last_value` + * before either writes it, which is the property an in-process counter or the + * engine's in-memory fallback cannot have, and the one the hosted runtime + * (several containers, one tenant database) needs. Two cold writers racing on + * the first row both scan, both INSERT, one wins the row and the other's + * `ON CONFLICT` arm increments the winner's value: distinct numbers, no lost + * seed. Measured on two processes, each holding its own `@libsql/client` + * connection to one database file (`turso-remote-autonumber-concurrency.test.ts`). + * The seed is NOT folded into the statement as a SQL `MAX(…)`: the bootstrap + * reading strips a declared suffix and reads the digit run after the prefix + * (`readAutonumberCounter`), which a dialect expression would be a second copy + * of, and that copy is the collision this section exists to prevent. + * + * # The legacy table shape — refused, not migrated + * + * `SqlDriver.ensureSequencesKeyHashShape` rebuilds a pre-`key_hash` table in + * place through a live Knex connection. This face has none, and a raw-SQL + * rewrite of that rebuild would be a second copy of a migration. So a table + * found WITHOUT `key_hash` is refused here, loudly and actionably, instead of + * being keyed by the legacy `(object, tenant_id, field)` rule — which would be + * a second keying rule beside the shared one. It cannot arise on a database + * this face created (it creates the current shape), only on one a pre-`key_hash` + * local face wrote first; the remedy is to open that database once through the + * local or embedded-replica face, whose `initObjects` migrates it. + * + * # What is deliberately left as it was * * A record that ALREADY carries a value in the slot. `fillAutoNumberFields` - * skips exactly those (`undefined` / `null` / `''` is its own generate - * predicate, reused verbatim below rather than re-derived), and on this face - * they are written through unchanged and correctly — measured. That is the - * `isSystem` seed replay and the `preserveAudit` historical import, which - * `engine.ts` exempts from its strip on purpose (#5503); refusing them would - * break a path that works today and would take the two faces further apart, not - * closer (#6203). + * skips exactly those (`undefined` / `null` / `''` is its generate predicate), + * and on this face they are written through unchanged — the `isSystem` seed + * replay and the `preserveAudit` historical import, which `engine.ts` exempts + * from its strip on purpose (#5503). And on `upsert`, a row that MERGES keeps + * the number already in its column (#7011): the autonumber columns are named + * to the transport as insert-only, so the fresh reservation lands only on the + * insert leg — the leg #7099 recorded as writing NULL, which now gets a number. */ + /** - * `SqlDriver.fillAutoNumberFields`'s own generate predicate: a slot holding - * `undefined` / `null` / `''` is one the driver would have had to fill. - * - * Stated once, read twice — by the pre-write refusal below and by the - * post-write report on the one leg that refusal provably cannot classify - * (#7099). Two spellings of "empty" would be two answers to one question, and - * the second one would drift. + * The refusal for a pre-`key_hash` sequences table this face cannot migrate — + * see the section above for why it refuses rather than keys by the legacy + * rule. `DATABASE_ERROR`/500: the request is spelled correctly, the backend's + * own state is what blocks the write, and a `StandardErrorCode` member so there + * is no new code. */ -function isEmptyAutoNumberSlot(held: unknown): boolean { - return held === undefined || held === null || held === ''; -} - -function refuseRemoteAutonumber(object: string, fields: string[], path: string): never { +function refuseLegacyRemoteSequencesTable(table: string): never { const err = new Error( - `Object "${object}" declares auto_number field(s) [${fields.join(', ')}] left empty for this ` + - `${path}, and the Turso REMOTE transport does not generate record numbers. ` + - `Generated here: none — remote writes go through \`RemoteTransport\`, which builds its own ` + - `INSERT and never enters \`SqlDriver.fillAutoNumberFields\`, so on this face \`auto_number\` ` + - `is only a column mapped to TEXT. The object is spelled correctly and @objectstack/spec ` + - `declares \`autonumber\` as a field type this driver's local and embedded-replica faces do ` + - `generate — this is a capability gap in the remote transport, not a mistake in the request, ` + - `which is why it answers NOT_IMPLEMENTED/501 rather than a 400. Use the local or ` + - `embedded-replica transport for objects that carry record numbers, or supply the value ` + - `explicitly (a seed replay or \`preserveAudit\` import keeps its own numbers and is written ` + - `unchanged). It is refused rather than resolved, because resolving would write NULL into the ` + - `slot and persist the row without its record number.`, + `The sequence-counter table "${table}" on this database predates the key_hash shape, and the ` + + `Turso REMOTE transport does not migrate it (the local and embedded-replica transports do, ` + + `in place, when they initialise objects). No record number was issued and no row was written. ` + + `Open this database once through the local or embedded-replica transport (a \`file:\` URL, with ` + + `\`syncUrl\` for a replica) and let it initialise the objects that declare autonumber fields; ` + + `the rebuilt table then serves every transport.`, ) as Error & { code?: string; status?: number }; - err.code = StandardErrorCode.enum.NOT_IMPLEMENTED; - err.status = 501; + err.code = StandardErrorCode.enum.DATABASE_ERROR; + err.status = 500; throw err; } +/** + * A table, field or tenant column interpolated into a sequence statement, + * spelled the way the transport spells a table it does not own + * (`RemoteTransport.tableSql`): double-quoted, any embedded quote doubled, so + * nothing inside the name can close the quoting and continue as grammar. Not + * held to the bare-identifier shape, because a federated object's + * `external.remoteName` is authored as any string and the local face reads + * such a table through Knex's own quoting — the counter must bootstrap from + * the same table on this face. + */ +function quoteSequenceName(name: string): string { + return `"${name.replace(/"/g, '""')}"`; +} + // ── Remote transactions: refused, never decorative ─────────────────────────── /** @@ -1595,6 +1662,15 @@ export class TursoDriver extends SqlDriver { */ private readonly remoteCodecResidueConverged: Record> = {}; + /** + * The remote face's `sequencesTableEnsurePromise`: that the `key_hash`-keyed + * `_objectstack_sequences` table EXISTS, probed once per process through the + * transport. It remembers nothing about any counter's value — the counter + * lives in the database and moves there. See + * {@link ensureRemoteSequencesTable}. + */ + private remoteSequencesTableEnsured: Promise | null = null; + constructor(config: TursoDriverConfig) { const mode = TursoDriver.detectMode(config); // A local or replica engine with nothing durable behind it (a remote url, @@ -2254,113 +2330,299 @@ export class TursoDriver extends SqlDriver { return this.aggregateBackendFault(object, query, error); } + // ── Autonumber on the remote face: the sequence, moved in one statement ──── + // + // The ruling, the measurements and the shared/own split are in the module + // docblock "Autonumber on the remote face" above. In short: every rule that + // decides WHICH counter a value is drawn from and WHERE a cold counter starts + // is `SqlDriver`'s and is called, never copied; only the statement that moves + // the counter is this face's, because it has no Knex connection to hold a + // transaction on. + /** - * [#6944] Refuse a remote write that would need a record number this face - * cannot issue — see {@link refuseRemoteAutonumber} for the ruling and the - * measurements. - * - * Called BEFORE `toRemoteWriteForms` and before any statement is built, so a - * refused write costs no round trip — the rule every other refusal on this - * path already follows, and the one the refusal suites assert. - * - * The lookup is `autoNumberFields[object]` with no table-name fallback, - * unlike `fillAutoNumberFields`. That is not a shortcut: in remote mode the - * only writer of this registry is `registerRemoteFieldMetadata`, whose own - * docs record that it keys strictly by `object` ("never let a stray - * `schema.name` shadow it"), and the Knex path that produces the other key is - * unreachable here. A second lookup rule would be a second answer. - * - * A no-op for an object with no `auto_number` field, which is the overwhelming - * majority — one map read per remote write. + * Rows answered by one sequence statement over the remote transport. The + * transport's raw door is used on purpose: these statements belong to the + * counter, not to any object's data, so they bypass the per-object read + * classifiers the data doors wrap their statements in. */ - private refuseUngeneratableRemoteAutonumber( - object: string, - rows: Array | null | undefined>, - path: string, - ): void { - const cfgs = this.autoNumberFields[object]; - if (!cfgs || cfgs.length === 0) return; - const empty = new Set(); - for (const row of rows) { - if (!row || typeof row !== 'object') continue; - for (const cfg of cfgs) { - if (isEmptyAutoNumberSlot(row[cfg.name])) empty.add(cfg.name); + private async remoteSequenceRows(sql: string, args: unknown[] = []): Promise>> { + const rows = await this.remoteTransport!.execute(sql, args); + return Array.isArray(rows) ? (rows as Array>) : []; + } + + /** + * The remote half of {@link SqlDriver.ensureSequencesTable}: make sure the + * `key_hash`-keyed sequences table exists, once per process, through the + * transport. The DDL is `SqlDriver.defineSequencesTable` compiled to text by + * this face's connection-less Knex (#20054 — it holds the dialect's compiler + * and nothing else), so both faces create the same table from one + * definition. A pre-`key_hash` table is refused rather than migrated — see + * {@link refuseLegacyRemoteSequencesTable}. + * + * Cross-process race on the first create: a sibling container may create the + * table between this process's existence probe and its DDL. The DDL then + * fails on "already exists", and the table is re-probed before the error is + * believed — the same recovery `ensureSequencesTable` performs. + */ + private async ensureRemoteSequencesTable(): Promise { + if (this.remoteSequencesTableEnsured) { + await this.remoteSequencesTableEnsured; + return; + } + const table = this.sequencesTableName; + // Knex types its schema probes by what AWAITING them answers (`hasTable` + // → `Promise`), but each is a `SchemaBuilder`, and a builder + // compiles to its statements without a connection. Compiled here and SENT + // through the transport, so the probes and the DDL are the dialect's own + // spellings rather than a second copy of them. + const compile = (builder: unknown): Array<{ sql: string; bindings: readonly unknown[] }> => + (builder as { toSQL(): Array<{ sql: string; bindings: readonly unknown[] }> }).toSQL(); + const run = async (statements: Array<{ sql: string; bindings: readonly unknown[] }>) => { + let rows: Array> = []; + for (const s of statements) rows = await this.remoteSequenceRows(s.sql, [...s.bindings]); + return rows; + }; + const exists = async () => (await run(compile(this.knex.schema.hasTable(table)))).length > 0; + this.remoteSequencesTableEnsured = (async () => { + if (!(await exists())) { + try { + await run(compile(this.knex.schema.createTable(table, (t) => this.defineSequencesTable(t)))); + } catch (err) { + if (!(await exists())) throw err; + } } + // `hasColumn` compiles to the dialect's column listing (`PRAGMA table_info` + // on SQLite); the shape check reads the column names off its rows. + const columns = await run(compile(this.knex.schema.hasColumn(table, 'key_hash'))); + if (!columns.some((c) => c.name === 'key_hash')) refuseLegacyRemoteSequencesTable(table); + })(); + try { + await this.remoteSequencesTableEnsured; + } catch (err) { + // Not cached: a transient failure must not pin "ensured" or "refused" on + // the process. The next write probes again. + this.remoteSequencesTableEnsured = null; + throw err; } - if (empty.size > 0) refuseRemoteAutonumber(object, [...empty], path); } /** - * [#7099] Say out loud that a remote write landed a row whose declared - * `auto_number` column holds no record number. - * - * # Why this exists beside the refusal, rather than inside it - * - * {@link refuseUngeneratableRemoteAutonumber} runs BEFORE the statement is - * built, which is what makes a refused write cost zero round trips — and is - * also the reason it cannot cover every leg. An upsert carrying an `id` or - * explicit `conflictKeys` may merge (safe: the row keeps the number already - * in its column) or may insert (the slot lands NULL), and which one it did is - * not knowable before it runs. #6944 left that leg as declared residue. - * - * What has since been measured is that the round trip the residue was - * attributed to is **already paid**: `RemoteTransport.upsert` follows its - * `INSERT … ON CONFLICT` with `SELECT * FROM "" WHERE "id" = ?` and - * returns the mapped row, unconditionally. So the NULL is in hand at no extra - * cost, on the layer that knows which column is an `auto_number` — and the - * leg can be made loud without buying anything. Note the check is not "did it - * insert or merge": it is one field read on a row this method already holds. - * - * # Report, not refuse — deliberately - * - * The write has already happened when this runs. Refusing here would be a - * different act from the pre-write gate (it would have to undo a landed row), - * and that trade-off is untouched by this change: nothing about what the - * remote face accepts or rejects moves. Generating the number on remote stays - * behind the same appetite door (#6944 disposition A) it always has. - * - * # `warn`, not `error` - * - * By AGENTS.md §Degradation log levels this is a FUNCTIONAL degradation, not - * a durability one: everything the caller submitted persisted, nothing it - * claims to have stored is missing, and the returned row carries the `null` - * in plain sight rather than reporting a success that did not happen. What is - * absent is a DERIVED value this face declares it does not issue — the same - * capability gap the sibling legs answer with `NOT_IMPLEMENTED`/501. Grading - * it `error` would file a known capability gap beside real data loss, which - * is the over-application that rule warns about by name. - * - * # Not throttled, unlike the tenant-audit warning - * - * `SqlDriver.tenantAuditWarned` collapses its warning per `{object}:{op}` - * because that one reports a CONFIGURATION mistake — the second occurrence - * carries no information the first did not. Here the row `id` IS the payload: - * each occurrence names a different row that landed without a record number, - * and that list is what an operator repairs. One line per unnumbered row is - * proportional, not noisy. + * `SqlDriver.getNextSequenceValue`, routed by transport: the inherited Knex + * transaction on the local and embedded-replica faces, one atomic statement + * over `@libsql/client` on the remote face. The caller is + * `fillAutoNumberFields`, inherited unchanged, which is what keeps the format, + * the tenant, the scope and the reservation report one rule on all three + * faces. */ - private reportUnnumberedRemoteRow( + protected override async getNextSequenceValue( object: string, - row: Record | null | undefined, - path: string, - ): void { - const cfgs = this.autoNumberFields[object]; - if (!cfgs || cfgs.length === 0) return; - if (!row || typeof row !== 'object') return; - const unfilled = cfgs - .filter((cfg) => isEmptyAutoNumberSlot(row[cfg.name])) - .map((cfg) => cfg.name); - if (unfilled.length === 0) return; - this.logger.warn( - `[driver-turso] ${path} on "${object}" returned row id ${JSON.stringify(row.id)} with ` + - `auto_number field(s) [${unfilled.join(', ')}] left empty. The Turso REMOTE transport does ` + - `not generate record numbers, so an upsert that matches no existing row inserts one without ` + - `it — the row is persisted, its record number is not, and nothing else reports this. Supply ` + - `the value explicitly on this path (a seed replay or import keeps its own numbers and is ` + - `written unchanged), or use the local / embedded-replica transport, which do issue ` + - `them.`, - { object, fields: unfilled, id: row.id, path }, + tableName: string, + field: string, + prefix: string, + tenantField: string | null, + tenantId: string | null, + parentTrx?: Parameters[6], + scope = '', + suffix = '', + ): Promise { + if (!this.isRemote) { + return super.getNextSequenceValue(object, tableName, field, prefix, tenantField, tenantId, parentTrx, scope, suffix); + } + return this.nextRemoteSequenceValue(tableName, field, prefix, tenantField, tenantId, scope, suffix); + } + + /** + * Reserve and return the next counter value over the remote transport. + * + * Warm path, one round trip: `UPDATE … SET last_value = last_value + 1 … + * RETURNING last_value`. A row comes back, that is the value; none comes + * back, the counter is cold. Cold path: bootstrap from the data table's MAX + * by the shared reading, then `INSERT … ON CONFLICT (key_hash) DO UPDATE SET + * last_value = last_value + 1 RETURNING last_value` — so a sibling that + * seeded the row first is incremented, never overwritten, and the value this + * process reads back is the one the database committed for it. Both + * statements are single SQLite statements, serialised by the database's + * write lock across every connection and process. No in-process state takes + * part: `remoteSequencesTableEnsured` only remembers that the TABLE exists. + * + * The counter is keyed exactly as the local face keys it — + * `sequenceKeyHash(table, resolveSequenceTenantId(…), field, scope)` — which + * is what lets an embedded replica and a remote client of the same database + * draw from one row rather than two. + */ + private async nextRemoteSequenceValue( + tableName: string, + field: string, + prefix: string, + tenantField: string | null, + tenantId: string | null, + scope: string, + suffix: string, + ): Promise { + await this.ensureRemoteSequencesTable(); + const table = this.sequencesTableName; + const resolvedTenantId = this.resolveSequenceTenantId(tenantField, tenantId); + const keyHash = this.sequenceKeyHash(tableName, resolvedTenantId, field, scope); + + const bumped = await this.remoteSequenceRows( + `UPDATE "${table}" SET "last_value" = "last_value" + 1, "updated_at" = CURRENT_TIMESTAMP ` + + `WHERE "key_hash" = ? RETURNING "last_value"`, + [keyHash], + ); + if (bumped.length > 0) return Number(bumped[0].last_value); + + const seedMax = await this.scanRemoteMaxCounter( + tableName, + field, + prefix, + tenantField, + resolvedTenantId === GLOBAL_TENANT ? null : resolvedTenantId, + suffix, + ); + const issued = await this.remoteSequenceRows( + `INSERT INTO "${table}" ("key_hash", "object", "tenant_id", "field", "scope", "last_value", "updated_at") ` + + `VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ` + + `ON CONFLICT ("key_hash") DO UPDATE SET "last_value" = "${table}"."last_value" + 1, ` + + `"updated_at" = CURRENT_TIMESTAMP RETURNING "last_value"`, + [keyHash, tableName, resolvedTenantId, field, scope, seedMax + 1], ); + if (issued.length === 0) { + // RETURNING always answers one row for an INSERT that landed or merged; + // an empty answer means the transport is not the SQLite this face + // expects. Said rather than read as `NaN`. + throw new Error( + `The sequence statement for "${tableName}.${field}" answered no row over the remote transport; ` + + `no record number was issued. The remote endpoint must honour INSERT … ON CONFLICT … RETURNING.`, + ); + } + return Number(issued[0].last_value); + } + + /** + * The remote half of {@link SqlDriver.scanMaxNumericTail}: fetch the stored + * values of one counter's partition through the transport and read them by + * the shared `maxAutonumberCounter`. The predicate is the same anchor the + * local face compiles — the escaped prefix as a `LIKE` pre-filter, the tenant + * column when the counter is tenant-scoped — spelled with an explicit + * `ESCAPE '\'`, because SQLite's `LIKE` has no escape character unless one is + * declared, and the shared escaping writes backslashes. + */ + private async scanRemoteMaxCounter( + tableName: string, + field: string, + prefix: string, + tenantField: string | null, + tenantId: string | null, + suffix: string, + ): Promise { + const col = quoteSequenceName(field); + let sql = `SELECT ${col} FROM ${quoteSequenceName(tableName)} WHERE ${col} LIKE ? ESCAPE '\\' AND ${col} IS NOT NULL`; + const args: unknown[] = [`${this.escapeLikePrefix(prefix)}%`]; + if (tenantField && tenantId !== null) { + sql += ` AND ${quoteSequenceName(tenantField)} = ?`; + args.push(tenantId); + } + const rows = await this.remoteSequenceRows(sql, args); + return this.maxAutonumberCounter(rows.map((r) => r[field]), prefix, suffix); + } + + /** + * [#5495] The collision discriminator, routed by transport: does a row in + * this counter's partition already hold the value this driver just issued? + * The inherited Knex read on the local faces; the same `WHERE` through the + * transport on the remote face, which ignores the Knex runner it is handed + * (`collidingAutoNumberReservations` passes `this.knex`, which holds no + * connection here). + */ + protected override async autoNumberValueExists( + queryRunner: Parameters[0], + reservation: AutoNumberReservation, + ): Promise { + if (!this.isRemote) return super.autoNumberValueExists(queryRunner, reservation); + const col = quoteSequenceName(reservation.field); + let sql = `SELECT ${col} FROM ${quoteSequenceName(reservation.tableName)} WHERE ${col} = ?`; + const args: unknown[] = [reservation.value]; + if (reservation.tenantField && reservation.tenantId !== null) { + sql += ` AND ${quoteSequenceName(reservation.tenantField)} = ?`; + args.push(reservation.tenantId); + } + sql += ' LIMIT 1'; + return (await this.remoteSequenceRows(sql, args)).length > 0; + } + + /** + * [#5495] Re-seed one counter from the data-table MAX, routed by transport. + * The remote statement moves the counter FORWARD only, in one atomic step: + * `UPDATE … SET last_value = ? WHERE key_hash = ? AND last_value < ?` — the + * same "never rewind" rule the inherited version applies in its transaction, + * without needing one. + */ + protected override async resyncSequenceToDataMax(reservation: AutoNumberReservation): Promise { + if (!this.isRemote) return super.resyncSequenceToDataMax(reservation); + await this.ensureRemoteSequencesTable(); + const resolvedTenantId = this.resolveSequenceTenantId(reservation.tenantField, reservation.tenantId); + const keyHash = this.sequenceKeyHash(reservation.tableName, resolvedTenantId, reservation.field, reservation.scope); + const observedMax = await this.scanRemoteMaxCounter( + reservation.tableName, + reservation.field, + reservation.prefix, + reservation.tenantField, + resolvedTenantId === GLOBAL_TENANT ? null : resolvedTenantId, + reservation.suffix, + ); + await this.remoteSequenceRows( + `UPDATE "${this.sequencesTableName}" SET "last_value" = ?, "updated_at" = CURRENT_TIMESTAMP ` + + `WHERE "key_hash" = ? AND "last_value" < ?`, + [observedMax, keyHash, observedMax], + ); + } + + /** + * One remote row write with its record numbers issued first and the #5495 + * collision re-seed around it — the shape `SqlDriver.create` and + * `SqlDriver.upsert` give the local faces, with the statement itself handed + * in: `fillAutoNumberFields` fills the empty slots on `row` (a caller-supplied + * value is kept), `send` writes the filled row, and a unique violation that + * `collidingAutoNumberReservations` proves to be THIS counter's (a seed + * replay or import landed rows above it) re-seeds the counter from the data + * table, clears only the colliding fields and retries, up to the inherited + * retry budget. Any other error — including a duplicate on a value the caller + * typed — is rethrown untouched: that 409 is the caller's to receive. + * + * `row` is this method's own copy of the caller's data, so the caller's + * object is never mutated by a number it did not ask to see on it. + */ + private async writeRemoteRowWithAutoNumbers( + object: string, + row: Record, + options: DriverOptions | undefined, + send: (filled: Record) => Promise, + ): Promise { + for (let attempt = 0; ; attempt++) { + const reservations = await this.fillAutoNumberFields(object, row, options); + try { + return await send(row); + } catch (error) { + if (attempt >= this.autoNumberCollisionRetries) throw error; + const colliding = await this.collidingAutoNumberReservations(error, reservations, options); + if (colliding.length === 0) throw error; + for (const reservation of colliding) { + await this.resyncSequenceToDataMax(reservation); + delete row[reservation.field]; + } + } + } + } + + /** + * The `auto_number` columns of `object`, looked up as `fillAutoNumberFields` + * looks them up (object name first, then the physical table it maps to), so + * the columns named insert-only to the transport are exactly the ones a + * number was issued for. + */ + private remoteAutoNumberColumns(object: string, table: string): string[] { + const cfgs = this.autoNumberFields[object] || this.autoNumberFields[table]; + return cfgs ? cfgs.map((cfg) => cfg.name) : []; } // [#15267] The override declares the contract's type, as both of its branches @@ -2372,8 +2634,14 @@ export class TursoDriver extends SqlDriver { this.assertRemoteTransactionUnsupported(options, 'create'); if (this.isRemote) { const table = this.remoteTableFor(object, 'create'); - this.refuseUngeneratableRemoteAutonumber(object, [data], 'create'); - return this.formatRemoteRow(object, await this.remoteTransport!.create(object, this.toRemoteWriteForms(object, data), table)); + // The record numbers are issued HERE, on the layer that holds the + // schema, before the transport builds its INSERT — see "Autonumber on + // the remote face" at the top of this file. A row that already carries + // its number (seed replay, import) is written through unchanged. + const written = await this.writeRemoteRowWithAutoNumbers(object, { ...data }, options, (filled) => + this.remoteTransport!.create(object, this.toRemoteWriteForms(object, filled), table), + ); + return this.formatRemoteRow(object, written); } return super.create(object, data, options); } @@ -2404,38 +2672,21 @@ export class TursoDriver extends SqlDriver { this.assertRemoteTransactionUnsupported(options, 'upsert'); if (this.isRemote) { const table = this.remoteTableFor(object, 'upsert'); - // [#6944] An upsert is insert-OR-merge, and only the merge leg is safe - // here: `RemoteTransport.upsert` emits - // `INSERT … ON CONFLICT() DO UPDATE`, so a row that matches keeps - // the number already in its column and never needed one issued — measured - // (an `id`-bearing upsert onto an existing `CASE-00042` merged and kept - // it). Refusing that would break a path that works. - // - // The insert leg is refused where it is PROVABLE without a round trip: no - // `id`/`_id` and no explicit `conflictKeys` means the transport mints a - // fresh nanoid for the sole default merge key, so the statement can only - // insert — measured, two such upserts produced two rows with different - // ids. That is also the shape the engine sends for a new record. - // - // ⚠️ The leg the pre-write gate cannot classify: an upsert that DOES - // carry an id or conflict keys but matches nothing still inserts, and on - // that leg the slot is still written NULL. That outcome is UNCHANGED by - // #7099 — what changed is that it is no longer silent. The row comes back - // through this method, so the NULL can be read AFTER the write and - // reported, without the probe query the pre-write gate exists to avoid - // and without turning an accepted write into a refused one. Generating - // the number here stays behind the deferred half (A). - const mayMerge = data?.id !== undefined || data?._id !== undefined - || (Array.isArray(conflictKeys) && conflictKeys.length > 0); - if (!mayMerge) this.refuseUngeneratableRemoteAutonumber(object, [data], 'upsert'); - const row = this.formatRemoteRow(object, await this.remoteTransport!.upsert(object, this.toRemoteWriteForms(object, data), conflictKeys, table)); - // Judged on the row the CALLER receives, after read-coercion — reporting - // a different value than the one handed out would be its own defect. The - // `!mayMerge` leg reaches this too and is a no-op there by construction: - // an empty slot was already refused above, and a caller-supplied number - // comes back filled. - this.reportUnnumberedRemoteRow(object, row, 'upsert'); - return row; + // An upsert is insert-OR-merge, and which leg it takes is knowable only + // after it runs. The record number is therefore reserved BEFORE the + // statement, as `SqlDriver.upsert` reserves it on the local faces, and + // the autonumber columns are named to the transport as insert-only + // (#7011): on the insert leg the fresh number lands with the row — the + // leg #7099 recorded as writing NULL under the refusal — and on the merge + // leg the row keeps the number already in its column, the reservation + // going unused exactly as it does on the local faces (a gap in the + // sequence, never a renumbering). An explicit payload value does not + // renumber a merged row either; `update()` is the renumbering path. + const insertOnly = this.remoteAutoNumberColumns(object, table); + const written = await this.writeRemoteRowWithAutoNumbers(object, { ...data }, options, (filled) => + this.remoteTransport!.upsert(object, this.toRemoteWriteForms(object, filled), conflictKeys, table, insertOnly), + ); + return this.formatRemoteRow(object, written); } return super.upsert(object, data, conflictKeys, options); } @@ -3007,16 +3258,20 @@ export class TursoDriver extends SqlDriver { override async bulkCreate(object: string, data: any[], options?: DriverOptions): Promise[]> { this.assertRemoteTransactionUnsupported(options, 'bulkCreate'); if (this.isRemote) { - const table = this.remoteTableFor(object, 'bulkCreate'); - // [#6944] Same refusal as `create`, and it has to be stated here rather - // than inherited: `RemoteTransport.bulkCreate` loops its OWN `create`, not - // this class's, so nothing about the single-row override reaches this - // path. Refused for the whole batch before any row is written — the - // statement is all-or-nothing on this transport too, so a partial batch is - // not a state this can leave behind. - this.refuseUngeneratableRemoteAutonumber(object, Array.isArray(data) ? data : [], 'bulkCreate'); - const formatted = Array.isArray(data) ? data.map((d) => this.toRemoteWriteForms(object, d)) : data; - return this.formatRemoteRows(object, await this.remoteTransport!.bulkCreate(object, formatted, table)); + // The column-map refusal runs once, before any row, as it did when the + // batch was handed down whole. + this.remoteTableFor(object, 'bulkCreate'); + // The batch is written one row at a time through this class's own + // `create`, so each row's record number is issued on the layer that holds + // the schema and each row's #5495 collision re-seed is its own. + // `RemoteTransport.bulkCreate` was never one statement: it loops the + // transport's own `create` (one INSERT and one read-back per row), so a + // batch on this transport was never all-or-nothing, and routing it + // through the driver's `create` changes neither the statements sent nor + // what a mid-batch failure leaves behind — it adds only the numbers. + const results: Record[] = []; + for (const row of data) results.push(await this.create(object, row, options)); + return results; } return super.bulkCreate(object, data, options); } diff --git a/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts b/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts new file mode 100644 index 00000000000..1cd58047e56 --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21113] Two writers, two PROCESSES, one database: every record number the + * remote face issues is distinct. + * + * # Why real processes, and what they measure + * + * The hosted runtime runs several containers against one tenant database, so + * the acceptance is cross-process atomicity — ⛔ never an in-process counter, + * ⛔ never the engine's in-memory fallback. Every other remote autonumber pin + * in this package runs on `libsql-sqlite-stub.testkit.ts`, a synchronous + * better-sqlite3 behind the `@libsql/client` interface: it serialises every + * statement by construction, so it cannot tell an atomic counter from a + * read-then-write one. Neither can two `@libsql/client` instances in ONE + * Node process, whose native calls run on the one JS thread. + * + * So this file spawns two child processes (through `tsx`, over the package's + * SOURCE, the way `packages/spec`'s process-boundary pins do), each holding + * its own `@libsql/client` connection — the native `file:` backend, which is + * libSQL itself, the engine behind a Turso endpoint — to one database file, + * each driving its own `TursoDriver` in REMOTE mode, writing through + * `TursoDriver.create`. A file barrier releases both writers at once, so their + * statements contend on the database's write lock rather than following each + * other through process start-up. + * + * What is NOT measured here, said plainly: an HTTP `sqld` server. None runs in + * this environment, and the transport's HTTP batching and retries are the + * concern of the suites that mock `execute`. What a server adds is the network; + * what decides atomicity is the statement, and the statement runs on the same + * engine under the same lock here as there. + * + * # The pins + * + * - N writes from two processes give N distinct numbers — the counter moved + * in one atomic statement per reservation, so no two writers read the same + * `last_value`. + * - Each writer's own sequence is strictly increasing: the counter is + * gap-tolerant-monotonic. With no statement failing, the union is exactly + * 1..N, and the file says so when it is. + * - The two writers really overlapped: neither's numbers form one contiguous + * block. (Had they run one after the other, the pin above would be vacuous.) + * - The writers are different processes, and neither is this one. + * + * # Reverse verification — direction predicted BEFORE it was run + * + * With the warm path split into two statements (read `last_value`, then write + * `last_value + 1`), two processes releasing on the barrier read the same + * value and write the same successor, so the distinct count drops below N. + * The window between the two statements is a native call wide, so the drop is + * probable rather than certain on a single run; the measured outcome, and the + * number of runs it took, are recorded in the PR. + */ + +import { describe, it, expect, afterAll } from 'vitest'; +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createClient } from '@libsql/client'; +import { TursoDriver } from './index.js'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const PKG_ROOT = join(HERE, '..'); +const DRIVER_ENTRY = new URL('./index.ts', import.meta.url).href; + +const WRITES_PER_WORKER = 25; +const WORKERS = ['A', 'B'] as const; + +const NUMBERED_OBJECT = { + name: 'crm_case', + fields: { + organization_id: { type: 'string' }, + case_number: { type: 'autonumber', format: 'CASE-{00000}', unique: true }, + title: { type: 'string' }, + }, +}; + +const dirs: string[] = []; +afterAll(() => { + while (dirs.length > 0) rmSync(dirs.pop()!, { recursive: true, force: true }); +}); + +interface WorkerReport { + tag: string; + pid: number; + numbers: string[]; + errors: string[]; +} + +/** + * The writer. Runs under `tsx` so it imports the driver's SOURCE, like the + * test itself does. It connects, registers the schema (the tables already + * exist — the parent created them — so `initObjects` records metadata and + * runs no DDL), announces readiness, waits for the parent's `go` file, then + * writes as fast as the database lets it and reports on stdout. + */ +const WORKER_SOURCE = ` + import { createClient } from '@libsql/client'; + import { existsSync, writeFileSync } from 'node:fs'; + // tsx compiles this package's .ts to CJS (no "type": "module"), so the + // class may arrive under \`default\` — the spelling packages/spec's + // process-boundary pins use. + const driverModule = await import(${JSON.stringify(DRIVER_ENTRY)}); + const TursoDriver = driverModule.TursoDriver ?? driverModule.default?.TursoDriver; + const { OS_TEST_DB, OS_TEST_DIR, OS_TEST_TAG, OS_TEST_WRITES } = process.env; + const client = createClient({ url: 'file:' + OS_TEST_DB, timeout: 20000 }); + const driver = new TursoDriver({ url: 'libsql://concurrency.probe', client }); + await driver.connect(); + await driver.initObjects([${JSON.stringify(NUMBERED_OBJECT)}]); + writeFileSync(OS_TEST_DIR + '/ready-' + OS_TEST_TAG, String(process.pid)); + const go = OS_TEST_DIR + '/go'; + while (!existsSync(go)) await new Promise((r) => setTimeout(r, 2)); + const numbers = []; + const errors = []; + for (let i = 0; i < Number(OS_TEST_WRITES); i++) { + try { + const row = await driver.create('crm_case', { organization_id: 'orgA', title: OS_TEST_TAG + '-' + i }); + numbers.push(row.case_number); + } catch (e) { + errors.push(String(e && e.message || e)); + } + } + await driver.disconnect(); + process.stdout.write(JSON.stringify({ tag: OS_TEST_TAG, pid: process.pid, numbers, errors })); +`; + +function runWorker(env: Record): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ['--import', 'tsx', '--input-type=module', '-e', WORKER_SOURCE], + { cwd: PKG_ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let out = ''; + let err = ''; + child.stdout.on('data', (d) => { out += String(d); }); + child.stderr.on('data', (d) => { err += String(d); }); + child.on('error', reject); + child.on('close', (code) => { + if (code !== 0) { + reject(new Error(`writer ${env.OS_TEST_TAG} exited ${code}\nstderr:\n${err}\nstdout:\n${out}`)); + return; + } + try { + resolve(JSON.parse(out) as WorkerReport); + } catch (e) { + reject(new Error(`writer ${env.OS_TEST_TAG} wrote no report: ${String(e)}\nstderr:\n${err}\nstdout:\n${out}`)); + } + }); + }); +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe('[#21113] two writers in two processes draw distinct numbers from one database', () => { + it(`${WORKERS.length} x ${WRITES_PER_WORKER} creates -> ${WORKERS.length * WRITES_PER_WORKER} distinct, monotonic per writer, interleaved`, async () => { + const dir = mkdtempSync(join(tmpdir(), 'turso-autonumber-concurrency-')); + dirs.push(dir); + const dbPath = join(dir, 'shared.db'); + + // The parent creates the schema through its own remote-mode driver, so + // both writers find the tables and the unique index already there. + const parentClient = createClient({ url: `file:${dbPath}`, timeout: 20000 }); + const parent = new TursoDriver({ url: 'libsql://concurrency.probe', client: parentClient }); + await parent.connect(); + await parent.initObjects([NUMBERED_OBJECT as any]); + + // A writer that dies before the barrier surfaces as ITS error, not as a + // readiness timeout. + let died: Error | null = null; + const runs = WORKERS.map((tag) => + runWorker({ OS_TEST_DB: dbPath, OS_TEST_DIR: dir, OS_TEST_TAG: tag, OS_TEST_WRITES: String(WRITES_PER_WORKER) }) + .catch((e: Error) => { died ??= e; throw e; }), + ); + // Release both only once both are connected and registered, so the + // writes contend rather than queue behind process start-up. + const deadline = Date.now() + 60_000; + while (!WORKERS.every((tag) => existsSync(join(dir, `ready-${tag}`)))) { + if (died) throw died; + if (Date.now() > deadline) throw new Error('writers did not become ready within 60s'); + await sleep(5); + } + writeFileSync(join(dir, 'go'), '1'); + const reports = await Promise.all(runs); + + // Different processes, and not this one. + const pids = reports.map((r) => r.pid); + expect(new Set(pids).size).toBe(WORKERS.length); + expect(pids).not.toContain(process.pid); + + // No write failed — so the union below is the whole 1..N and nothing was + // burned. (A failure would show here with its message, not as a gap.) + for (const r of reports) expect(r.errors, `writer ${r.tag} errors`).toEqual([]); + for (const r of reports) expect(r.numbers).toHaveLength(WRITES_PER_WORKER); + + // N distinct numbers across both writers. + const all = reports.flatMap((r) => r.numbers); + const total = WORKERS.length * WRITES_PER_WORKER; + expect(new Set(all).size).toBe(total); + expect(all.every((n) => /^CASE-\d{5}$/.test(n))).toBe(true); + const expected = Array.from({ length: total }, (_, i) => `CASE-${String(i + 1).padStart(5, '0')}`); + expect([...all].sort()).toEqual(expected); + + // Monotonic within each writer. + const asInt = (n: string) => Number(n.slice('CASE-'.length)); + for (const r of reports) { + const ints = r.numbers.map(asInt); + for (let i = 1; i < ints.length; i++) expect(ints[i], `writer ${r.tag} at ${i}`).toBeGreaterThan(ints[i - 1]); + } + + // They really overlapped: no writer's numbers form one contiguous block. + for (const r of reports) { + const ints = r.numbers.map(asInt); + const contiguous = ints[ints.length - 1] - ints[0] + 1 === ints.length; + expect(contiguous, `writer ${r.tag} ran alone: ${r.numbers[0]}..${r.numbers[r.numbers.length - 1]}`).toBe(false); + } + + // And the database agrees: one row per number, one counter row at N. + const rows = await parent.find('crm_case', { orderBy: [{ field: 'case_number', order: 'asc' }] } as any); + expect(rows.map((row) => row.case_number)).toEqual(expected); + const counter = await parentClient.execute('select "last_value" from "_objectstack_sequences"'); + expect(counter.rows).toHaveLength(1); + expect(Number(counter.rows[0].last_value)).toBe(total); + await parent.disconnect(); + }, 120_000); +}); diff --git a/packages/drivers/driver-turso/src/turso-remote-autonumber-generation.test.ts b/packages/drivers/driver-turso/src/turso-remote-autonumber-generation.test.ts new file mode 100644 index 00000000000..87f108e8743 --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-remote-autonumber-generation.test.ts @@ -0,0 +1,601 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21113] `auto_number` on the Turso REMOTE face: ISSUED, from the same + * persistent counter the other two faces draw from — and the other two faces + * untouched. + * + * # The history this file carries + * + * This is the #7089 refusal suite (`turso-remote-autonumber-refusal.test.ts`), + * converted, not deleted. Its first life pinned #6944's disposition B: a remote + * write that left an `auto_number` slot empty was refused `NOT_IMPLEMENTED`/501 + * before any statement was built, because `RemoteTransport` builds its own + * `INSERT` and never entered `fillAutoNumberFields`. Measured on `main` @ + * `2f3e79351`, before the refusal, the slot was written NULL: + * + * ``` + * REMOTE create -> RESOLVED case_number=null + * REMOTE bulkCreate -> RESOLVED [null, null] + * REMOTE upsert -> RESOLVED case_number=null + * LOCAL create -> RESOLVED case_number="CASE-00001" + * ``` + * + * Triage left disposition A — implement autonumber on remote — behind the + * appetite door "for want of measured demand", and recorded that + * `supports.autonumber` stays `true` on this face until A ships. The demand was + * then measured on the hosted product (objectstack-ai/cloud#2531: a hosted + * tenant, whose database is on this transport, could not create a HotCRM + * account — `POST /api/v1/data/crm_account` answered 501). So A shipped, and + * every refusal case below became the generation case it was holding the + * place of. The three-face structure, the controls and the layer pins are the + * refusal suite's own, kept because the questions they answer did not change. + * + * # What is pinned, and against what + * + * - **Generation on every leg** (`create`, `bulkCreate`, the no-id `upsert`, + * the id-bearing `upsert` that INSERTS — the leg #7099 recorded as writing + * NULL), on a real SQLite wearing the `@libsql/client` interface, so every + * assertion reads a row that landed, not a statement string. + * - **One semantics** (#6203): the local face and the remote face of the SAME + * database file draw from ONE `_objectstack_sequences` row under one + * `key_hash`, and read a seeded value's counter by one rule — suffix + * included (#6468) — because the shared `SqlDriver` members are CALLED, not + * copied. A `{field}` token the row leaves empty is refused by the same + * sentence on both faces, since it is the same code. + * - **The #5495 re-seed** reaches this face: rows landed above the counter by + * a bypass path cost one internal retry, not a 409. + * - **The legacy table shape** is refused, loudly, with `code` AND `status`, + * and nothing is written. + * - **Still untouched**: a caller-supplied number, `update()`, an object with + * no `auto_number` field, and the local and replica faces. + * + * Cross-process atomicity — two writers in two processes — is + * `turso-remote-autonumber-concurrency.test.ts`'s pin, since it needs real + * processes rather than the single-threaded stub this file runs on. + * + * # Reverse verification — direction predicted BEFORE it was run + * + * ① Merge-leg exclusion dropped (the transport ignores `insertOnlyColumns`). + * Predicted: exactly the MERGE pins go red — the merged row comes back + * carrying the fresh reservation instead of `CASE-00042` — while every + * insert-leg pin stays green, since an over-eager merge set changes + * nothing about an insert. + * ② Warm-path statement made non-atomic (read `last_value`, then write it + * back + 1, as two statements). Predicted: NOTHING in this file goes red — + * the stub serialises by construction — which is exactly why the + * concurrency pin lives in its own file on real processes. + * The measured outcomes are recorded in the PR. + */ + +import { describe, it, expect, afterAll, afterEach, assert } from 'vitest'; +import { createClient } from '@libsql/client'; +import { TursoDriver } from './index.js'; +import { RemoteTransport } from './remote-transport.js'; +import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; +import { replicaFiles } from './replica-file.testkit.js'; + +// A replica is a local FILE: the constructor refuses one on `:memory:`. The +// same testkit hands out the shared file the two-face block opens twice. +const dbFiles = replicaFiles(); +afterAll(() => dbFiles.removeAll()); + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +const NUMBERED_OBJECT = { + name: 'crm_case', + fields: { + organization_id: { type: 'string' }, + case_number: { type: 'autonumber', format: 'CASE-{00000}', unique: true }, + title: { type: 'string' }, + }, +}; + +/** The control: same shape, no record number. */ +const PLAIN_OBJECT = { + name: 'crm_note', + fields: { organization_id: { type: 'string' }, title: { type: 'string' } }, +}; + +/** A format whose counter does NOT end the value (#6468): the suffix is read, not folded into the number. */ +const SUFFIXED_OBJECT = { + name: 'ticket', + fields: { ticket_no: { type: 'autonumber', format: '{000}-{YYYY}' }, title: { type: 'string' } }, +}; + +/** A format that interpolates a row field: an empty one is refused, not rendered into the wrong scope. */ +const FIELD_SCOPED_OBJECT = { + name: 'po', + fields: { dept: { type: 'string' }, po_no: { type: 'autonumber', format: '{dept}-{000}' } }, +}; + +const ALL_OBJECTS = [NUMBERED_OBJECT, PLAIN_OBJECT, SUFFIXED_OBJECT, FIELD_SCOPED_OBJECT]; + +/** `renderAutonumber` reads date tokens in UTC when no business timezone is given. */ +const YYYY = String(new Date().getUTCFullYear()); + +const open: TursoDriver[] = []; + +afterEach(async () => { + while (open.length) await open.pop()!.disconnect().catch(() => {}); +}); + +async function makeRemote() { + const stub = makeLibsqlSqliteStub(); + const driver = new TursoDriver({ url: 'libsql://probe.turso.io', client: stub as never }); + open.push(driver); + await driver.connect(); + expect(driver.transportMode).toBe('remote'); + await driver.initObjects(ALL_OBJECTS as any); + return { driver, stub }; +} + +async function makeLocal() { + const driver = new TursoDriver({ url: ':memory:' }); + open.push(driver); + expect(driver.transportMode).toBe('local'); + await driver.initObjects([NUMBERED_OBJECT as any]); + return driver; +} + +/** + * The third face. `syncUrl` is what makes it `replica`; the sync itself is + * switched off because this suite is about the write path, not about the + * network the embedded replica talks to. + */ +async function makeReplica() { + const stub = makeLibsqlSqliteStub(); + const driver = new TursoDriver({ + url: dbFiles.next(), + syncUrl: 'libsql://probe.turso.io', + client: stub as never, + sync: { onConnect: false, intervalSeconds: 0 }, + }); + open.push(driver); + await driver.connect(); + expect(driver.transportMode).toBe('replica'); + await driver.initObjects([NUMBERED_OBJECT as any]); + return driver; +} + +const rowCount = (stub: LibsqlSqliteStub, table: string, where = '1=1', args: unknown[] = []) => + (stub.raw.prepare(`select count(*) as c from "${table}" where ${where}`).all(...args) as Array<{ c: number }>)[0].c; + +/** + * The counter rows on disk. The table is created the first time a number is + * reserved, so "no table yet" is the strongest form of "nothing was reserved" + * and answers `[]` rather than throwing. + */ +const sequenceRows = (stub: LibsqlSqliteStub): Array> => { + const present = stub.raw + .prepare(`select name from sqlite_master where type = 'table' and name = '_objectstack_sequences'`) + .all(); + if (present.length === 0) return []; + return stub.raw.prepare('select * from "_objectstack_sequences"').all() as Array>; +}; + +describe('[#21113] REMOTE: a record number this face now issues', () => { + it('create — the first number, then the next, from the persistent counter', async () => { + const { driver, stub } = await makeRemote(); + const first = await driver.create('crm_case', { organization_id: 'orgA', title: 'first' }); + const second = await driver.create('crm_case', { organization_id: 'orgA', title: 'second' }); + expect(first.case_number).toBe('CASE-00001'); + expect(second.case_number).toBe('CASE-00002'); + // On disk, not only on the returned row. + expect(rowCount(stub, 'crm_case', '"case_number" = ?', ['CASE-00001'])).toBe(1); + expect(rowCount(stub, 'crm_case', '"case_number" = ?', ['CASE-00002'])).toBe(1); + // And the counter is a ROW in the database, in the key_hash shape, not a + // number held by this process. + const seq = sequenceRows(stub); + expect(seq).toHaveLength(1); + expect(seq[0]).toMatchObject({ object: 'crm_case', field: 'case_number', tenant_id: 'orgA', scope: '', last_value: 2 }); + expect(String(seq[0].key_hash)).toMatch(/^[0-9a-f]{64}$/); + }); + + it('bulkCreate — every row numbered, in order', async () => { + const { driver } = await makeRemote(); + const rows = await driver.bulkCreate('crm_case', [ + { organization_id: 'orgA', title: 'b1' }, + { organization_id: 'orgA', title: 'b2' }, + ]); + expect((rows as any[]).map((r) => r.case_number)).toEqual(['CASE-00001', 'CASE-00002']); + }); + + it('bulkCreate — an explicit number in the batch is kept, and the next empty slot continues ABOVE it', async () => { + // The bootstrap from the data table's MAX: the explicit row lands first, + // the cold counter scans the table, and the generated row follows it + // rather than starting a second sequence at 1. + const { driver } = await makeRemote(); + const rows = await driver.bulkCreate('crm_case', [ + { organization_id: 'orgA', title: 'imported', case_number: 'CASE-00007' }, + { organization_id: 'orgA', title: 'needs one' }, + ]); + expect((rows as any[]).map((r) => r.case_number)).toEqual(['CASE-00007', 'CASE-00008']); + }); + + it('upsert with no id and no conflict keys — an insert, numbered', async () => { + const { driver } = await makeRemote(); + const row = await driver.upsert('crm_case', { organization_id: 'orgA', title: 'u1' }); + expect(row.case_number).toBe('CASE-00001'); + }); + + it('an empty string is an empty slot, exactly as `fillAutoNumberFields` reads it', async () => { + const { driver } = await makeRemote(); + const row = await driver.create('crm_case', { organization_id: 'orgA', title: 'blank', case_number: '' }); + expect(row.case_number).toBe('CASE-00001'); + }); + + it('an explicit null is an empty slot too', async () => { + const { driver } = await makeRemote(); + const row = await driver.create('crm_case', { organization_id: 'orgA', title: 'nulled', case_number: null }); + expect(row.case_number).toBe('CASE-00001'); + }); + + it('[#7099] an id-bearing upsert that INSERTS gets its number — the leg that used to land NULL', async () => { + // Under the refusal this leg was the declared residue: it carried an id, + // so it might merge, so it was not refused — and when it inserted, the + // slot landed NULL and a warning said so. Now the number is reserved + // before the statement and the insert leg carries it. No warning: there + // is nothing left to report. + const { driver, stub } = await makeRemote(); + const warnings: string[] = []; + const sink = (driver as unknown as { logger: { warn: (msg: string) => void } }).logger; + const realWarn = sink.warn; + sink.warn = (msg: string) => { warnings.push(String(msg)); }; + try { + const inserted = await driver.upsert('crm_case', { + id: 'never-seen', + organization_id: 'orgA', + title: 'inserted by upsert', + }); + expect(inserted.case_number).toBe('CASE-00001'); + expect(rowCount(stub, 'crm_case', '"id" = ? and "case_number" = ?', ['never-seen', 'CASE-00001'])).toBe(1); + expect(warnings.filter((m) => /auto_number/.test(m))).toEqual([]); + } finally { + sink.warn = realWarn; + } + }); + + it('the caller never sees its own object mutated by the number', async () => { + const { driver } = await makeRemote(); + const data = { organization_id: 'orgA', title: 'untouched input' }; + const row = await driver.create('crm_case', data); + expect(row.case_number).toBe('CASE-00001'); + expect(data).toEqual({ organization_id: 'orgA', title: 'untouched input' }); + }); +}); + +describe('[#21113] REMOTE: what is deliberately left as it was', () => { + it('a caller-supplied number is written unchanged — the seed replay / import path', async () => { + const { driver } = await makeRemote(); + const row = await driver.create('crm_case', { + organization_id: 'orgA', + title: 'imported', + case_number: 'CASE-09999', + }); + expect(row.case_number).toBe('CASE-09999'); + }); + + it('a batch that carries all its own numbers is written unchanged', async () => { + const { driver, stub } = await makeRemote(); + const rows = await driver.bulkCreate('crm_case', [ + { organization_id: 'orgA', title: 'i1', case_number: 'CASE-00101' }, + { organization_id: 'orgA', title: 'i2', case_number: 'CASE-00102' }, + ]); + expect((rows as any[]).map((r) => r.case_number)).toEqual(['CASE-00101', 'CASE-00102']); + // Nothing was reserved for rows that brought their own numbers. + expect(sequenceRows(stub)).toEqual([]); + }); + + it('[#7011] an id-bearing upsert MERGES and keeps the number already in the column', async () => { + const { driver, stub } = await makeRemote(); + await driver.create('crm_case', { + id: 'fixed1', + organization_id: 'orgA', + title: 'seeded', + case_number: 'CASE-00042', + }); + const merged = await driver.upsert('crm_case', { id: 'fixed1', organization_id: 'orgA', title: 'edited' }); + expect(merged.case_number).toBe('CASE-00042'); + expect(merged.title).toBe('edited'); + expect(rowCount(stub, 'crm_case')).toBe(1); + // The reservation the merge did not use is a GAP in the sequence, never a + // renumbering — the local face's rule (#7011), byte for byte: the counter + // bootstrapped from the seeded 42, reserved 43 for the merge, and the next + // insert takes 44. + const next = await driver.create('crm_case', { organization_id: 'orgA', title: 'after the merge' }); + expect(next.case_number).toBe('CASE-00044'); + }); + + it('[#7011] an explicit payload number does not renumber a merged row either', async () => { + const { driver } = await makeRemote(); + await driver.create('crm_case', { id: 'fixed4', organization_id: 'orgA', title: 'seeded', case_number: 'CASE-00042' }); + const merged = await driver.upsert('crm_case', { id: 'fixed4', organization_id: 'orgA', title: 'renumber?', case_number: 'CASE-00099' }); + expect(merged.case_number).toBe('CASE-00042'); + expect(merged.title).toBe('renumber?'); + }); + + it('update never needed a number and is untouched', async () => { + const { driver } = await makeRemote(); + await driver.create('crm_case', { + id: 'fixed2', + organization_id: 'orgA', + title: 'seeded', + case_number: 'CASE-00043', + }); + const updated = await driver.update('crm_case', 'fixed2', { title: 'renamed' }); + // [#14438] `update()` declares its not-found arm; a seeded id must answer the row. + assert(updated !== null, 'update on a seeded id answered the not-found arm'); + expect(updated.case_number).toBe('CASE-00043'); + expect(updated.title).toBe('renamed'); + }); + + it('an object with no auto_number field is written exactly as before, and reserves nothing', async () => { + const { driver, stub } = await makeRemote(); + const row = await driver.create('crm_note', { organization_id: 'orgA', title: 'n' }); + expect(typeof row.id).toBe('string'); + expect(row.title).toBe('n'); + const note = await driver.upsert('crm_note', { id: 'note1', organization_id: 'orgA', title: 'u' }); + expect(note.title).toBe('u'); + expect(sequenceRows(stub)).toEqual([]); + }); +}); + +describe('[#6203] one semantics: the shared SqlDriver rules, called on both faces of one database', () => { + /** + * The same database FILE, opened twice: once by the local face (Knex over + * better-sqlite3), once by the remote face (`@libsql/client`'s native `file:` + * client, which is libSQL itself — the engine a Turso endpoint runs). What + * this pins is that the two faces compute ONE counter identity and ONE + * bootstrap, so their numbers interleave on one row instead of colliding + * from two. + */ + async function makeSharedPair(objects: unknown[]) { + const url = dbFiles.next(); + const local = new TursoDriver({ url }); + open.push(local); + expect(local.transportMode).toBe('local'); + await local.initObjects(objects as any); + + const client = createClient({ url }); + const remote = new TursoDriver({ url: 'libsql://probe.turso.io', client }); + open.push(remote); + await remote.connect(); + expect(remote.transportMode).toBe('remote'); + await remote.initObjects(objects as any); + return { local, remote }; + } + + it('numbers issued by the local face and by the remote face on the same database do not collide', async () => { + const { local, remote } = await makeSharedPair([NUMBERED_OBJECT]); + const opts = { bypassTenantAudit: true } as any; + const a = await local.create('crm_case', { organization_id: 'orgA', title: 'local 1' }, opts); + const b = await remote.create('crm_case', { organization_id: 'orgA', title: 'remote 1' }); + const c = await local.create('crm_case', { organization_id: 'orgA', title: 'local 2' }, opts); + const d = await remote.create('crm_case', { organization_id: 'orgA', title: 'remote 2' }); + expect([a, b, c, d].map((r) => r.case_number)).toEqual(['CASE-00001', 'CASE-00002', 'CASE-00003', 'CASE-00004']); + + // ONE counter row, under the key the local face computes. + const knex = (local as any).knex; + const rows = await knex('_objectstack_sequences').select('*'); + expect(rows).toHaveLength(1); + expect(rows[0].key_hash).toBe((local as any).sequenceKeyHash('crm_case', 'orgA', 'case_number', '')); + expect(Number(rows[0].last_value)).toBe(4); + }); + + it('the tenant bucket is the same rule: a different organization is a different counter on both faces', async () => { + const { local, remote } = await makeSharedPair([NUMBERED_OBJECT]); + const opts = { bypassTenantAudit: true } as any; + expect((await local.create('crm_case', { organization_id: 'orgA', title: 'a' }, opts)).case_number).toBe('CASE-00001'); + expect((await remote.create('crm_case', { organization_id: 'orgB', title: 'b' })).case_number).toBe('CASE-00001'); + expect((await local.create('crm_case', { organization_id: 'orgB', title: 'b2' }, opts)).case_number).toBe('CASE-00002'); + expect((await remote.create('crm_case', { organization_id: 'orgA', title: 'a2' })).case_number).toBe('CASE-00002'); + const knex = (local as any).knex; + const rows = await knex('_objectstack_sequences').select('tenant_id', 'last_value').orderBy('tenant_id'); + expect(rows.map((r: any) => [r.tenant_id, Number(r.last_value)])).toEqual([['orgA', 2], ['orgB', 2]]); + }); + + it('[#6468] a seeded value whose counter does not END the value is read by the shared rule on the remote face', async () => { + // `{000}-{YYYY}` renders `005-2026`: the counter is 5, the suffix is the + // year. Concatenating every digit would read 52026 and the next remote + // number would jump to 52027 — the two-wrong-answers defect #6468 closed + // by sharing `readAutonumberCounter`. Both faces answer 6. + const { local, remote } = await makeSharedPair([SUFFIXED_OBJECT]); + await remote.create('ticket', { title: 'seeded', ticket_no: `005-${YYYY}` }); + expect((await remote.create('ticket', { title: 'r' })).ticket_no).toBe(`006-${YYYY}`); + expect((await local.create('ticket', { title: 'l' })).ticket_no).toBe(`007-${YYYY}`); + }); + + it('a `{field}` token the row leaves empty is refused by the same sentence on both faces, and nothing is written', async () => { + const { remote } = await makeSharedPair([FIELD_SCOPED_OBJECT]); + const stubbed = await makeRemote(); + const local = await makeLocal(); + await local.initObjects([FIELD_SCOPED_OBJECT as any]); + + const message = async (write: () => Promise) => + write().then( + (row) => { throw new Error(`expected a refusal, got ${JSON.stringify(row)}`); }, + (e) => (e as Error).message, + ); + const onRemote = await message(() => remote.create('po', { po_no: '' })); + const onLocal = await message(() => local.create('po', { po_no: '' }, { bypassTenantAudit: true } as any)); + expect(onRemote).toContain('Cannot generate autonumber "po.po_no" (format "{dept}-{000}"): referenced field(s) [dept] are empty on the record.'); + expect(onRemote).toBe(onLocal); + + // The refusal is raised before any statement: no row, no counter row. + const onStub = await message(() => stubbed.driver.create('po', { po_no: '' })); + expect(onStub).toBe(onRemote); + expect(rowCount(stubbed.stub, 'po')).toBe(0); + expect(sequenceRows(stubbed.stub)).toEqual([]); + + // The control: with the field set, the scope renders and the number lands. + expect((await remote.create('po', { dept: 'OPS' })).po_no).toBe('OPS-001'); + expect((await remote.create('po', { dept: 'OPS' })).po_no).toBe('OPS-002'); + expect((await remote.create('po', { dept: 'HR' })).po_no).toBe('HR-001'); + }); +}); + +describe('[#5495] the collision re-seed reaches the remote face', () => { + const landByBypass = (stub: LibsqlSqliteStub, from: number, to: number) => { + const insert = stub.raw.prepare( + 'insert into "crm_case" ("id", "organization_id", "case_number", "title") values (?, ?, ?, ?)', + ); + for (let n = from; n <= to; n++) insert.run(`bypass-${n}`, 'orgA', `CASE-${String(n).padStart(5, '0')}`, `row ${n}`); + }; + + it('create — served on the first call after a seed replay lands above the counter', async () => { + const { driver, stub } = await makeRemote(); + await driver.create('crm_case', { organization_id: 'orgA', title: 'first' }); + expect(sequenceRows(stub)[0].last_value).toBe(1); + // Rows 2..30 land by a path that never enters `fillAutoNumberFields`, so + // the counter sits at 1 while the table holds 30. + landByBypass(stub, 2, 30); + + const created = await driver.create('crm_case', { organization_id: 'orgA', title: 'after the replay' }); + expect(created.case_number).toBe('CASE-00031'); + expect(rowCount(stub, 'crm_case', '"case_number" = ?', ['CASE-00031'])).toBe(1); + expect(rowCount(stub, 'crm_case')).toBe(31); + // The counter was moved FORWARD to the observed MAX, then issued from. + expect(sequenceRows(stub)[0].last_value).toBe(31); + }); + + it('bulkCreate — the same, one row at a time', async () => { + const { driver, stub } = await makeRemote(); + await driver.create('crm_case', { organization_id: 'orgA', title: 'first' }); + landByBypass(stub, 2, 10); + const rows = await driver.bulkCreate('crm_case', [ + { organization_id: 'orgA', title: 'b1' }, + { organization_id: 'orgA', title: 'b2' }, + ]); + expect((rows as any[]).map((r) => r.case_number)).toEqual(['CASE-00011', 'CASE-00012']); + expect(rowCount(stub, 'crm_case')).toBe(12); + }); + + it('a duplicate on a value the CALLER typed is still the caller\'s 409 — never retried, never re-seeded', async () => { + const { driver, stub } = await makeRemote(); + await driver.create('crm_case', { organization_id: 'orgA', title: 'first', case_number: 'CASE-00500' }); + const err = await driver + .create('crm_case', { organization_id: 'orgA', title: 'dup', case_number: 'CASE-00500' }) + .then( + (row) => { throw new Error(`expected a unique violation, got ${JSON.stringify(row)}`); }, + (e) => e as Error, + ); + expect(err.message).toMatch(/UNIQUE constraint failed/i); + expect(rowCount(stub, 'crm_case')).toBe(1); + expect(sequenceRows(stub)).toEqual([]); + }); +}); + +describe('[#21113] REMOTE: a sequences table in the legacy shape is refused, not keyed by a second rule', () => { + it('DATABASE_ERROR/500 before any row is written, and not cached as "ensured"', async () => { + const stub = makeLibsqlSqliteStub(); + // A pre-`key_hash` table, as a local face older than the current shape + // left it. The remote face cannot migrate it (no Knex connection) and must + // not key by the legacy `(object, tenant_id, field)` rule beside the + // shared one. + stub.raw.prepare( + 'create table "_objectstack_sequences" ("object" text not null, "tenant_id" text not null, "field" text not null, "last_value" integer not null default 0)', + ).run(); + const driver = new TursoDriver({ url: 'libsql://probe.turso.io', client: stub as never }); + open.push(driver); + await driver.connect(); + await driver.initObjects([NUMBERED_OBJECT as any]); + + const refusalOf = () => + driver.create('crm_case', { organization_id: 'orgA', title: 'first' }).then( + (row) => { throw new Error(`expected a refusal, got ${JSON.stringify(row)}`); }, + (e) => e as WireBearingError, + ); + const err = await refusalOf(); + expect(err.code).toBe('DATABASE_ERROR'); + expect(err.status).toBe(500); + expect(err.message).toContain( + 'The sequence-counter table "_objectstack_sequences" on this database predates the key_hash shape, and the Turso REMOTE transport does not migrate it', + ); + expect(rowCount(stub, 'crm_case')).toBe(0); + // The legacy table is left exactly as found. + const columns = (stub.raw.prepare('pragma table_info("_objectstack_sequences")').all() as Array<{ name: string }>).map((c) => c.name); + expect(columns).toEqual(['object', 'tenant_id', 'field', 'last_value']); + // A second write probes again and refuses again — the refusal was not + // pinned on the process as a verdict. + const again = await refusalOf(); + expect(again.code).toBe('DATABASE_ERROR'); + expect(again.status).toBe(500); + + // The control: a caller-supplied number never needs the counter and is + // written through, legacy table or not. + const imported = await driver.create('crm_case', { organization_id: 'orgA', title: 'imported', case_number: 'CASE-00007' }); + expect(imported.case_number).toBe('CASE-00007'); + }); +}); + +describe('[#6203] the other faces are untouched', () => { + it('LOCAL still issues the number', async () => { + const local = await makeLocal(); + const row = await local.create( + 'crm_case', + { organization_id: 'orgA', title: 'l' }, + { bypassTenantAudit: true } as any, + ); + expect(row.case_number).toBe('CASE-00001'); + }); + + it('LOCAL bulkCreate and upsert still issue numbers', async () => { + const local = await makeLocal(); + const rows = await local.bulkCreate( + 'crm_case', + [ + { organization_id: 'orgA', title: 'b1' }, + { organization_id: 'orgA', title: 'b2' }, + ], + { bypassTenantAudit: true } as any, + ); + expect((rows as any[]).map((r) => r.case_number)).toEqual(['CASE-00001', 'CASE-00002']); + + const upserted = await local.upsert( + 'crm_case', + { organization_id: 'orgA', title: 'u' }, + undefined, + { bypassTenantAudit: true } as any, + ); + expect(upserted.case_number).toBe('CASE-00003'); + }); + + it('REPLICA — the third face — still issues the number', async () => { + const replica = await makeReplica(); + const row = await replica.create( + 'crm_case', + { organization_id: 'orgA', title: 'r' }, + { bypassTenantAudit: true } as any, + ); + expect(row.case_number).toBe('CASE-00001'); + }); +}); + +describe('[#21113] the layer the number is issued on, pinned', () => { + it('RemoteTransport still cannot see a field type, so it is not the one that issues', () => { + // `create(object, data)` — two parameters, neither of them a schema — and + // the class caches nothing from `syncSchema`. The refusal was raised one + // layer up for this reason, and generation happens one layer up for the + // same reason: the transport receives a row that already carries its + // number. Kept as an assertion so a future schema-bearing signature or an + // autonumber member on the transport makes the decision reviewable again. + expect(RemoteTransport.prototype.create.length).toBe(2); + expect(Object.getOwnPropertyNames(RemoteTransport.prototype).some((m) => /autonumber|sequence/i.test(m))) + .toBe(false); + }); + + it('TursoDriver DOES see it, in remote mode, and the capability bit is now TRUE rather than knowingly false', async () => { + const { driver } = await makeRemote(); + const cfgs = (driver as any).autoNumberFields['crm_case']; + expect(cfgs).toHaveLength(1); + expect(cfgs[0]).toMatchObject({ name: 'case_number', format: 'CASE-{00000}' }); + // The bit that makes the engine defer to this driver rather than run its + // own in-memory fallback. It stayed `true` through the refusal era and + // flipped WITH the implementation, as triage recorded it would. + expect((driver.supports as any).autonumber).toBe(true); + }); +}); diff --git a/packages/drivers/driver-turso/src/turso-remote-autonumber-refusal.test.ts b/packages/drivers/driver-turso/src/turso-remote-autonumber-refusal.test.ts deleted file mode 100644 index ee59c933a21..00000000000 --- a/packages/drivers/driver-turso/src/turso-remote-autonumber-refusal.test.ts +++ /dev/null @@ -1,537 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#6944] `auto_number` on the Turso REMOTE face: refused loudly, not written - * as NULL — and the other two faces untouched. - * - * # The defect this replaces - * - * `TursoDriver` picks its transport from `url`. Local and embedded-replica - * inherit `SqlDriver`'s write path and issue record numbers from the persistent - * `_objectstack_sequences` table; remote overrides the write path to - * `RemoteTransport`, which builds its own INSERT and never enters - * `fillAutoNumberFields`. Measured on `main` @ `2f3e79351`, before this change: - * - * ``` - * REMOTE create -> RESOLVED case_number=null - * REMOTE bulkCreate -> RESOLVED [null, null] - * REMOTE upsert -> RESOLVED case_number=null - * LOCAL create -> RESOLVED case_number="CASE-00001" - * ``` - * - * Nothing upstream catches it: `supports.autonumber` is `true` on this face - * (inherited via `...super.supports`), so the engine defers generation to the - * driver and never runs its own fallback. A driver face that boots and silently - * fails to deliver a declared capability is the shape #3724 ruled on, and triage - * applied that ruling here on 2026-08-09: **disposition B — explicit refusal**. - * Implementing autonumber on the remote transport (A) is deferred for want of - * measured demand, not forgotten. - * - * # Where the refusal is raised, and why not one layer down - * - * `RemoteTransport.create(object, data)` takes no schema and caches none, so it - * cannot tell an `auto_number` column from any other `TEXT` one — measured - * (arity 2; `syncSchema` reads a schema and keeps nothing). `TursoDriver` can: - * `registerRemoteFieldMetadata` → `registerExternalObject` classifies fields at - * remote schema-sync time and populates `autoNumberFields`, measured live in - * remote mode. So the refusal is raised on the driver, the layer that holds the - * knowledge, rather than reached for from the one that does not. - * - * # ⚠️ Every refusal case asserts `code` AND `status` - * - * ADR-0112, and #6144: this refusal is one we author, and a bare - * `rejects.toThrow()` would be satisfied by any error at all — including the - * `Error` a future refactor throws for an unrelated reason. `NOT_IMPLEMENTED` / - * 501 is the class, on the same two-class taxonomy this package already applies - * to aggregate functions (#5907) and date buckets (#6212): `autonumber` is a - * field type `@objectstack/spec` declares and this driver's other faces - * generate, so the caller made no mistake and the gap is the backend's. - * - * # [#6203] Three faces, because a fix that lands on one is two answers - * - * The local and replica blocks are not decoration. The refusal must not leak - * onto a face that generates numbers correctly, and "the base class was not - * touched" is not on its own an answer about a class that OVERRIDES `create`, - * `bulkCreate` and `upsert` to route by transport mode. - * - * # Reverse verification — direction predicted BEFORE it was run - * - * Prediction: with the refusal removed, every case in the REMOTE refusal block - * fails through the harness's "it resolved" branch (the un-fixed code never - * threw — it wrote NULL), NOT on a `code`/`status` comparison; the pass-through - * block, the residue pin and all three faces' generate/merge controls stay - * green, since none of them depends on the refusal existing. - * - * Measured with `refuseUngeneratableRemoteAutonumber`'s body emptied - * (`return;`) — `8 failed | 923 passed` across the package, and the prediction - * held: - * - * - this file's REMOTE block goes 0/6, every failure through "it resolved"; - * - the two rewritten boundary pins (`turso-autonumber-resync.test.ts`, - * `turso-autonumber-batch-resync.test.ts`) fail the same way, which is the - * point of rewriting rather than deleting them — they now go red for the - * defect too; - * - the 11 cases outside the REMOTE block stay green, confirming they pin - * facts true on both sides of the change and would survive a future - * re-implementation of A. - * - * One detail not predicted and worth keeping: the reverted suite does not merely - * go red, it REPRINTS the defect — every failure message carries the resolved - * row with `"case_number":null` (or `""` for the empty-string case) inside it. - * - * # [#7099] The leg the gate cannot classify is now LOUD — still not refused - * - * The residue pin below was recorded under a premise that has since been - * measured false. It said classifying this leg "needs the round trip this - * refusal exists to avoid"; in fact `RemoteTransport.upsert` already runs - * `SELECT * FROM "" WHERE "id" = ?` after every `INSERT … ON CONFLICT` - * and returns the mapped row — the pin's own `expect(inserted.case_number)` - * was reading that very round trip's result. So the NULL was in hand all along, - * one field read away, on the layer that knows which column is an - * `auto_number`. No probe query was added, and none is needed. - * - * What did NOT change: the row still lands, and it still lands with a NULL - * record number. Refusing after the write is a different act from the pre-write - * gate, and generating the number on remote is still the deferred half (A). The - * pin therefore asserts BOTH halves — the unchanged NULL and the new warning — - * so a future change that quietly converts this leg into a refusal goes red on - * the first half rather than sliding through on the second. - * - * ## Where the detection lives, and why not on `RemoteTransport` - * - * The card's promoted scope suggested handing the autonumber rule DOWN to - * `RemoteTransport` (the `setFilterColumnSql` / `setDiagnosticSink` plumbing - * precedent). Measured, the driver is the better layer and needs no plumbing at - * all: `TursoDriver.upsert` already receives the transport's returned row and - * already holds `autoNumberFields[object]`, and its `logger.warn` is the very - * sink `setDiagnosticSink` forwards to — so routing through the transport would - * be a longer path to the same log line. It would also contradict the layering - * decision the block below pins: "RemoteTransport cannot see a field type, so it - * cannot be the one to refuse" asserts no member of `RemoteTransport.prototype` - * matches `/autonumber|sequence/i`. Teaching the transport this rule would have - * gone red on that pin — correctly. - * - * ## Reverse verification — both directions predicted BEFORE they were run - * - * ① Fix reverted (driver source restored to `origin/main`, all new tests kept). - * Predicted: exactly one red — the residue pin, failing on the WARNING half - * (`expect(lines).toHaveLength(1)`), never on the NULL half asserted above - * it. Measured `1 failed | 19 passed`, `expected [] to have a length of 1`. - * The 19 green include the three silence controls, which an unfixed driver - * satisfies trivially — stated plainly because it is what ② is for. - * - * ② Fix broken the OTHER way (empty-slot predicate dropped, so the report fires - * on every declared `auto_number` column). Predicted: exactly two red — the - * MERGE-leg control and the caller-supplied-number control — with the - * residue pin itself STAYING GREEN, since an over-firing rule still emits - * its one line, and with the no-`auto_number`-field control also staying - * green, since the registry lookup returns before the predicate is reached. - * Measured `2 failed | 18 passed`, both `expected [ Array(1) ] to deeply - * equal []`. That is the half ① cannot prove: the pin alone cannot tell a - * correct warning from a wolf-crying one, and the controls are what do. - */ - -import { describe, it, expect, afterAll, afterEach, vi, assert } from 'vitest'; -import { TursoDriver } from './index.js'; -import { RemoteTransport } from './remote-transport.js'; -import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; -import { replicaFiles } from './replica-file.testkit.js'; - -// A replica is a local FILE: the constructor refuses one on `:memory:`. -const replicaFileUrls = replicaFiles(); -afterAll(() => replicaFileUrls.removeAll()); - -interface WireBearingError extends Error { - code?: string; - status?: number; -} - -/** The first sentence of the refusal, spelled out rather than imported (#5240). */ -const REFUSAL_SENTENCE = (object: string, field: string, path: string) => - `Object "${object}" declares auto_number field(s) [${field}] left empty for this ${path}, ` + - `and the Turso REMOTE transport does not generate record numbers.`; - -const NUMBERED_OBJECT = { - name: 'crm_case', - fields: { - organization_id: { type: 'string' }, - case_number: { type: 'autonumber', format: 'CASE-{00000}', unique: true }, - title: { type: 'string' }, - }, -}; - -/** The control: same shape, no record number. */ -const PLAIN_OBJECT = { - name: 'crm_note', - fields: { organization_id: { type: 'string' }, title: { type: 'string' } }, -}; - -const open: TursoDriver[] = []; - -afterEach(async () => { - while (open.length) await open.pop()!.disconnect().catch(() => {}); -}); - -async function makeRemote() { - const stub = makeLibsqlSqliteStub(); - const driver = new TursoDriver({ url: 'libsql://probe.turso.io', client: stub as never }); - open.push(driver); - await driver.connect(); - expect(driver.transportMode).toBe('remote'); - await driver.initObjects([NUMBERED_OBJECT as any, PLAIN_OBJECT as any]); - return { driver, stub }; -} - -async function makeLocal() { - const driver = new TursoDriver({ url: ':memory:' }); - open.push(driver); - expect(driver.transportMode).toBe('local'); - await driver.initObjects([NUMBERED_OBJECT as any]); - return driver; -} - -/** - * The third face. `syncUrl` is what makes it `replica`; the sync itself is - * switched off because this suite is about the write path, not about the - * network the embedded replica talks to. - */ -async function makeReplica() { - const stub = makeLibsqlSqliteStub(); - const driver = new TursoDriver({ - url: replicaFileUrls.next(), - syncUrl: 'libsql://probe.turso.io', - client: stub as never, - sync: { onConnect: false, intervalSeconds: 0 }, - }); - open.push(driver); - await driver.connect(); - expect(driver.transportMode).toBe('replica'); - await driver.initObjects([NUMBERED_OBJECT as any]); - return driver; -} - -/** - * [#7099] Watch the sink the driver actually writes operator diagnostics to. - * - * `TursoDriver` inherits `SqlDriver.logger` and `TursoDriver`'s own remote - * constructor forwards it to `RemoteTransport.setDiagnosticSink`, so this ONE - * spy sees a warning raised on either layer — which is what makes the assertion - * a statement about the operator's experience rather than about a call site. - * Filtered to `auto_number` lines so an unrelated warning (a tenant audit, a - * backfill) can neither satisfy nor break the pin. - */ -function watchAutoNumberWarnings(driver: TursoDriver) { - const sink = (driver as unknown as { logger: { warn: (msg: string, meta?: unknown) => void } }).logger; - const spy = vi.spyOn(sink, 'warn'); - return { - lines: () => spy.mock.calls.map((c) => String(c[0])).filter((m) => /auto_number/.test(m)), - restore: () => spy.mockRestore(), - }; -} - -const rowCount = (stub: LibsqlSqliteStub, table: string) => - (stub.raw.prepare(`select count(*) as c from "${table}"`).all() as Array<{ c: number }>)[0].c; - -/** - * Run a remote write that must be refused, and prove it cost nothing: the - * refusal happens before the statement is built, so no row may appear and no - * statement may reach the client — the rule every other refusal on this path - * already follows. - */ -async function refusalOf( - stub: LibsqlSqliteStub, - write: () => Promise, -): Promise { - const before = rowCount(stub, 'crm_case'); - const executed: unknown[] = []; - const realExecute = stub.execute.bind(stub); - stub.execute = async (s: unknown) => { - executed.push(s); - return realExecute(s); - }; - try { - const resolved = await write(); - throw new Error( - `expected the remote face to refuse, but it resolved with ${JSON.stringify(resolved)}`, - ); - } catch (e) { - const err = e as WireBearingError; - if (err.message.startsWith('expected the remote face to refuse')) throw err; - expect(executed).toEqual([]); - expect(rowCount(stub, 'crm_case')).toBe(before); - return err; - } finally { - stub.execute = realExecute; - } -} - -describe('[#6944] REMOTE: a record number this face cannot issue is refused', () => { - it('create — NOT_IMPLEMENTED/501, nothing written', async () => { - const { driver, stub } = await makeRemote(); - const err = await refusalOf(stub, () => - driver.create('crm_case', { organization_id: 'orgA', title: 'first' }), - ); - expect(err.code).toBe('NOT_IMPLEMENTED'); - expect(err.status).toBe(501); - expect(err.message).toContain(REFUSAL_SENTENCE('crm_case', 'case_number', 'create')); - }); - - it('bulkCreate — refused for the whole batch, no partial write', async () => { - const { driver, stub } = await makeRemote(); - const err = await refusalOf(stub, () => - driver.bulkCreate('crm_case', [ - { organization_id: 'orgA', title: 'b1' }, - { organization_id: 'orgA', title: 'b2' }, - ]), - ); - expect(err.code).toBe('NOT_IMPLEMENTED'); - expect(err.status).toBe(501); - expect(err.message).toContain(REFUSAL_SENTENCE('crm_case', 'case_number', 'bulkCreate')); - }); - - it('bulkCreate — one numbered row among explicit ones still refuses the batch', async () => { - const { driver, stub } = await makeRemote(); - const err = await refusalOf(stub, () => - driver.bulkCreate('crm_case', [ - { organization_id: 'orgA', title: 'imported', case_number: 'CASE-00007' }, - { organization_id: 'orgA', title: 'needs one' }, - ]), - ); - expect(err.code).toBe('NOT_IMPLEMENTED'); - expect(err.status).toBe(501); - }); - - it('upsert with no id and no conflict keys — provably an insert, so refused', async () => { - const { driver, stub } = await makeRemote(); - const err = await refusalOf(stub, () => - driver.upsert('crm_case', { organization_id: 'orgA', title: 'u1' }), - ); - expect(err.code).toBe('NOT_IMPLEMENTED'); - expect(err.status).toBe(501); - expect(err.message).toContain(REFUSAL_SENTENCE('crm_case', 'case_number', 'upsert')); - }); - - it('an empty string is an empty slot, exactly as `fillAutoNumberFields` reads it', async () => { - const { driver, stub } = await makeRemote(); - const err = await refusalOf(stub, () => - driver.create('crm_case', { organization_id: 'orgA', title: 'blank', case_number: '' }), - ); - expect(err.code).toBe('NOT_IMPLEMENTED'); - expect(err.status).toBe(501); - }); - - it('an explicit null is an empty slot too', async () => { - const { driver, stub } = await makeRemote(); - const err = await refusalOf(stub, () => - driver.create('crm_case', { organization_id: 'orgA', title: 'nulled', case_number: null }), - ); - expect(err.code).toBe('NOT_IMPLEMENTED'); - expect(err.status).toBe(501); - }); -}); - -describe('[#6944] REMOTE: what is deliberately NOT refused', () => { - it('a caller-supplied number is written unchanged — the seed replay / import path', async () => { - const { driver } = await makeRemote(); - const row = await driver.create('crm_case', { - organization_id: 'orgA', - title: 'imported', - case_number: 'CASE-09999', - }); - expect(row.case_number).toBe('CASE-09999'); - }); - - it('a batch that carries all its own numbers is written unchanged', async () => { - const { driver } = await makeRemote(); - const rows = await driver.bulkCreate('crm_case', [ - { organization_id: 'orgA', title: 'i1', case_number: 'CASE-00101' }, - { organization_id: 'orgA', title: 'i2', case_number: 'CASE-00102' }, - ]); - expect((rows as any[]).map((r) => r.case_number)).toEqual(['CASE-00101', 'CASE-00102']); - }); - - it('an id-bearing upsert MERGES and keeps the number already in the column', async () => { - const { driver } = await makeRemote(); - await driver.create('crm_case', { - id: 'fixed1', - organization_id: 'orgA', - title: 'seeded', - case_number: 'CASE-00042', - }); - const merged = await driver.upsert('crm_case', { id: 'fixed1', organization_id: 'orgA', title: 'edited' }); - expect(merged.case_number).toBe('CASE-00042'); - expect(merged.title).toBe('edited'); - }); - - it('update never needed a number and is untouched', async () => { - const { driver } = await makeRemote(); - await driver.create('crm_case', { - id: 'fixed2', - organization_id: 'orgA', - title: 'seeded', - case_number: 'CASE-00043', - }); - const updated = await driver.update('crm_case', 'fixed2', { title: 'renamed' }); - // [#14438] `update()` declares its not-found arm; a seeded id must answer the row. - assert(updated !== null, 'update on a seeded id answered the not-found arm'); - expect(updated.case_number).toBe('CASE-00043'); - expect(updated.title).toBe('renamed'); - }); - - it('an object with no auto_number field is written exactly as before', async () => { - const { driver } = await makeRemote(); - const row = await driver.create('crm_note', { organization_id: 'orgA', title: 'n' }); - expect(typeof row.id).toBe('string'); - expect(row.title).toBe('n'); - }); - - it('[#7099] an id-bearing upsert that INSERTS still writes NULL — and now says so', async () => { - // Both halves are asserted, and the pairing is the point. - // - // ① The NULL is UNCHANGED. #7099 restored observability on this leg, it did - // not change what the remote face accepts: refusing after the write has - // landed is a different act from the pre-write gate, and it stays out of - // scope. If this half ever flips, the change was a refusal or a - // generator, not a warning, and it owes the appetite-door conversation - // (#6944 disposition A). - // - // ② The silence is GONE. The premise the residue was recorded under — - // "whether that statement merges or inserts is knowable only after it - // runs, a round trip this refusal exists to avoid" — was measured false: - // `RemoteTransport.upsert` already pays that `SELECT` unconditionally - // and hands the row back, so the driver reads the NULL off a row it - // already holds. The warning names the object, the column and the id, - // because "some row somewhere lost its number" is not repairable. - const { driver } = await makeRemote(); - const warnings = watchAutoNumberWarnings(driver); - const inserted = await driver.upsert('crm_case', { - id: 'never-seen', - organization_id: 'orgA', - title: 'inserted by upsert', - }); - expect(inserted.case_number).toBeNull(); - - const lines = warnings.lines(); - expect(lines).toHaveLength(1); - expect(lines[0]).toContain('"crm_case"'); - expect(lines[0]).toContain('case_number'); - expect(lines[0]).toContain('never-seen'); - warnings.restore(); - }); - - it('[#7099] the MERGE leg stays silent — the warning does not cry wolf on a working path', async () => { - // The false-positive control, and the reason the check reads the RESULT row - // rather than the request: a merging upsert leaves the slot empty in the - // request too, so a request-shaped rule would warn on the one leg #6944 - // measured as correct. Nothing is wrong here — the row kept the number it - // already had — and an operator must not be told otherwise. - const { driver } = await makeRemote(); - await driver.create('crm_case', { - id: 'fixed3', - organization_id: 'orgA', - title: 'seeded', - case_number: 'CASE-00044', - }); - const warnings = watchAutoNumberWarnings(driver); - const merged = await driver.upsert('crm_case', { id: 'fixed3', organization_id: 'orgA', title: 'edited' }); - expect(merged.case_number).toBe('CASE-00044'); - expect(warnings.lines()).toEqual([]); - warnings.restore(); - }); - - it('[#7099] an object with no auto_number field never reaches the report', async () => { - // The other half of "no wolf": the registry lookup is keyed by object, so - // the overwhelming majority of remote upserts — objects with no record - // number at all — cost one map read and say nothing. A rule that warned on - // any NULL column would fire here, on a column that is simply absent. - const { driver } = await makeRemote(); - const warnings = watchAutoNumberWarnings(driver); - const row = await driver.upsert('crm_note', { id: 'note1', organization_id: 'orgA', title: 'n' }); - expect(row.title).toBe('n'); - expect(warnings.lines()).toEqual([]); - warnings.restore(); - }); - - it('[#7099] a caller-supplied number on the same leg is silent too', async () => { - // The seed-replay / import path crosses exactly this leg (id-bearing, no - // matching row) and is deliberately not refused. It must not be warned - // about either: the slot is filled, so there is nothing to report. - const { driver } = await makeRemote(); - const warnings = watchAutoNumberWarnings(driver); - const imported = await driver.upsert('crm_case', { - id: 'imported-1', - organization_id: 'orgA', - title: 'replayed', - case_number: 'CASE-00777', - }); - expect(imported.case_number).toBe('CASE-00777'); - expect(warnings.lines()).toEqual([]); - warnings.restore(); - }); -}); - -describe('[#6203] the refusal does not leak onto the faces that generate', () => { - it('LOCAL still issues the number', async () => { - const local = await makeLocal(); - const row = await local.create( - 'crm_case', - { organization_id: 'orgA', title: 'l' }, - { bypassTenantAudit: true } as any, - ); - expect(row.case_number).toBe('CASE-00001'); - }); - - it('LOCAL bulkCreate and upsert still issue numbers', async () => { - const local = await makeLocal(); - const rows = await local.bulkCreate( - 'crm_case', - [ - { organization_id: 'orgA', title: 'b1' }, - { organization_id: 'orgA', title: 'b2' }, - ], - { bypassTenantAudit: true } as any, - ); - expect((rows as any[]).map((r) => r.case_number)).toEqual(['CASE-00001', 'CASE-00002']); - - const upserted = await local.upsert( - 'crm_case', - { organization_id: 'orgA', title: 'u' }, - undefined, - { bypassTenantAudit: true } as any, - ); - expect(upserted.case_number).toBe('CASE-00003'); - }); - - it('REPLICA — the third face — still issues the number', async () => { - const replica = await makeReplica(); - const row = await replica.create( - 'crm_case', - { organization_id: 'orgA', title: 'r' }, - { bypassTenantAudit: true } as any, - ); - expect(row.case_number).toBe('CASE-00001'); - }); -}); - -describe('[#6944] the layer the refusal is raised on, pinned', () => { - it('RemoteTransport cannot see a field type, so it cannot be the one to refuse', () => { - // `create(object, data)` — two parameters, neither of them a schema — and - // the class caches nothing from `syncSchema`. This is the measurement that - // decided the layer, kept as an assertion so a future schema-bearing - // signature makes the decision reviewable again rather than stale. - expect(RemoteTransport.prototype.create.length).toBe(2); - expect(Object.getOwnPropertyNames(RemoteTransport.prototype).some((m) => /autonumber|sequence/i.test(m))) - .toBe(false); - }); - - it('TursoDriver DOES see it, in remote mode, which is why it can', async () => { - const { driver } = await makeRemote(); - const cfgs = (driver as any).autoNumberFields['crm_case']; - expect(cfgs).toHaveLength(1); - expect(cfgs[0]).toMatchObject({ name: 'case_number', format: 'CASE-{00000}' }); - // And the capability bit that makes the engine defer to this driver rather - // than fill the slot itself is still `true` here — which is precisely why a - // silent skip had nothing upstream to catch it. - expect((driver.supports as any).autonumber).toBe(true); - }); -}); diff --git a/packages/drivers/driver-turso/src/turso-update-missing-id.test.ts b/packages/drivers/driver-turso/src/turso-update-missing-id.test.ts index e58b26cac62..909bef52a19 100644 --- a/packages/drivers/driver-turso/src/turso-update-missing-id.test.ts +++ b/packages/drivers/driver-turso/src/turso-update-missing-id.test.ts @@ -34,7 +34,8 @@ * * The card measured that NO landed test pinned the miss posture on this driver * (`turso-driver.test.ts:138,731` and - * `turso-remote-autonumber-refusal.test.ts:369` all read `update()` results + * `turso-remote-autonumber-generation.test.ts`'s `update()` case — the + * refusal suite at `:369` when this was measured — all read `update()` results * over rows that EXIST), so every pin here is net-new coverage. * * # The pins, and what each alone would miss diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa469a128c1..3636f16804b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1115,6 +1115,9 @@ importers: better-sqlite3: specifier: ^13.0.3 version: 13.0.3 + tsx: + specifier: ^4.23.12 + version: 4.23.12 typescript: specifier: ^6.0.3 version: 6.0.3 From 56fd788cb72d945a674c810f48c3ad1379e89855 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 1 Oct 2026 08:58:16 +0000 Subject: [PATCH 2/7] docs(driver-turso): README and changeset for remote record numbers; concurrency pin reports its measurement The README's remote-mode refusal table loses its "Record numbers" row and gains a section on how remote mode issues them. The changeset declares the widening for @objectstack/driver-turso (minor) and the lifted protected members for @objectstack/driver-sql (patch). The cross-process pin asks for two writer changes in the global order rather than a non-contiguous run per writer, which the scheduler does not guarantee, and prints what it measured. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- .changeset/21113-turso-remote-autonumber.md | 42 +++++++++++++++++++ packages/drivers/driver-turso/README.md | 25 +++++++---- ...urso-remote-autonumber-concurrency.test.ts | 29 +++++++++---- 3 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 .changeset/21113-turso-remote-autonumber.md diff --git a/.changeset/21113-turso-remote-autonumber.md b/.changeset/21113-turso-remote-autonumber.md new file mode 100644 index 00000000000..e144d85fd08 --- /dev/null +++ b/.changeset/21113-turso-remote-autonumber.md @@ -0,0 +1,42 @@ +--- +'@objectstack/driver-turso': minor +'@objectstack/driver-sql': patch +--- + +feat(driver-turso): the remote transport issues `auto_number` values (#21113) + +Clause-②: yes (widening) + +A `create()`, `bulkCreate()` or `upsert()` on the Turso REMOTE transport that +leaves an `autonumber` field empty (`undefined`, `null` or `''`) now gets a +generated value. It used to be refused with `NOT_IMPLEMENTED` / 501, so on a +hosted tenant database — which is on this transport — no object declaring an +`auto_number` field could get a new record at all. Nothing is declared anew in +the spec or in the package exports; `supports.autonumber` stays `true` and is +now honoured. + +- The value comes from the same persistent `_objectstack_sequences` counter the + local and embedded-replica transports use, rendered by the same format rules + (`autonumberFormat` / `format`, organization scope, date and `{field}` + tokens), bootstrapped from the table's highest existing value by the same + reading, and re-seeded the same way after rows land above the counter by a + seed replay or import. A remote driver and an embedded replica of one + database draw from one counter row. +- The counter moves in one statement over the connection (`UPDATE … RETURNING`, + or on a cold counter `INSERT … ON CONFLICT (key_hash) DO UPDATE … RETURNING`), + so writers in different processes never draw the same number. +- An `upsert()` that merges into an existing row keeps the number already in + the row; a row that carries its own number is written unchanged. +- A `_objectstack_sequences` table in the pre-`key_hash` shape is refused in + remote mode with `DATABASE_ERROR` / 500 and the remedy in the message (open + the database once through the local or embedded-replica transport, which + migrates it); remote mode does not migrate it and does not key by the legacy + rule. +- `RemoteTransport.upsert()` takes an optional fifth argument naming columns + that are written on insert and left alone on merge. + +`@objectstack/driver-sql`: the sequence rules a second transport shares are +now `protected` members of `SqlDriver` (`resolveSequenceTenantId`, +`defineSequencesTable`, `maxAutonumberCounter`, `escapeLikePrefix`, +`sequencesTableName`, `autoNumberCollisionRetries`), and the +`AutoNumberReservation` type is exported. No behaviour changes on any dialect. diff --git a/packages/drivers/driver-turso/README.md b/packages/drivers/driver-turso/README.md index 313fc6615a8..cd21e141ad3 100644 --- a/packages/drivers/driver-turso/README.md +++ b/packages/drivers/driver-turso/README.md @@ -130,7 +130,6 @@ table lists the ones that answer differently from local mode. | Operation | Refused call | Use instead | |:---|:---|:---| | Transactions | `beginTransaction()`, `commit()`, `rollback()` (and their deprecated aliases `commitTransaction()` and `rollbackTransaction()`), and `options.transaction` passed to any `RemoteTransport` method in the tree above, to `aggregate()` or to `syncSchemasBatch()` | The local or embedded-replica transport, which run Knex transactions and honour `options.transaction` | -| Record numbers | `create()`, `bulkCreate()`, and an `upsert()` with no `id`, `_id` or `conflictKeys`, when a row leaves an `autonumber` field empty (`undefined`, `null` or `''`) | The local or embedded-replica transport, which generate record numbers, or a value you supply, which is written unchanged | | Deferring schema DDL | `setDeferredDdl(true)`, which `os migrate plan` calls. `setDeferredDdl(false)` is accepted | Run the command against a local SQLite copy of the database (a `file:` URL) | | Schema drift detection | `detectManagedDrift()` | `os migrate plan` against a local SQLite copy of the database (a `file:` URL) | | Planning the ADR-0104 media column move | `planMediaColumnMove()`, the column step of `os migrate files-to-references` | The local or embedded-replica transport, which plan it | @@ -147,18 +146,30 @@ table lists the ones that answer differently from local mode. reads that and does not call `beginTransaction()`: it runs the callback with no transaction and logs a warning once per datasource, or, called with `require: true`, throws `TransactionUnsupportedError` before the callback runs. -- **Record numbers.** Remote mode never generates one. The check uses the - `autonumber` fields this driver recorded when it synced the object's schema - (`syncSchema`, `syncSchemasBatch` or `initObjects`). An `upsert()` that - carries an `id`, `_id` or `conflictKeys` is not refused, because it may merge - into an existing row. If it inserts instead, the row is written with the field - empty and the driver logs a warning naming the row. - **Aggregation.** `aggregate()` called on the driver directly also refuses, with the same code, a `groupBy` entry that has a `dateGranularity`, and an `aggregations` entry with a non-empty `filter`. `engine.aggregate()` never sends either one to this driver: it fetches the rows and computes both in memory. The local transport also refuses a per-aggregation `filter`. +#### Record numbers in remote mode + +An `autonumber` field a row leaves empty (`undefined`, `null` or `''`) is +filled in remote mode as it is in local and embedded-replica mode: `create()`, +`bulkCreate()` and `upsert()` issue the next value from the persistent +`_objectstack_sequences` counter, rendered with the field's `autonumberFormat` +(or `format`) and scoped per organization, per date token and per `{field}` +token exactly as `SqlDriver` scopes it. The counter moves in one statement over +the connection (`UPDATE … RETURNING`; on a cold counter, a bootstrap from the +table's highest existing value followed by `INSERT … ON CONFLICT DO UPDATE … +RETURNING`), so two processes writing to one database never draw the same +number. A row that already carries a value keeps it (a seed replay or an +import); an `upsert()` that merges into an existing row keeps the number +already in the row. A `_objectstack_sequences` table in the legacy shape +(created by a local-mode driver older than the `key_hash` column) is refused +with `code: 'DATABASE_ERROR'` / `status: 500` and the remedy in the message; +remote mode does not migrate it. + Answered in remote mode, differently from local mode: - **`distinct()`** runs a `SELECT DISTINCT` on the remote database and answers diff --git a/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts b/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts index 1cd58047e56..ba81b9dc877 100644 --- a/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts @@ -38,8 +38,13 @@ * - Each writer's own sequence is strictly increasing: the counter is * gap-tolerant-monotonic. With no statement failing, the union is exactly * 1..N, and the file says so when it is. - * - The two writers really overlapped: neither's numbers form one contiguous - * block. (Had they run one after the other, the pin above would be vacuous.) + * - The two writers really overlapped: in the global order of the numbers, + * the writer changes at least twice — each wrote while the other still had + * writes pending. (One change is a serial run, A then B, which would make + * the distinct-count pin vacuous; a writer finishing a contiguous run INSIDE + * the other's run is still an overlap, and is what the scheduler does under + * load, so contiguity itself is not the criterion.) The measured number of + * changes is printed with the failure, and reported in the PR. * - The writers are different processes, and neither is this one. * * # Reverse verification — direction predicted BEFORE it was run @@ -65,7 +70,7 @@ const HERE = fileURLToPath(new URL('.', import.meta.url)); const PKG_ROOT = join(HERE, '..'); const DRIVER_ENTRY = new URL('./index.ts', import.meta.url).href; -const WRITES_PER_WORKER = 25; +const WRITES_PER_WORKER = 100; const WORKERS = ['A', 'B'] as const; const NUMBERED_OBJECT = { @@ -210,12 +215,18 @@ describe('[#21113] two writers in two processes draw distinct numbers from one d for (let i = 1; i < ints.length; i++) expect(ints[i], `writer ${r.tag} at ${i}`).toBeGreaterThan(ints[i - 1]); } - // They really overlapped: no writer's numbers form one contiguous block. - for (const r of reports) { - const ints = r.numbers.map(asInt); - const contiguous = ints[ints.length - 1] - ints[0] + 1 === ints.length; - expect(contiguous, `writer ${r.tag} ran alone: ${r.numbers[0]}..${r.numbers[r.numbers.length - 1]}`).toBe(false); - } + // They really overlapped: walking the numbers in order, the writer + // changes at least twice. A serial run (all of A, then all of B) changes + // once; a writer whose contiguous run sits inside the other's changes + // twice, and that is still both writers contending on one counter. + const owner = new Map(); + for (const r of reports) for (const n of r.numbers) owner.set(asInt(n), r.tag); + const inOrder = [...owner.keys()].sort((a, b) => a - b).map((n) => owner.get(n)!); + let switches = 0; + for (let i = 1; i < inOrder.length; i++) if (inOrder[i] !== inOrder[i - 1]) switches++; + expect(switches, `the writers ran one after the other (${switches} change(s) of writer across ${total} numbers)`).toBeGreaterThanOrEqual(2); + // The measurement, on the record: how hard the two writers contended. + process.stdout.write(`[turso-remote-autonumber-concurrency] pids ${pids.join('/')} · ${total} writes · ${new Set(all).size} distinct · ${switches} writer change(s)\n`); // And the database agrees: one row per number, one counter row at N. const rows = await parent.find('crm_case', { orderBy: [{ field: 'case_number', order: 'asc' }] } as any); From a8d87cfd1623bd0323ea6c4875664125ee4ab63e Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 1 Oct 2026 09:05:55 +0000 Subject: [PATCH 3/7] test(driver-turso): record the measured direction of the non-atomic ablation in the concurrency pin Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- ...urso-remote-autonumber-concurrency.test.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts b/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts index ba81b9dc877..0293408055a 100644 --- a/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts @@ -47,14 +47,27 @@ * changes is printed with the failure, and reported in the PR. * - The writers are different processes, and neither is this one. * - * # Reverse verification — direction predicted BEFORE it was run + * # Reverse verification — predicted BEFORE it was run, then measured * - * With the warm path split into two statements (read `last_value`, then write - * `last_value + 1`), two processes releasing on the barrier read the same - * value and write the same successor, so the distinct count drops below N. - * The window between the two statements is a native call wide, so the drop is - * probable rather than certain on a single run; the measured outcome, and the - * number of runs it took, are recorded in the PR. + * Predicted: with the warm path split into two statements (read `last_value`, + * then write `last_value + 1`), two processes releasing on the barrier read + * the same value and write the same successor, so the distinct count drops + * below N. + * + * Measured — a different red than predicted, kept rather than tidied: the + * duplicate reservations never reached the distinct-count pin, because the + * fixture's unique index (`uniq_crm_case_organization_id_case_number`, the + * tenant-scoped unique every autonumber this repo declares `unique` carries) + * refused the second row carrying the same number, and the #5495 re-seed — + * forward-only, to the observed MAX — could not outrun a writer that kept + * reading the same stale value, so after its retry budget the write FAILED. + * The red is on the "no write failed" pin, with `SQLITE_CONSTRAINT: UNIQUE + * constraint failed: index 'uniq_crm_case_organization_id_case_number'` in + * the writer's error list: 2 of 3 runs with no gap between the two + * statements, 3 of 3 with a 1 ms gap (the order of one HTTP round trip). So + * what this pin distinguishes is a counter that can hand out one number + * twice, and on a declared-unique column that surfaces as refused writes + * before it surfaces as duplicate rows. */ import { describe, it, expect, afterAll } from 'vitest'; From 20a25d847c2cf74c7b7e64293229d2f67c61ac3f Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 1 Oct 2026 10:07:10 +0000 Subject: [PATCH 4/7] test(driver-turso): type the option and query arguments in the new autonumber suites `bypassTenantAudit` is a declared DriverOptions key and the read-back order is a DriverQuery, so neither needs an `as any`; the query-options-erasure ratchet counted the one on the concurrency pin's find(). Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- AGENTS.md | 11 +++++++++++ .../turso-remote-autonumber-concurrency.test.ts | 4 +++- .../turso-remote-autonumber-generation.test.ts | 15 ++++++++------- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e9e211fc927..30792b6eb55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1106,3 +1106,14 @@ Both non-handshake shapes, and how to classify and probe your own: ## Edit Sizing Keep single `edit`/`create` payloads under ~20KB. Split larger changes into multiple sequential edits. + + + +# This is NOT the Turborepo you know + +Turborepo configuration, task behavior, and CLI commands can vary between installed versions and may differ from your training data. Resolve the `turbo` package from this file's directory or relevant workspace; in monorepos, it may not be visible from the repository root. For example, run `node -p "require.resolve('turbo/package.json')"` from a workspace that depends on `turbo`. + +Read `docs/README.md` inside that installed package first, then read the relevant pages from its `docs/` directory before changing Turborepo configuration or commands. Heed deprecation notices. These bundled docs match the installed package version and are available without network access. + +This block is written and re-added by `turbo` before repository-scoped commands when an AI agent is detected. In the Turborepo source repository, its template is defined in `crates/turborepo-cli/src/cli/agent_guidance.rs`. Removing the managed block while updates are enabled means a later qualifying invocation will add it again. Set `"agentGuidance": false` in the root `turbo.json` or `turbo.jsonc` to opt out; this does not remove an existing block. Keep the block committed with your work to avoid an uncommitted change on the next agent invocation. + diff --git a/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts b/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts index 0293408055a..e072f88b73c 100644 --- a/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts @@ -77,6 +77,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createClient } from '@libsql/client'; +import type { DriverQuery } from '@objectstack/spec/contracts'; import { TursoDriver } from './index.js'; const HERE = fileURLToPath(new URL('.', import.meta.url)); @@ -242,7 +243,8 @@ describe('[#21113] two writers in two processes draw distinct numbers from one d process.stdout.write(`[turso-remote-autonumber-concurrency] pids ${pids.join('/')} · ${total} writes · ${new Set(all).size} distinct · ${switches} writer change(s)\n`); // And the database agrees: one row per number, one counter row at N. - const rows = await parent.find('crm_case', { orderBy: [{ field: 'case_number', order: 'asc' }] } as any); + const byNumber: DriverQuery = { orderBy: [{ field: 'case_number', order: 'asc' }] }; + const rows = await parent.find('crm_case', byNumber); expect(rows.map((row) => row.case_number)).toEqual(expected); const counter = await parentClient.execute('select "last_value" from "_objectstack_sequences"'); expect(counter.rows).toHaveLength(1); diff --git a/packages/drivers/driver-turso/src/turso-remote-autonumber-generation.test.ts b/packages/drivers/driver-turso/src/turso-remote-autonumber-generation.test.ts index 87f108e8743..7b2855272fc 100644 --- a/packages/drivers/driver-turso/src/turso-remote-autonumber-generation.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-autonumber-generation.test.ts @@ -70,6 +70,7 @@ import { describe, it, expect, afterAll, afterEach, assert } from 'vitest'; import { createClient } from '@libsql/client'; +import type { DriverOptions } from '@objectstack/spec/data'; import { TursoDriver } from './index.js'; import { RemoteTransport } from './remote-transport.js'; import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; @@ -371,7 +372,7 @@ describe('[#6203] one semantics: the shared SqlDriver rules, called on both face it('numbers issued by the local face and by the remote face on the same database do not collide', async () => { const { local, remote } = await makeSharedPair([NUMBERED_OBJECT]); - const opts = { bypassTenantAudit: true } as any; + const opts: DriverOptions = { bypassTenantAudit: true }; const a = await local.create('crm_case', { organization_id: 'orgA', title: 'local 1' }, opts); const b = await remote.create('crm_case', { organization_id: 'orgA', title: 'remote 1' }); const c = await local.create('crm_case', { organization_id: 'orgA', title: 'local 2' }, opts); @@ -388,7 +389,7 @@ describe('[#6203] one semantics: the shared SqlDriver rules, called on both face it('the tenant bucket is the same rule: a different organization is a different counter on both faces', async () => { const { local, remote } = await makeSharedPair([NUMBERED_OBJECT]); - const opts = { bypassTenantAudit: true } as any; + const opts: DriverOptions = { bypassTenantAudit: true }; expect((await local.create('crm_case', { organization_id: 'orgA', title: 'a' }, opts)).case_number).toBe('CASE-00001'); expect((await remote.create('crm_case', { organization_id: 'orgB', title: 'b' })).case_number).toBe('CASE-00001'); expect((await local.create('crm_case', { organization_id: 'orgB', title: 'b2' }, opts)).case_number).toBe('CASE-00002'); @@ -421,7 +422,7 @@ describe('[#6203] one semantics: the shared SqlDriver rules, called on both face (e) => (e as Error).message, ); const onRemote = await message(() => remote.create('po', { po_no: '' })); - const onLocal = await message(() => local.create('po', { po_no: '' }, { bypassTenantAudit: true } as any)); + const onLocal = await message(() => local.create('po', { po_no: '' }, { bypassTenantAudit: true })); expect(onRemote).toContain('Cannot generate autonumber "po.po_no" (format "{dept}-{000}"): referenced field(s) [dept] are empty on the record.'); expect(onRemote).toBe(onLocal); @@ -538,7 +539,7 @@ describe('[#6203] the other faces are untouched', () => { const row = await local.create( 'crm_case', { organization_id: 'orgA', title: 'l' }, - { bypassTenantAudit: true } as any, + { bypassTenantAudit: true }, ); expect(row.case_number).toBe('CASE-00001'); }); @@ -551,7 +552,7 @@ describe('[#6203] the other faces are untouched', () => { { organization_id: 'orgA', title: 'b1' }, { organization_id: 'orgA', title: 'b2' }, ], - { bypassTenantAudit: true } as any, + { bypassTenantAudit: true }, ); expect((rows as any[]).map((r) => r.case_number)).toEqual(['CASE-00001', 'CASE-00002']); @@ -559,7 +560,7 @@ describe('[#6203] the other faces are untouched', () => { 'crm_case', { organization_id: 'orgA', title: 'u' }, undefined, - { bypassTenantAudit: true } as any, + { bypassTenantAudit: true }, ); expect(upserted.case_number).toBe('CASE-00003'); }); @@ -569,7 +570,7 @@ describe('[#6203] the other faces are untouched', () => { const row = await replica.create( 'crm_case', { organization_id: 'orgA', title: 'r' }, - { bypassTenantAudit: true } as any, + { bypassTenantAudit: true }, ); expect(row.case_number).toBe('CASE-00001'); }); From 78fea133439486284fe8bc855d60f1d49a55ed50 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 1 Oct 2026 10:07:49 +0000 Subject: [PATCH 5/7] =?UTF-8?q?chore:=20restore=20AGENTS.md=20to=20the=20m?= =?UTF-8?q?erge-base=20content=20=E2=80=94=20a=20gate=20run=20rewrote=20it?= =?UTF-8?q?=20in=20the=20working=20tree=20and=20a=20blanket=20add=20carrie?= =?UTF-8?q?d=20it=20into=20the=20previous=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- AGENTS.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 30792b6eb55..e9e211fc927 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1106,14 +1106,3 @@ Both non-handshake shapes, and how to classify and probe your own: ## Edit Sizing Keep single `edit`/`create` payloads under ~20KB. Split larger changes into multiple sequential edits. - - - -# This is NOT the Turborepo you know - -Turborepo configuration, task behavior, and CLI commands can vary between installed versions and may differ from your training data. Resolve the `turbo` package from this file's directory or relevant workspace; in monorepos, it may not be visible from the repository root. For example, run `node -p "require.resolve('turbo/package.json')"` from a workspace that depends on `turbo`. - -Read `docs/README.md` inside that installed package first, then read the relevant pages from its `docs/` directory before changing Turborepo configuration or commands. Heed deprecation notices. These bundled docs match the installed package version and are available without network access. - -This block is written and re-added by `turbo` before repository-scoped commands when an AI agent is detected. In the Turborepo source repository, its template is defined in `crates/turborepo-cli/src/cli/agent_guidance.rs`. Removing the managed block while updates are enabled means a later qualifying invocation will add it again. Set `"agentGuidance": false` in the root `turbo.json` or `turbo.jsonc` to opt out; this does not remove an existing block. Keep the block committed with your work to avoid an uncommitted change on the next agent invocation. - From 2c9d2985a067984cfb97997d73757e9f38835315 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 1 Oct 2026 10:25:04 +0000 Subject: [PATCH 6/7] test(driver-turso): the cross-process pin runs rounds and declares an un-overlapped run NOT MEASURED Whether two processes overlap is the scheduler's decision: under another seat's full-package run one writer wrote all of its numbers before the other was scheduled past the barrier. The hard pins (distinct, complete, monotonic, no failed write) hold on every round; the overlap evidence ends the loop on the first round that shows it, and a run with none is skipped with a note rather than failed or passed. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- ...urso-remote-autonumber-concurrency.test.ts | 195 ++++++++++-------- 1 file changed, 111 insertions(+), 84 deletions(-) diff --git a/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts b/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts index e072f88b73c..953bbb17f39 100644 --- a/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-autonumber-concurrency.test.ts @@ -38,15 +38,23 @@ * - Each writer's own sequence is strictly increasing: the counter is * gap-tolerant-monotonic. With no statement failing, the union is exactly * 1..N, and the file says so when it is. - * - The two writers really overlapped: in the global order of the numbers, - * the writer changes at least twice — each wrote while the other still had - * writes pending. (One change is a serial run, A then B, which would make - * the distinct-count pin vacuous; a writer finishing a contiguous run INSIDE - * the other's run is still an overlap, and is what the scheduler does under - * load, so contiguity itself is not the criterion.) The measured number of - * changes is printed with the failure, and reported in the PR. * - The writers are different processes, and neither is this one. * + * And the EVIDENCE that the pins above were not vacuous: in the global order + * of the numbers, the writer changes at least twice — each wrote while the + * other still had writes pending. (One change is a serial run, A then B; a + * writer finishing a contiguous run INSIDE the other's run is still an + * overlap, and is what the scheduler does under load, so contiguity is not + * the criterion.) Whether two processes overlap is the box's decision, not + * the code's: measured on a shared box under another seat's full-package run, + * one process wrote all of its numbers before the other was scheduled past the + * barrier (1 change). So the overlap is not asserted on a single round. Up to + * MAX_ROUNDS rounds run, each on a fresh database with every hard pin above + * asserted; the first round that overlapped ends the loop and prints its + * measurement. If no round overlapped, the test is SKIPPED with a note that + * says NOT MEASURED — visible in the run's skipped count, never read as green + * — rather than failing on what the scheduler did or passing on a serial run. + * * # Reverse verification — predicted BEFORE it was run, then measured * * Predicted: with the warm path split into two statements (read `last_value`, @@ -84,7 +92,9 @@ const HERE = fileURLToPath(new URL('.', import.meta.url)); const PKG_ROOT = join(HERE, '..'); const DRIVER_ENTRY = new URL('./index.ts', import.meta.url).href; -const WRITES_PER_WORKER = 100; +const WRITES_PER_WORKER = 200; +/** Rounds a loaded box gets to let the two writers overlap before the overlap evidence is declared NOT MEASURED. */ +const MAX_ROUNDS = 3; const WORKERS = ['A', 'B'] as const; const NUMBERED_OBJECT = { @@ -173,82 +183,99 @@ function runWorker(env: Record): Promise { const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +/** + * One round: a fresh database, both writers, the barrier, every hard pin. + * Answers how many times the writer changed in the global order. + */ +async function round(): Promise { + const dir = mkdtempSync(join(tmpdir(), 'turso-autonumber-concurrency-')); + dirs.push(dir); + const dbPath = join(dir, 'shared.db'); + + // The parent creates the schema through its own remote-mode driver, so + // both writers find the tables and the unique index already there. + const parentClient = createClient({ url: `file:${dbPath}`, timeout: 20000 }); + const parent = new TursoDriver({ url: 'libsql://concurrency.probe', client: parentClient }); + await parent.connect(); + await parent.initObjects([NUMBERED_OBJECT as any]); + + // A writer that dies before the barrier surfaces as ITS error, not as a + // readiness timeout. + let died: Error | null = null; + const runs = WORKERS.map((tag) => + runWorker({ OS_TEST_DB: dbPath, OS_TEST_DIR: dir, OS_TEST_TAG: tag, OS_TEST_WRITES: String(WRITES_PER_WORKER) }) + .catch((e: Error) => { died ??= e; throw e; }), + ); + // Release both only once both are connected and registered, so the + // writes contend rather than queue behind process start-up. + const deadline = Date.now() + 60_000; + while (!WORKERS.every((tag) => existsSync(join(dir, `ready-${tag}`)))) { + if (died) throw died; + if (Date.now() > deadline) throw new Error('writers did not become ready within 60s'); + await sleep(5); + } + writeFileSync(join(dir, 'go'), '1'); + const reports = await Promise.all(runs); + + // Different processes, and not this one. + const pids = reports.map((r) => r.pid); + expect(new Set(pids).size).toBe(WORKERS.length); + expect(pids).not.toContain(process.pid); + + // No write failed — so the union below is the whole 1..N and nothing was + // burned. (A failure would show here with its message, not as a gap.) + for (const r of reports) expect(r.errors, `writer ${r.tag} errors`).toEqual([]); + for (const r of reports) expect(r.numbers).toHaveLength(WRITES_PER_WORKER); + + // N distinct numbers across both writers. + const all = reports.flatMap((r) => r.numbers); + const total = WORKERS.length * WRITES_PER_WORKER; + expect(new Set(all).size).toBe(total); + expect(all.every((n) => /^CASE-\d{5}$/.test(n))).toBe(true); + const expected = Array.from({ length: total }, (_, i) => `CASE-${String(i + 1).padStart(5, '0')}`); + expect([...all].sort()).toEqual(expected); + + // Monotonic within each writer. + const asInt = (n: string) => Number(n.slice('CASE-'.length)); + for (const r of reports) { + const ints = r.numbers.map(asInt); + for (let i = 1; i < ints.length; i++) expect(ints[i], `writer ${r.tag} at ${i}`).toBeGreaterThan(ints[i - 1]); + } + + // And the database agrees: one row per number, one counter row at N. + const byNumber: DriverQuery = { orderBy: [{ field: 'case_number', order: 'asc' }] }; + const rows = await parent.find('crm_case', byNumber); + expect(rows.map((row) => row.case_number)).toEqual(expected); + const counter = await parentClient.execute('select "last_value" from "_objectstack_sequences"'); + expect(counter.rows).toHaveLength(1); + expect(Number(counter.rows[0].last_value)).toBe(total); + await parent.disconnect(); + + // The overlap evidence: walking the numbers in order, how often the writer + // changed. A serial run (all of A, then all of B) changes once; a writer + // whose contiguous run sits inside the other's changes twice, and that is + // still both writers contending on one counter. + const owner = new Map(); + for (const r of reports) for (const n of r.numbers) owner.set(asInt(n), r.tag); + const inOrder = [...owner.keys()].sort((a, b) => a - b).map((n) => owner.get(n)!); + let switches = 0; + for (let i = 1; i < inOrder.length; i++) if (inOrder[i] !== inOrder[i - 1]) switches++; + process.stdout.write(`[turso-remote-autonumber-concurrency] pids ${pids.join('/')} · ${total} writes · ${new Set(all).size} distinct · ${switches} writer change(s)\n`); + return switches; +} + describe('[#21113] two writers in two processes draw distinct numbers from one database', () => { - it(`${WORKERS.length} x ${WRITES_PER_WORKER} creates -> ${WORKERS.length * WRITES_PER_WORKER} distinct, monotonic per writer, interleaved`, async () => { - const dir = mkdtempSync(join(tmpdir(), 'turso-autonumber-concurrency-')); - dirs.push(dir); - const dbPath = join(dir, 'shared.db'); - - // The parent creates the schema through its own remote-mode driver, so - // both writers find the tables and the unique index already there. - const parentClient = createClient({ url: `file:${dbPath}`, timeout: 20000 }); - const parent = new TursoDriver({ url: 'libsql://concurrency.probe', client: parentClient }); - await parent.connect(); - await parent.initObjects([NUMBERED_OBJECT as any]); - - // A writer that dies before the barrier surfaces as ITS error, not as a - // readiness timeout. - let died: Error | null = null; - const runs = WORKERS.map((tag) => - runWorker({ OS_TEST_DB: dbPath, OS_TEST_DIR: dir, OS_TEST_TAG: tag, OS_TEST_WRITES: String(WRITES_PER_WORKER) }) - .catch((e: Error) => { died ??= e; throw e; }), - ); - // Release both only once both are connected and registered, so the - // writes contend rather than queue behind process start-up. - const deadline = Date.now() + 60_000; - while (!WORKERS.every((tag) => existsSync(join(dir, `ready-${tag}`)))) { - if (died) throw died; - if (Date.now() > deadline) throw new Error('writers did not become ready within 60s'); - await sleep(5); - } - writeFileSync(join(dir, 'go'), '1'); - const reports = await Promise.all(runs); - - // Different processes, and not this one. - const pids = reports.map((r) => r.pid); - expect(new Set(pids).size).toBe(WORKERS.length); - expect(pids).not.toContain(process.pid); - - // No write failed — so the union below is the whole 1..N and nothing was - // burned. (A failure would show here with its message, not as a gap.) - for (const r of reports) expect(r.errors, `writer ${r.tag} errors`).toEqual([]); - for (const r of reports) expect(r.numbers).toHaveLength(WRITES_PER_WORKER); - - // N distinct numbers across both writers. - const all = reports.flatMap((r) => r.numbers); - const total = WORKERS.length * WRITES_PER_WORKER; - expect(new Set(all).size).toBe(total); - expect(all.every((n) => /^CASE-\d{5}$/.test(n))).toBe(true); - const expected = Array.from({ length: total }, (_, i) => `CASE-${String(i + 1).padStart(5, '0')}`); - expect([...all].sort()).toEqual(expected); - - // Monotonic within each writer. - const asInt = (n: string) => Number(n.slice('CASE-'.length)); - for (const r of reports) { - const ints = r.numbers.map(asInt); - for (let i = 1; i < ints.length; i++) expect(ints[i], `writer ${r.tag} at ${i}`).toBeGreaterThan(ints[i - 1]); + it(`${WORKERS.length} x ${WRITES_PER_WORKER} creates -> ${WORKERS.length * WRITES_PER_WORKER} distinct, monotonic per writer, and the writers overlapped`, async (ctx) => { + const observed: number[] = []; + for (let i = 0; i < MAX_ROUNDS; i++) { + const switches = await round(); + observed.push(switches); + if (switches >= 2) return; } - - // They really overlapped: walking the numbers in order, the writer - // changes at least twice. A serial run (all of A, then all of B) changes - // once; a writer whose contiguous run sits inside the other's changes - // twice, and that is still both writers contending on one counter. - const owner = new Map(); - for (const r of reports) for (const n of r.numbers) owner.set(asInt(n), r.tag); - const inOrder = [...owner.keys()].sort((a, b) => a - b).map((n) => owner.get(n)!); - let switches = 0; - for (let i = 1; i < inOrder.length; i++) if (inOrder[i] !== inOrder[i - 1]) switches++; - expect(switches, `the writers ran one after the other (${switches} change(s) of writer across ${total} numbers)`).toBeGreaterThanOrEqual(2); - // The measurement, on the record: how hard the two writers contended. - process.stdout.write(`[turso-remote-autonumber-concurrency] pids ${pids.join('/')} · ${total} writes · ${new Set(all).size} distinct · ${switches} writer change(s)\n`); - - // And the database agrees: one row per number, one counter row at N. - const byNumber: DriverQuery = { orderBy: [{ field: 'case_number', order: 'asc' }] }; - const rows = await parent.find('crm_case', byNumber); - expect(rows.map((row) => row.case_number)).toEqual(expected); - const counter = await parentClient.execute('select "last_value" from "_objectstack_sequences"'); - expect(counter.rows).toHaveLength(1); - expect(Number(counter.rows[0].last_value)).toBe(total); - await parent.disconnect(); - }, 120_000); + ctx.skip( + `NOT MEASURED: in ${MAX_ROUNDS} round(s) the two processes never overlapped on this box ` + + `(writer changes per round: ${observed.join(', ')}); every round's numbers were distinct and complete, ` + + `but a serial run cannot vouch for the atomic statement, so this is a skip, not a pass.`, + ); + }, 300_000); }); From 5d09309a65a19d50631d0a349e0e002b1129d6b8 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 1 Oct 2026 11:17:51 +0000 Subject: [PATCH 7/7] fix(driver-sql,driver-turso): no root export for the reservation type; the overrides read it off the protected members Review 5930100091: a root type re-export levels driver-sql at minor, and patch was declared. The re-export is dropped and the two re-seed overrides on the remote face take their parameter types through Parameters indexed over SqlDriver's protected members, so driver-sql's export list is unchanged and patch stands. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- .changeset/21113-turso-remote-autonumber.md | 4 ++-- packages/drivers/driver-sql/src/index.ts | 6 ------ packages/drivers/driver-turso/src/turso-driver.ts | 7 ++++--- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/.changeset/21113-turso-remote-autonumber.md b/.changeset/21113-turso-remote-autonumber.md index e144d85fd08..c62bb3b2daf 100644 --- a/.changeset/21113-turso-remote-autonumber.md +++ b/.changeset/21113-turso-remote-autonumber.md @@ -38,5 +38,5 @@ now honoured. `@objectstack/driver-sql`: the sequence rules a second transport shares are now `protected` members of `SqlDriver` (`resolveSequenceTenantId`, `defineSequencesTable`, `maxAutonumberCounter`, `escapeLikePrefix`, -`sequencesTableName`, `autoNumberCollisionRetries`), and the -`AutoNumberReservation` type is exported. No behaviour changes on any dialect. +`sequencesTableName`, `autoNumberCollisionRetries`). No export is added and no +behaviour changes on any dialect. diff --git a/packages/drivers/driver-sql/src/index.ts b/packages/drivers/driver-sql/src/index.ts index 646e2804b87..cb0a71d2536 100644 --- a/packages/drivers/driver-sql/src/index.ts +++ b/packages/drivers/driver-sql/src/index.ts @@ -44,12 +44,6 @@ export type { // checking with it. SqlWindowFunctionSpec, SqlWindowFunctionQuery, - // The autonumber reservation a write draws (#5495): what `fillAutoNumberFields` - // reports and the collision re-seed consumes. Exported so a subclass that - // issues numbers over another transport (`TursoDriver`'s remote face) can - // override the re-seed primitives with their declared parameter type instead - // of a structural copy of it. - AutoNumberReservation, } from './sql-driver.js'; // [#5222] The cross-field `{ $field }` push-down conformance corpus: one diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 9d5efdce452..63fb031cb97 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -31,7 +31,6 @@ import { SqlDriver, GLOBAL_TENANT, - type AutoNumberReservation, type IntrospectedSchema, type ManagedDriftEntry, type SqlDriverConfig, @@ -2536,7 +2535,7 @@ export class TursoDriver extends SqlDriver { */ protected override async autoNumberValueExists( queryRunner: Parameters[0], - reservation: AutoNumberReservation, + reservation: Parameters[1], ): Promise { if (!this.isRemote) return super.autoNumberValueExists(queryRunner, reservation); const col = quoteSequenceName(reservation.field); @@ -2557,7 +2556,9 @@ export class TursoDriver extends SqlDriver { * same "never rewind" rule the inherited version applies in its transaction, * without needing one. */ - protected override async resyncSequenceToDataMax(reservation: AutoNumberReservation): Promise { + protected override async resyncSequenceToDataMax( + reservation: Parameters[0], + ): Promise { if (!this.isRemote) return super.resyncSequenceToDataMax(reservation); await this.ensureRemoteSequencesTable(); const resolvedTenantId = this.resolveSequenceTenantId(reservation.tenantField, reservation.tenantId);