Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/19845-turso-remote-drift-detection-refusal.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or reshaped: no spec key, no export, no stored row and no config key — `detectManagedDrift` keeps its name and its signature. There is no old spelling that maps to a new one: the refused call asked the remote transport for a capability it never delivered, and the refusal itself carries the remedy. -->
87 changes: 87 additions & 0 deletions packages/drivers/driver-turso/src/turso-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -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<string, any>; indexes?: any[] }>,
): ReturnType<SqlDriver['detectManagedDrift']> {
if (this.isRemote) refuseRemoteDriftDetection();
return super.detectManagedDrift(objects);
}

override async syncSchema(object: string, schema: unknown, options?: DriverOptions): Promise<void> {
this.assertRemoteTransactionUnsupported(options, 'syncSchema');
if (this.isRemote) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Call, (driver: TursoDriver) => ReturnType<TursoDriver['detectManagedDrift']>> = {
// 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<Call>(['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<Call>(['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<Call>(['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();
},
);
});
Loading