From 7651ac296453e823939750b1ca6a549b62b7d711 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 14:51:30 +0000 Subject: [PATCH 1/3] fix(driver-turso): refuse remote schema drift detection instead of answering "no drift" A remote TursoDriver inherited SqlDriver.detectManagedDrift, which reads the physical schema through the placeholder :memory: Knex connection remote mode is built with. Every table read as absent, so the answer was [] for every remote database, and the artifact-pinned boot gate of `os serve` read a remote Turso database as never drifted. The remote face now refuses with the transport's NOT_IMPLEMENTED/501 envelope, with or without explicit objects. The boot gate already reports a failed detection as a warning and continues the boot, so a remote boot is told the schema was not checked instead of being told nothing. Local and replica modes are unchanged. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude --- ...45-turso-remote-drift-detection-refusal.md | 19 ++ .../drivers/driver-turso/src/turso-driver.ts | 83 +++++++++ ...rso-remote-drift-detection-refusal.test.ts | 175 ++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 .changeset/19845-turso-remote-drift-detection-refusal.md create mode 100644 packages/drivers/driver-turso/src/turso-remote-drift-detection-refusal.test.ts diff --git a/.changeset/19845-turso-remote-drift-detection-refusal.md b/.changeset/19845-turso-remote-drift-detection-refusal.md new file mode 100644 index 0000000000..0346af36b8 --- /dev/null +++ b/.changeset/19845-turso-remote-drift-detection-refusal.md @@ -0,0 +1,19 @@ +--- +'@objectstack/driver-turso': minor +--- + +fix(driver-turso): a REMOTE `TursoDriver` refuses to detect schema drift instead of answering that there is none (#19845) + +Clause-②: no (narrowing) + +**BREAKING for callers that read schema drift from a remote Turso datasource** — `TursoDriver.detectManagedDrift()` in `remote` transport mode (a `libsql://`, `https://`, `http://`, `wss://` or `ws://` URL with no `syncUrl`) now throws a `NOT_IMPLEMENTED` / `501` error, with or without an explicit object list, where it used to answer `[]`. The `local` and `replica` modes detect drift exactly as before. + +What the refusal replaces, measured on the transport's SQLite-backed test double: the inherited detector reads the physical schema through Knex, and a remote driver's Knex connection is a placeholder in-memory database holding none of the datasource's tables. A synced table carrying an extra physical column the declaration omits therefore read `unmapped_column` / `drop_column` on the local face and `[]` on the remote one. The artifact-pinned boot gate of `os serve` (`OS_ARTIFACT_URL`), which refuses a boot on destructive drift, read that `[]` as "never drifted" and let every remote-Turso boot through. + +- **The boot gate now says it could not check.** It already treats a failed drift detection as "the check did not run": it prints a warning carrying the driver's message and the boot continues. A remote-Turso boot is therefore not refused by this change; it is told the schema was not checked, where before it was told nothing. +- **No other caller in this repository reaches it.** The `os migrate` commands that read drift (`plan`, `apply`, `multi-value-columns`) arm deferred schema DDL first, which the remote face already refuses. +- **No new error code.** `NOT_IMPLEMENTED` / `501` is a standard code, the envelope this transport already uses for its remote transaction, auto-number and deferred-DDL refusals. + +**If you are refused:** to check a remote Turso database for drift, run `os migrate plan` against a local SQLite copy of it (a `file:` URL), where the physical schema is introspected. + + diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index b33d3987b0..a6c625adfa 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -403,6 +403,74 @@ function refuseRemoteDeferredDdl(): never { throw err; } +// ── Remote schema drift detection: refused, never "no drift" ───────────────── + +/** + * [#19845] The Turso REMOTE face cannot detect schema drift, and now says so + * instead of answering that there is none. + * + * # The defect this replaces + * + * `SqlDriver.detectManagedDrift` reads the physical schema through Knex: a + * `hasTable` probe per table, then column and index introspection fed to the + * shared differ. In remote mode that Knex instance is the placeholder + * `:memory:` database {@link TursoDriver.toKnexConfig} hands the base + * constructor. It holds none of this datasource's tables, so every table was + * skipped as absent and the answer was `[]` whatever the remote database held. + * The no-argument call had a second reason to answer `[]`: it iterates + * `managedObjectFields`, which only the Knex `initObjects` fills and no remote + * schema door reaches. Measured on the transport's SQLite-backed double + * (`turso-remote-drift-detection-refusal.test.ts`): a synced table carrying an + * extra physical column the declaration omits reads `unmapped_column` / + * `drop_column` on the local face and `[]` on the remote one, with or without + * explicit objects. The artifact-pinned boot gate of `os serve`, whose job is + * to refuse a boot on destructive drift, therefore let every remote-Turso boot + * through as never drifted. + * + * # Why a refusal rather than an implementation + * + * The shared differ would serve a remote table: a clean remote-synced table, + * judged through a local Knex connection to the same SQLite file, reports no + * entries, as the local face does. But every read that feeds the differ goes + * through `this.knex` (table existence, column facts and order, the index set, + * the NULL-safe duplicate probe), so a remote implementation is a second copy + * of each of those SQLite arms. It also needs a remote answer for + * `applyMigrationEntries`, which the gate calls on whatever it finds and which + * runs on the same placeholder. Until that exists the refusal is the honest + * answer, in the envelope and for the reason {@link refuseRemoteDeferredDdl} + * records for its sibling gap on this transport: the call is spelled correctly + * and the base class declares it, so the gap is the backend's. + * `NOT_IMPLEMENTED`/501 is a {@link StandardErrorCode} member, so there is no + * new code. + * + * # What a caller sees + * + * The boot gate already has a channel for "the check did not run": a throw + * from `detectManagedDrift` becomes a warning carrying this message, and the + * boot continues. That is the right reading of a driver that cannot judge. It + * is neither a drift verdict that would refuse every remote boot nor a + * silence. The `os migrate` commands that read drift never get this far on a + * remote datasource, because they arm deferred DDL first and that is refused. + */ +function refuseRemoteDriftDetection(): never { + const err = new Error( + 'Schema drift detection is not supported by the Turso REMOTE transport (this datasource\'s ' + + 'transport mode is `remote`), so this driver cannot say whether the database\'s physical ' + + 'schema matches the declared objects. Drift detection reads the physical schema through the ' + + 'SQL driver\'s Knex connection, and in remote mode that connection is a placeholder in-memory ' + + 'database holding none of this datasource\'s tables. Until this change it therefore answered ' + + '"no drift" for every remote database, whatever its tables held. The call is spelled correctly ' + + 'and `SqlDriver` declares it, so this is a capability gap of the remote transport rather than a ' + + 'mistake in the request, which is why it answers NOT_IMPLEMENTED/501 and not a 400. To check ' + + 'this database for drift, run `os migrate plan` against a local SQLite copy of it (a `file:` ' + + 'URL), where the physical schema is introspected. Pointed at the remote URL, `os migrate plan` ' + + 'refuses, because the remote transport cannot defer schema DDL.', + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.NOT_IMPLEMENTED; + err.status = 501; + throw err; +} + // ── Remote operation timeout ───────────────────────────────────────────────── /** @@ -2045,6 +2113,21 @@ export class TursoDriver extends SqlDriver { super.setDeferredDdl(deferred); } + /** + * Detect managed-schema drift — refused on the REMOTE face, see + * {@link refuseRemoteDriftDetection}. The inherited detector reads the + * physical schema through the placeholder Knex connection remote mode is + * built with, so its remote answer was always `[]`. Refused with or without + * explicit `objects`, because both read the same placeholder. Local and + * replica modes inherit the Knex detector unchanged. + */ + override async detectManagedDrift( + objects?: Parameters[0], + ): ReturnType { + if (this.isRemote) refuseRemoteDriftDetection(); + return super.detectManagedDrift(objects); + } + override async syncSchema(object: string, schema: unknown, options?: DriverOptions): Promise { this.assertRemoteTransactionUnsupported(options, 'syncSchema'); if (this.isRemote) { diff --git a/packages/drivers/driver-turso/src/turso-remote-drift-detection-refusal.test.ts b/packages/drivers/driver-turso/src/turso-remote-drift-detection-refusal.test.ts new file mode 100644 index 0000000000..53dd9574dd --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-remote-drift-detection-refusal.test.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #19845 — schema drift detection on the Turso REMOTE face refuses instead of + * answering "no drift". + * + * `SqlDriver.detectManagedDrift` reads the physical schema through Knex, and a + * remote `TursoDriver` is built with a placeholder `:memory:` Knex connection + * that holds none of the datasource's tables. Before the refusal, the remote + * answer was `[]` for every database. That is the answer the artifact-pinned + * boot gate of `os serve` reads as "never drifted", so a gate whose job is to + * refuse a boot on destructive drift let every remote-Turso boot through. + * + * ## The reproduction, as measured before the refusal existed + * + * One declared object `t` with one field. Its table is synced, then an extra + * physical column `legacy` the declaration omits is added on disk. + * + * | face | call | answer | + * |:--|:--|:--| + * | local (`:memory:` url, Knex) | `detectManagedDrift()` | `t.legacy`: `unmapped_column`, op `drop_column`, `destructive` | + * | remote (batch door, the engine's boot sync) | `detectManagedDrift()` | `[]` | + * | remote | `detectManagedDrift([{ name: 't', fields }])` | `[]` | + * + * The remote physical table held `id, created_at, updated_at, name, legacy` + * at the time, so the drift was on disk and the detector did not see it. + * + * ## What is pinned + * + * 1. The local and embedded-replica faces still report the extra column. They + * are the controls: the same physical state, the same declaration, a real + * finding. + * 2. The remote face refuses both call shapes with `NOT_IMPLEMENTED` / `501` + * and the operator-facing first sentence, and sends nothing to the + * database while refusing. + */ + +import { describe, it, expect } from 'vitest'; +import { TursoDriver } from './turso-driver.js'; +import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +/** + * The refusal's opening sentence: the operator contract, because the boot gate + * prints the driver's message inside its warning. Spelled out here rather than + * imported, since a test that imports the string it asserts pins nothing about + * the wording. The rest of the message is prose that may be improved without a + * test edit. + */ +const REFUSAL_FIRST_SENTENCE = + "Schema drift detection is not supported by the Turso REMOTE transport (this datasource's " + + "transport mode is `remote`), so this driver cannot say whether the database's physical " + + 'schema matches the declared objects.'; + +const T_FIELDS = { name: { type: 'text' } }; +const T_OBJECTS = [{ name: 't', fields: T_FIELDS }]; +const ADD_LEGACY = 'ALTER TABLE "t" ADD COLUMN "legacy" TEXT'; + +/** The finding the local detector reports for the extra column. */ +const LEGACY_FINDING = { + table: 't', + column: 'legacy', + kind: 'unmapped_column', + category: 'destructive', + op: { type: 'drop_column' }, +}; + +const sqlOf = (stmt: unknown): string => + typeof stmt === 'string' ? stmt : String((stmt as { sql?: unknown }).sql ?? ''); + +/** Wrap the stub so every statement the transport sends is recorded. */ +function record(stub: LibsqlSqliteStub) { + const statements: string[] = []; + const client = { + async execute(stmt: unknown) { + statements.push(sqlOf(stmt)); + return stub.execute(stmt); + }, + async batch(stmts: unknown[]) { + for (const s of stmts) statements.push(sqlOf(s)); + return stub.batch(stmts); + }, + close() { + stub.close(); + }, + }; + return { client, statements }; +} + +const columnsOf = (stub: LibsqlSqliteStub, table: string) => + (stub.raw.prepare(`pragma table_info("${table}")`).all() as Array<{ name: string }>).map((r) => r.name); + +/** A remote driver whose `t` was synced by the engine's boot-sync door, then drifted on disk. */ +async function driftedRemote() { + const stub = makeLibsqlSqliteStub(); + const rec = record(stub); + const driver = new TursoDriver({ url: 'libsql://drift.turso.io', client: rec.client as never }); + await driver.connect(); + expect(driver.transportMode).toBe('remote'); + await driver.syncSchemasBatch([{ object: 't', schema: { name: 't', fields: T_FIELDS } }]); + stub.raw.prepare(ADD_LEGACY).run(); + // Non-vacuous: the drift is on disk, so an empty answer would be a miss. + expect(columnsOf(stub, 't')).toContain('legacy'); + return { driver, rec }; +} + +type Call = 'no arguments' | 'explicit objects'; +const CALLS: Record ReturnType> = { + // The call the artifact-pinned boot gate makes. + 'no arguments': (driver) => driver.detectManagedDrift(), + 'explicit objects': (driver) => driver.detectManagedDrift(T_OBJECTS), +}; + +describe('controls — the Knex detector still reports the extra column', () => { + it.each(['no arguments', 'explicit objects'])('local face, %s', async (call) => { + const driver = new TursoDriver({ url: ':memory:' }); + await driver.connect(); + expect(driver.transportMode).toBe('local'); + await driver.initObjects(T_OBJECTS); + await driver.execute(ADD_LEGACY); + + const drift = await CALLS[call](driver); + + expect(drift).toEqual([expect.objectContaining({ ...LEGACY_FINDING, op: expect.objectContaining(LEGACY_FINDING.op) })]); + await driver.disconnect(); + }); + + it.each(['no arguments', 'explicit objects'])('embedded-replica face, %s', async (call) => { + // Replica mode reads its local file through Knex; `sync.onConnect: false` + // keeps the (stubbed) sync target out of the measurement. + const stub = makeLibsqlSqliteStub(); + const driver = new TursoDriver({ + url: ':memory:', + syncUrl: 'libsql://drift.turso.io', + client: record(stub).client as never, + sync: { onConnect: false }, + }); + await driver.connect(); + expect(driver.transportMode).toBe('replica'); + await driver.initObjects(T_OBJECTS); + await driver.execute(ADD_LEGACY); + + const drift = await CALLS[call](driver); + + expect(drift).toEqual([expect.objectContaining({ ...LEGACY_FINDING, op: expect.objectContaining(LEGACY_FINDING.op) })]); + await driver.disconnect(); + }); +}); + +describe('remote face — refused, never "no drift"', () => { + it.each(['no arguments', 'explicit objects'])( + 'refuses with NOT_IMPLEMENTED / 501, %s, and sends nothing to the database', + async (call) => { + const { driver, rec } = await driftedRemote(); + const sentBefore = rec.statements.length; + + const err = await CALLS[call](driver).then( + (drift) => { + throw new Error(`expected a refusal, got an answer: ${JSON.stringify(drift)}`); + }, + (e: unknown) => e as WireBearingError, + ); + + expect(err.code).toBe('NOT_IMPLEMENTED'); + expect(err.status).toBe(501); + expect(err.message.startsWith(REFUSAL_FIRST_SENTENCE)).toBe(true); + expect(rec.statements.slice(sentBefore)).toEqual([]); + await driver.disconnect(); + }, + ); +}); From 6d1e4e96193159512d857287059f14c2ca6c2ff9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:06:39 +0000 Subject: [PATCH 2/3] fix(driver-turso): spell the detectManagedDrift override's parameter shape key for key check:object-def-param-keys arm C refuses an override that derives the base's object-definition parameter instead of declaring it, because the keys a caller may pass then vanish from the override's own declaration. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude --- packages/drivers/driver-turso/src/turso-driver.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index a6c625adfa..c6cd28bc8c 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -2120,9 +2120,13 @@ export class TursoDriver extends SqlDriver { * built with, so its remote answer was always `[]`. Refused with or without * explicit `objects`, because both read the same placeholder. Local and * replica modes inherit the Knex detector unchanged. + * + * The parameter repeats the base's declared shape key for key rather than + * deriving it (`check:object-def-param-keys` arm C), so the keys a caller may + * pass stay visible on this override's own declaration. */ override async detectManagedDrift( - objects?: Parameters[0], + objects?: Array<{ name: string; fields?: Record; indexes?: any[] }>, ): ReturnType { if (this.isRemote) refuseRemoteDriftDetection(); return super.detectManagedDrift(objects); From 7d9a7e38c742305b04cb8e6b9ce611c74ff40326 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:10:58 +0000 Subject: [PATCH 3/3] fix(driver-turso): word the remote drift refusal without a point in time The operator reads this message long after the change that introduced it, so it states what answering from the placeholder would do rather than what the driver did "until this change". Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude --- packages/drivers/driver-turso/src/turso-driver.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index c6cd28bc8c..4f2aabfcd1 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -458,13 +458,13 @@ function refuseRemoteDriftDetection(): never { 'transport mode is `remote`), so this driver cannot say whether the database\'s physical ' + 'schema matches the declared objects. Drift detection reads the physical schema through the ' + 'SQL driver\'s Knex connection, and in remote mode that connection is a placeholder in-memory ' + - 'database holding none of this datasource\'s tables. Until this change it therefore answered ' + - '"no drift" for every remote database, whatever its tables held. The call is spelled correctly ' + - 'and `SqlDriver` declares it, so this is a capability gap of the remote transport rather than a ' + - 'mistake in the request, which is why it answers NOT_IMPLEMENTED/501 and not a 400. To check ' + - 'this database for drift, run `os migrate plan` against a local SQLite copy of it (a `file:` ' + - 'URL), where the physical schema is introspected. Pointed at the remote URL, `os migrate plan` ' + - 'refuses, because the remote transport cannot defer schema DDL.', + 'database holding none of this datasource\'s tables. Answering from it would report "no drift" ' + + 'for every remote database, whatever its tables hold, so the call refuses. The call is spelled ' + + 'correctly and `SqlDriver` declares it, so this is a capability gap of the remote transport ' + + 'rather than a mistake in the request, which is why it answers NOT_IMPLEMENTED/501 and not a ' + + '400. To check this database for drift, run `os migrate plan` against a local SQLite copy of it ' + + '(a `file:` URL), where the physical schema is introspected. Pointed at the remote URL, ' + + '`os migrate plan` refuses, because the remote transport cannot defer schema DDL.', ) as Error & { code?: string; status?: number }; err.code = StandardErrorCode.enum.NOT_IMPLEMENTED; err.status = 501;