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..4f2aabfcd1 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. 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; + throw err; +} + // ── Remote operation timeout ───────────────────────────────────────────────── /** @@ -2045,6 +2113,25 @@ 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. + * + * 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?: Array<{ name: string; fields?: Record; indexes?: any[] }>, + ): 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(); + }, + ); +});