Skip to content

Commit 67be985

Browse files
committed
fix(driver-turso): refuse to arm deferred schema DDL on the remote face
TursoDriver inherited SqlDriver.setDeferredDdl, so a deferSchemaDdl boot (os migrate plan/apply/duplicates/account-issuer/multi-value-columns) armed a flag no remote schema door reads: the DDL and the canonical backfill ran during boot and the preview answered nothing. Arming now throws NOT_IMPLEMENTED/501 in remote mode before any statement is sent; disarming, local and replica modes, and ordinary boot sync are unchanged. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8d14563 commit 67be985

3 files changed

Lines changed: 242 additions & 56 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/driver-turso': minor
3+
---
4+
5+
fix(driver-turso): a REMOTE `TursoDriver` refuses to arm deferred schema DDL instead of accepting it and running the DDL anyway (#19823)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for callers that arm DDL deferral on a remote Turso datasource**`TursoDriver.setDeferredDdl(true)` in `remote` transport mode (a `libsql://`, `https://`, `http://`, `wss://` or `ws://` URL with no `syncUrl`) now throws a `NOT_IMPLEMENTED` / `501` error, where it used to be accepted and then ignored. The five `os migrate` commands that arm it — `plan`, `apply`, `duplicates`, `account-issuer` and `multi-value-columns` — therefore exit non-zero against a remote Turso database, where they used to exit 0 after changing it. Disarming (`setDeferredDdl(false)`) is accepted, and the `local` and `replica` modes defer exactly as before.
10+
11+
What the refusal replaces, measured on the transport's SQLite-backed test double: arming was accepted, but none of the remote schema doors reads the flag. The engine's boot sync (`syncSchemasBatch`) ran `CREATE TABLE` and `ALTER TABLE … ADD COLUMN` through `RemoteTransport`; the `syncSchema` / `initObjects` doors ran the same DDL plus the canonical temporal backfill, rewriting stored `datetime` / `time` values in place; and `previewDeferredSchemaWork()` and `flushDeferredSchemaDdl()` both answered `[]`. So `os migrate plan` changed the database and then reported no pending work, and `os migrate apply` asked for confirmation after the schema work had already run.
12+
13+
- **Refused at the setter.** Every deferring caller passes through `setDeferredDdl`, and it runs before any schema work: a refused arm sends nothing to the database and leaves the driver un-armed.
14+
- **The driver's message is what the operator reads.** The CLI prints it verbatim. It names the `remote` transport mode, says why the promise cannot be kept, and says what to do instead.
15+
- **No new error code.** `NOT_IMPLEMENTED` / `501` is a standard code, the envelope this transport already uses for its remote transaction and auto-number refusals.
16+
- **Ordinary boots are unchanged.** A boot that does not arm the deferral (`os serve`, `os start`, `os dev`) syncs a remote schema exactly as before.
17+
18+
**If you are refused:** to preview schema work, run the command against a local SQLite copy of the database (a `file:` URL); the local and embedded-replica faces defer DDL. To perform the additive schema work, let an ordinary boot against the remote datasource (`os serve` / `os start`) run it directly.
19+
20+
<!-- 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 — `setDeferredDdl` 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. -->

packages/drivers/driver-turso/src/turso-driver.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,66 @@ function refuseRemoteTransaction(door: string, detail: string): never {
342342
throw err;
343343
}
344344

345+
// ── Remote deferred schema DDL: refused, never decorative ────────────────────
346+
347+
/**
348+
* [#19823] The Turso REMOTE face cannot defer schema DDL, and now says so when
349+
* a caller tries to arm the deferral instead of accepting it and ignoring it.
350+
*
351+
* # The defect this replaces
352+
*
353+
* `SqlDriver.setDeferredDdl(true)` is how `os migrate plan` / `apply` /
354+
* `duplicates` / `account-issuer` / `multi-value-columns` keep their dry-run or
355+
* confirm-before-change promise: the Knex `initObjects` records the work in
356+
* `deferredSchemaObjects` instead of performing it, `previewDeferredSchemaWork`
357+
* renders it and `flushDeferredSchemaDdl` performs it after the operator says
358+
* yes. This class inherited the setter, so the CLI's own loud refusal (it fires
359+
* only when the method is absent) never fired — while every remote schema door
360+
* (`syncSchemasBatch`, the engine's boot sync; `syncSchema` / `initObjects`)
361+
* routes through `RemoteTransport`, which performs the DDL immediately, and the
362+
* latter two also run the #5770 canonical temporal backfill, which rewrites
363+
* stored rows. Measured (`turso-remote-deferred-ddl.test.ts`): the deferral was
364+
* accepted, CREATE/ALTER ran on every door, the backfill rewrote rows on two of
365+
* them, and preview and flush both answered `[]` — a dry run that changed the
366+
* database and then reported no pending work.
367+
*
368+
* # Why a refusal rather than an implementation
369+
*
370+
* Honouring the deferral remotely means recording the objects and building a
371+
* remote preview/flush — new capability with no measured pull. The refusal keeps
372+
* every promise those commands make true today, in the envelope and for the
373+
* reason {@link refuseRemoteTransaction} and {@link refuseRemoteAutonumber}
374+
* record for their sibling gaps on this transport: the call is spelled correctly
375+
* and the base class declares it, so the gap is the backend's —
376+
* `NOT_IMPLEMENTED`/501, a {@link StandardErrorCode} member, no new code.
377+
*
378+
* # Why at the setter
379+
*
380+
* It is the one door every deferring caller passes through, and it runs before
381+
* any schema work: a refused arm has sent nothing to the database, and the
382+
* driver is left un-armed, so an ordinary boot sync on it is unchanged.
383+
*/
384+
function refuseRemoteDeferredDdl(): never {
385+
const err = new Error(
386+
'Deferred schema DDL is not supported by the Turso REMOTE transport (this datasource\'s ' +
387+
'transport mode is `remote`), so a command that promises a dry run or a confirmation before ' +
388+
'any schema change cannot keep that promise against it. Remote mode sends every CREATE TABLE ' +
389+
'and ALTER TABLE through `RemoteTransport`, which performs it immediately and records nothing ' +
390+
'a plan could preview, and a remote schema sync also rewrites stored datetime/time values to ' +
391+
'their canonical spelling in place. Until this change arming the deferral was accepted: the ' +
392+
'database was altered during the boot and the plan then reported no pending work. The call is ' +
393+
'spelled correctly and `SqlDriver` declares it, so this is a capability gap of the remote ' +
394+
'transport rather than a mistake in the request — which is why it answers NOT_IMPLEMENTED/501 ' +
395+
'and not a 400. To preview schema work, run the command against a local SQLite copy of this ' +
396+
'database (a `file:` URL) — the local and embedded-replica faces defer DDL; to apply it, an ' +
397+
'ordinary boot against this datasource (`os serve` / `os start`) performs the additive schema ' +
398+
'sync directly.',
399+
) as Error & { code?: string; status?: number };
400+
err.code = StandardErrorCode.enum.NOT_IMPLEMENTED;
401+
err.status = 501;
402+
throw err;
403+
}
404+
345405
// ── Remote operation timeout ─────────────────────────────────────────────────
346406

347407
/**
@@ -1938,6 +1998,18 @@ export class TursoDriver extends SqlDriver {
19381998
// Schema Management (remote mode overrides)
19391999
// ===================================
19402000

2001+
/**
2002+
* Arm/disarm DDL deferral — refused on the REMOTE face when arming, see
2003+
* {@link refuseRemoteDeferredDdl}. None of the remote schema doors below reads
2004+
* the flag, so accepting it here would promise a dry run nothing keeps.
2005+
* Disarming is accepted (it is what the flag already is), and local / replica
2006+
* modes inherit the Knex deferral unchanged.
2007+
*/
2008+
override setDeferredDdl(deferred: boolean): void {
2009+
if (deferred && this.isRemote) refuseRemoteDeferredDdl();
2010+
super.setDeferredDdl(deferred);
2011+
}
2012+
19412013
override async syncSchema(object: string, schema: unknown, options?: DriverOptions): Promise<void> {
19422014
this.assertRemoteTransactionUnsupported(options, 'syncSchema');
19432015
if (this.isRemote) {

0 commit comments

Comments
 (0)