diff --git a/.changeset/turso-remote-autonumber-refusal.md b/.changeset/turso-remote-autonumber-refusal.md new file mode 100644 index 0000000000..a6835f2dba --- /dev/null +++ b/.changeset/turso-remote-autonumber-refusal.md @@ -0,0 +1,75 @@ +--- +"@objectstack/driver-turso": minor +--- + +fix(driver-turso): the remote face refuses an `auto_number` create instead of silently writing NULL (#6944) + +`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` — so on that face `auto_number` was only a column mapped +to `TEXT`, and the slot the engine deliberately leaves empty stayed empty. +Measured on `main` @ `2f3e79351`: + +``` +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 caught it. `supports.autonumber` is `true` on this face +(inherited via `...super.supports`), so the engine defers generation to the +driver entirely and never runs its own fallback — `engine.ts` already records +`driver-turso` in its driver table as "inherited, no fallback path". A driver +face that boots and quietly fails to deliver a declared capability is the shape +#3724 ruled on; triage applied that ruling here on 2026-08-09 as **disposition +B — explicit refusal**. Implementing autonumber on the remote transport (A) +stays deferred for want of measured demand. + +## What changed + +`TursoDriver.create` / `bulkCreate` / `upsert` now refuse, in remote mode, a +write that would need a record number this face cannot issue: + +``` +NOT_IMPLEMENTED / 501 +Object "crm_case" declares auto_number field(s) [case_number] left empty for +this create, and the Turso REMOTE transport does not generate record numbers. … +``` + +`NOT_IMPLEMENTED` / 501 is the same class this package already gives an +aggregate function it cannot compile (#5907) or a date bucket it cannot emit +(#6212), for the same reason and per ADR-0112: `autonumber` is a field type +`@objectstack/spec` declares and this very driver's other faces generate, so the +caller's object definition is correct and the gap is the backend's. + +The refusal is raised on `TursoDriver`, not inside `RemoteTransport`, because +the transport cannot see what it would need in order to decide: +`RemoteTransport.create(object, data)` takes no schema and caches none. The +driver can — `registerRemoteFieldMetadata` → `registerExternalObject` classifies +every field at remote schema-sync time and populates `autoNumberFields`, +measured live in remote mode. + +## What is deliberately NOT refused + +- **A record that already carries a value in the slot.** That is the `isSystem` + seed replay and the `preserveAudit` historical import, which the engine + exempts from its strip on purpose (#5503); on this face they were, and remain, + written through unchanged and correctly. The generate predicate + (`undefined` / `null` / `''`) is `fillAutoNumberFields`' own, reused rather + than re-derived. +- **A merging upsert.** `RemoteTransport.upsert` emits + `INSERT … ON CONFLICT DO UPDATE`, so a row that matches keeps the number + already in its column — measured. Only the provably-inserting shape (no `id`, + no explicit conflict keys, so the transport mints a fresh id for the sole + merge key) is refused. An id- or conflict-key-bearing upsert that turns out to + insert is a known residue: classifying it needs the round trip the refusal + exists to avoid, and it is pinned as such in the suite. +- **Local and embedded-replica.** Both still generate; pinned across all three + faces so the refusal cannot leak onto the wrong one (#6203). + +`packages/drivers/driver-turso` only. `driver-memory` / `driver-mongodb` sit +inside the #5499 freeze and declare no `supports.autonumber`; they take the +engine's own fallback path and are unrelated. 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 9c2177006a..852a87e8aa 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 @@ -18,14 +18,35 @@ * - **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 absence is + * 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 is #6944's, not this card's.) + * (That gap was #6944's, not this card's — and #6944 has since answered it.) + * + * # ⚠️ [#6944] The pin below was rewritten, not 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. + * + * 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`. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TursoDriver } from './index.js'; +import { makeLibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; describe('[#6943] TursoDriver batch/upsert autonumber re-seed', () => { let driver: TursoDriver; @@ -110,18 +131,54 @@ describe('[#6943] TursoDriver batch/upsert autonumber re-seed', () => { expect(merged.title).toBe('edited'); }); - it('REMOTE: the transport that bypasses this path has no autonumber machinery to re-seed', async () => { + it('REMOTE: the transport that bypasses this path still has no autonumber machinery to re-seed', 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. When one is added (#6944), - // this assertion is what has to be revisited. + // 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. const transportSurface = Object.getOwnPropertyNames( Object.getPrototypeOf((remote as any).remoteTransport), ); expect(transportSurface).toContain('bulkCreate'); 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 () => { + const remote = new TursoDriver({ + url: 'libsql://example.turso.io', + client: makeLibsqlSqliteStub() as never, + }); + await remote.connect(); + await remote.initObjects([ + { + name: 'crm_case', + fields: { + organization_id: { type: 'string' }, + case_number: { type: 'autonumber', format: 'CASE-{00000}', unique: true }, + title: { type: 'string' }, + }, + } 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); + + 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 de1970f933..cd388a68c5 100644 --- a/packages/drivers/driver-turso/src/turso-autonumber-resync.test.ts +++ b/packages/drivers/driver-turso/src/turso-autonumber-resync.test.ts @@ -16,13 +16,38 @@ * closes: on that face `auto_number` is only a column-type mapping * (`remote-transport.ts` maps it to `TEXT`) and no sequence machinery exists * to be stale. It is stated here so the boundary is on the record, and the - * absence is pinned as an ASSERTION rather than a comment, so that wiring + * boundary is pinned as an ASSERTION rather than a comment, so that wiring * autonumber into the remote transport later cannot silently inherit this * file's green. + * + * # ⚠️ [#6944] What that pin says now — rewritten, not 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. + * + * So the shape moved from ABSENT to EXPLICITLY REFUSED, and this pin had two + * wrong options and one right one: + * + * - DELETE it — and lose the only guard that stops a future 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. + * + * 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. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TursoDriver } from './index.js'; +import { makeLibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; describe('[#5495] TursoDriver autonumber re-seed', () => { let driver: TursoDriver; @@ -65,16 +90,55 @@ describe('[#5495] TursoDriver autonumber re-seed', () => { expect(created.case_number).toBe('CASE-00031'); }); - it('REMOTE: the transport that bypasses this path has no autonumber machinery to re-seed', async () => { + it('REMOTE: the transport that bypasses this path still has no autonumber machinery to re-seed', 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. When one - // is added, this assertion is the thing that has to be revisited. + // 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. 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. + const remote = new TursoDriver({ + url: 'libsql://example.turso.io', + client: makeLibsqlSqliteStub() as never, + }); + await remote.connect(); + await remote.initObjects([ + { + name: 'crm_case', + fields: { + organization_id: { type: 'string' }, + case_number: { type: 'autonumber', format: 'CASE-{00000}', unique: true }, + title: { type: 'string' }, + }, + } 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); + + await remote.disconnect(); + }); }); diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index bef5c0e042..18ac756d79 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -20,6 +20,7 @@ */ import { SqlDriver, type SqlDriverConfig } from '@objectstack/driver-sql'; +import { StandardErrorCode } from '@objectstack/spec/api'; import type { DriverQuery } from '@objectstack/spec/contracts'; import type { DriverOptions } from '@objectstack/spec/data'; import type { Client } from '@libsql/client'; @@ -126,6 +127,87 @@ export interface TursoDriverConfig { client?: Client; } +// ── Autonumber on the remote face ──────────────────────────────────────────── + +/** + * [#6944] A record number this face cannot issue — refused, not written as NULL. + * + * # What was measured, and where the knowledge lives + * + * `TursoDriver` picks its transport from `url`. Local and embedded-replica + * 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' }`: + * + * ``` + * create -> RESOLVED case_number=null + * bulkCreate -> RESOLVED [null, null] + * upsert -> RESOLVED case_number=null + * LOCAL create-> RESOLVED case_number="CASE-00001" + * ``` + * + * 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 + * "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: + * `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. + * + * # 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. + * + * # What is deliberately NOT refused + * + * 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). + */ +function refuseRemoteAutonumber(object: string, fields: string[], path: 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). Until this change the same call RESOLVED and wrote NULL into the slot (#6944).`, + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.NOT_IMPLEMENTED; + err.status = 501; + throw err; +} + // ── Turso Driver ───────────────────────────────────────────────────────────── /** @@ -531,8 +613,51 @@ export class TursoDriver extends SqlDriver { // yielding — the opposite of the memory guarantee it was declared for. This // override went with the base method; page `find()` with `limit`/`offset`. + /** + * [#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. + */ + 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) { + // `fillAutoNumberFields`'s own generate predicate, verbatim: a slot + // holding undefined / null / '' is one the driver would have had to + // fill. + const held = row[cfg.name]; + if (held === undefined || held === null || held === '') empty.add(cfg.name); + } + } + if (empty.size > 0) refuseRemoteAutonumber(object, [...empty], path); + } + override async create(object: string, data: Record, options?: DriverOptions): Promise { - if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.create(object, this.toRemoteWriteForms(object, data))); + if (this.isRemote) { + this.refuseUngeneratableRemoteAutonumber(object, [data], 'create'); + return this.formatRemoteRow(object, await this.remoteTransport!.create(object, this.toRemoteWriteForms(object, data))); + } return super.create(object, data, options); } @@ -542,7 +667,30 @@ export class TursoDriver extends SqlDriver { } override async upsert(object: string, data: Record, conflictKeys?: string[], options?: DriverOptions): Promise> { - if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.upsert(object, this.toRemoteWriteForms(object, data), conflictKeys)); + if (this.isRemote) { + // [#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. + // + // ⚠️ Residue, stated rather than papered over: 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. Classifying it needs the round trip + // this refusal exists to avoid, so it is left as a known gap of the same + // deferred half (A) rather than answered with a probe query. + const mayMerge = data?.id !== undefined || data?._id !== undefined + || (Array.isArray(conflictKeys) && conflictKeys.length > 0); + if (!mayMerge) this.refuseUngeneratableRemoteAutonumber(object, [data], 'upsert'); + return this.formatRemoteRow(object, await this.remoteTransport!.upsert(object, this.toRemoteWriteForms(object, data), conflictKeys)); + } return super.upsert(object, data, conflictKeys, options); } @@ -969,6 +1117,13 @@ export class TursoDriver extends SqlDriver { override async bulkCreate(object: string, data: any[], options?: DriverOptions): Promise { if (this.isRemote) { + // [#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)); } 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 new file mode 100644 index 0000000000..f6d973e431 --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-remote-autonumber-refusal.test.ts @@ -0,0 +1,392 @@ +// 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. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { TursoDriver } from './index.js'; +import { RemoteTransport } from './remote-transport.js'; +import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; + +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: ':memory:', + 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) => + (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' }); + 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('[known residue, not fixed] an id-bearing upsert that INSERTS still writes NULL', async () => { + // Stated as an assertion rather than a comment so it cannot quietly become + // untrue in either direction. `RemoteTransport.upsert` emits + // `INSERT … ON CONFLICT DO UPDATE`, and whether that statement merges or + // inserts is knowable only after it runs — a round trip this refusal exists + // to avoid. So the leg stays open, on the same deferred half (A) as the rest + // of remote autonumber generation. + const { driver } = await makeRemote(); + const inserted = await driver.upsert('crm_case', { + id: 'never-seen', + organization_id: 'orgA', + title: 'inserted by upsert', + }); + expect(inserted.case_number).toBeNull(); + }); +}); + +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); + }); +});