fix(driver-turso): refuse to arm deferred schema DDL on the remote face - #19842
Conversation
Replays the calls a deferSchemaDdl boot makes (setDeferredDdl, the engine's syncSchemasBatch door, the syncSchema/initObjects doors, preview and flush) against a remote-mode TursoDriver over the libsql SQLite stub, recording every statement. Measured on origin/main: arming is accepted, DDL runs on every door, the canonical backfill rewrites rows on the syncSchema/initObjects doors, and preview/flush answer nothing. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
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>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a341395ae0152ea42e7f68da50c038090fab09da && git checkout a341395ae0152ea42e7f68da50c038090fab09da
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4112752ec3eeccf52623103ddb7f82bd5587190f 67be9850fd360bec80eb28c672e4da42800cf855 && git checkout -B drift-repro 4112752ec3eeccf52623103ddb7f82bd5587190f && git merge --no-ff 67be9850fd360bec80eb28c672e4da42800cf855
node scripts/docs-audit/affected-docs.mjs --json 4112752ec3eeccf52623103ddb7f82bd5587190f
|
Contract reviewServed-tier: Rendered by an isolated at-tier reviewer subagent that was fed the card, its thread and this PR only, and adopted by the ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19823
Clause-②: no (narrowing)
Measurement first — committed before the fix (
8d145637a8)Triage (comment 5792299650) ordered the three predictions recorded MEASURED or REFUTED before any fix. The instrument is
packages/drivers/driver-turso/src/turso-remote-deferred-ddl.test.ts, first committed as a characterisation of the unrefused behaviour. It replays, against a remote-modeTursoDriverovermakeLibsqlSqliteStub(a real SQLite database wearing the@libsql/clientinterface) wrapped in a recorder that logs every statement the transport sends, the exact driver calls adeferSchemaDdlboot makes:setDeferredDdl(true): the CLI'sDeferSchemaDdlPlugin.init.syncSchemasBatch(...):ObjectQLPlugin.start()'s boot sync. It takes the batch door because this driver answerssupports.batchSchemaSync === trueand has the method, which the test also asserts.syncSchema(...): the composed-host coverage pass (engine.syncObjectSchema) thatplan/applyrun.previewDeferredSchemaWork()/flushDeferredSchemaDdl(): whatplanprints, and whatapplyperforms after its confirm prompt.Seed: an existing remote table
probemissing one declared column and holding a naive datetime (2025-07-28 00:00:00), plus a declared objectfreshwith no table. Run on base1cacfe4a42: 6 of 6 characterisation tests green.syncSchemasBatch(engine boot sync)CREATE TABLE "fresh" (...),ALTER TABLE "probe" ADD COLUMN "why" TEXTsyncSchema/initObjectssyncSchemasBatchsyncSchema/initObjectsupdate "probe" set "at" = (case ... end) where rowid in (select ...); the row now reads2025-07-28T00:00:00.000ZpreviewDeferredSchemaWork()answers[],flushDeferredSchemaDdl()answers[],deferredSchemaObjectCountis 0Triage's exits: neither fires. Exit one (all three REFUTED) does not: the plan path reaches DDL on every door. Exit two (a destructive statement) does not: no door emitted a
DROPor a type change. The only row writes are the canonical backfill'supdate, which rewrites a value's spelling and not the instant it names.Not measured end to end: the
os migrate planbinary itself against a live libsql remote. The CLI's build closure is 58 workspace packages. The driver half is measured above. The CLI half is read from source on1cacfe4a42:packages/cli/src/utils/schema-migrate.tsDeferSchemaDdlPlugin.initcallssetDeferredDdl(true)in Phase 1.packages/core/src/kernel.tsbootstrap()rethrows an init error unwrapped, andRuntime.start()iskernel.bootstrap().planprintserror.message. Under--jsonit emitserrorpluscodethrougherrorCodeFields.Dispatch assumptions, measured
isRemotearms ofsyncSchemaandinitObjectsdo run remote DDL plusbackfillRemoteCanonicalTemporalQuietly(), and never read the flag (deferredDdl: 0 hits underpackages/drivers/driver-turso/src/on base). The engine's boot sync reaches neither of them, though. It takes a third door,TursoDriver.syncSchemasBatch, whose remote arm forwards straight toRemoteTransport.syncSchemasBatch: DDL without the backfill. So on the ordinaryplanpath the DDL is certain, and the backfill arrives through the coverage pass.typeof driver.setDeferredDdl !== 'function'. The measurement shows the inherited setter acceptingtrueon the remote face without a throw.needs_decisionstop. Enumerated fromdeferSchemaDdl: trueunderpackages/cli/src/commands/**.setDeferredDdlhas no other caller in this repository.os migrate planos migrate apply[]os migrate duplicatesboot_failedplus the detail)os migrate account-issueros migrate multi-value-columns--applypromises "the only statements this command may run are the remedy's"previewDeferredSchemaWork/flushDeferredSchemaDdlreaddeferredSchemaObjects, which only the KnexSqlDriver.initObjectsfills. On remote both answer[], per the table above.remote-canonical-backfill.tsalready said so in prose.Through the CLI, a Turso URL always builds a remote driver.
standalone-stack.tshands the driver{ url, authToken }with nosyncUrl, and afile:URL is classifiedsqlite. So all five commands refuse on every Turso URL the CLI accepts.The fix
TursoDriveroverridessetDeferredDdl: arming (true) inremotetransport mode throws before any statement is sent. Disarming is accepted, andlocal/replicadelegate toSqlDriverunchanged. The refusal (refuseRemoteDeferredDdl, beside the transaction and auto-number refusals) answerscode: 'NOT_IMPLEMENTED',status: 501. That is aStandardErrorCodemember and the envelope this transport already uses for its other capability gaps, so there is no new error code.Whose message the operator reads — measured, not assumed. The CLI's own refusal ("does not support deferred schema DDL ... Upgrade @objectstack/driver-sql") cannot fire, because the method exists. The driver's throw propagates out of
DeferSchemaDdlPlugin.initunwrapped, and the command prints itsmessage. So the driver's own message is the operator contract, and the CLI is untouched:packages/cli/src/utils/schema-migrate.tsandpackages/cli/src/commands/migrate/*were read only. Its first sentence:The rest says why (remote DDL is immediate, and a remote sync rewrites temporal values in place), what it replaced, and what to do instead: preview against a local SQLite copy (a
file:URL), or let an ordinaryos serve/os startboot perform the additive sync.Why at the setter: every deferring caller passes through it, and it runs before any schema work. A refused arm has sent nothing, and it leaves the driver un-armed, so an ordinary boot sync on the same instance is unchanged. Honouring the deferral remotely (recording objects, a remote preview and flush) is new capability with no measured pull, and it is not attempted here.
Tests —
turso-remote-deferred-ddl.test.ts(8 tests)toBeInstanceOf(Error),code === 'NOT_IMPLEMENTED',status === 501, and the message starts with the first sentence above, spelled out in the test rather than imported.setDeferredDdl(false)is accepted and sends nothing.syncSchemasBatchstill emits the CREATE and the ALTER, andsyncSchemastill runs the backfillupdate(the stored value becomes2025-07-28T00:00:00.000Z).localandreplica: arming is accepted. The same replay records instead of performing (freshabsent,whyabsent,deferredSchemaObjectCount2). The preview listscreate_table fresh [label]andadd_columns probe [why]. The flush performs exactly the previewed work, and the libsql client carried no DDL.supports.batchSchemaSync === trueandsyncSchemasBatchis a function, the two facts the engine ANDs to pick the batch door.Package suite at
67be9850fd:pnpm --filter @objectstack/driver-turso exec vitest run --maxWorkers=2gave 56 files / 1299 tests passed.pnpm --filter @objectstack/driver-turso typecheckexited 0, andtsc --noEmit --listFilesincludes the new file (56 test files in the program).Ablation — committed fix, then removed, then restored
At HEAD
67be9850fd, throughscripts/ablation-replace.mjsin WRAP mode, with a shelltraprestoringgit checkout HEAD --on the absolute path. The anchor wasif (deferred && this.isRemote) refuseRemoteDeferredDdl();, replaced by a marker comment.79960fb8a08ftod16059b40f0a. The in-mutationgrep -cread anchor 0 and marker 1. The subject is imported fromsrc/(a relative./turso-driver.js), so nodist/leg applies.3 failed | 5 passed (8), exactly those three:expected null to be an instance of Error, thenexpected undefined to be 'NOT_IMPLEMENTED'twice.79960fb8a08f),git diff HEADis empty, andgit status --porcelainis empty.Gates — derived on the final commit
67be9850fdnode scripts/pm/dispatch-gates.mjs --commands(no paths) derived 61 commands. Every exit code was captured before any pipe, and each command was recorded with it:check:adr-0087-registration --base origin/mainaccepted the changeset as[BREAKING+clause-②-narrowing] not-required (no-migration-prescription), andcheck:changeset-no-majorreported nomajor(the level axis is not applicable locally, since there is no PR payload). Also green:check:empty-changeset,check:doc-authoring,check:nul-bytes,check:object-def-param-keys,check:published-files,check:dts-closure,check:sourcemap-no-sources-content,check:test-source-alias,check:type-check-coverage,check:cross-package-test-inputsandcheck:engine-double-contract.check:dual-build-cjs-loadsandcheck:type-check-debtneed the whole workspace built, andcheck:lean-entry-closureneedsobjectql/dist. They are declared to CI. A narrow probe of the half this diff touches: the builtdriver-tursodist/index.jsloads underrequireanddist/index.mjsunderimport, and both exportTursoDriver(exit 0).--ranreconciliation:✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 58 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3).It reported 0 UNRUN.Driver conformance ledger (lane commitment), identical before the first edit and after the final commit:
OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.8 conformance suite(s) ... 7 run the matrix, 1 declare named cell(s), 0 in the DIALECT ledger.No DEBT added.
Changeset judgement — a declared narrowing
A remote
setDeferredDdl(true)used to resolve, and a remoteos migrate planused to exit 0. Both now refuse. That narrows what the published driver accepts, so this PR follows the shape of this seat's sibling PR #19829:Clause-②: no (narrowing), aminorbump for@objectstack/driver-turso, a BREAKING banner, and the ADR-0087 dispositionnot-required (no-migration-prescription). Nothing authorable is removed or renamed, andsetDeferredDdlkeeps its name and signature. The claim carries bareClause-②: no, and its own rationale calls this change a narrowing, so the arm is added and the base value is unchanged.Acceptance notes
content/docs/deployment/cli.mdx, section "Nothing is written before you confirm", does not mention that a remote Turso datasource is refused. It is incomplete rather than wrong: plan and apply still write nothing. Successor: none.setDeferredDdlon a remoteTursoDriveroutside this repository (for example the cloud repository) were NOT MEASURED, because that repository is not reachable from this container. Inside this repository the only caller is the CLI plugin.detectManagedDrift()reading the dummy Knex connection.Generated by Claude Code