Skip to content

fix(driver-turso): refuse to arm deferred schema DDL on the remote face - #19842

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19823-turso-remote-deferred-ddl
Sep 23, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19823-turso-remote-deferred-ddl

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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-mode TursoDriver over makeLibsqlSqliteStub (a real SQLite database wearing the @libsql/client interface) wrapped in a recorder that logs every statement the transport sends, the exact driver calls a deferSchemaDdl boot makes:

  1. setDeferredDdl(true): the CLI's DeferSchemaDdlPlugin.init.
  2. syncSchemasBatch(...): ObjectQLPlugin.start()'s boot sync. It takes the batch door because this driver answers supports.batchSchemaSync === true and has the method, which the test also asserts.
  3. syncSchema(...): the composed-host coverage pass (engine.syncObjectSchema) that plan / apply run.
  4. previewDeferredSchemaWork() / flushDeferredSchemaDdl(): what plan prints, and what apply performs after its confirm prompt.

Seed: an existing remote table probe missing one declared column and holding a naive datetime (2025-07-28 00:00:00), plus a declared object fresh with no table. Run on base 1cacfe4a42: 6 of 6 characterisation tests green.

prediction door verdict evidence (recorded statements, disk)
(a) the dry run performs DDL syncSchemasBatch (engine boot sync) MEASURED CREATE TABLE "fresh" (...), ALTER TABLE "probe" ADD COLUMN "why" TEXT
(a) syncSchema / initObjects MEASURED the same CREATE and ALTER
(b) the dry run rewrites rows syncSchemasBatch REFUTED no row write; the naive value is still on disk
(b) syncSchema / initObjects MEASURED update "probe" set "at" = (case ... end) where rowid in (select ...); the row now reads 2025-07-28T00:00:00.000Z
(c) the plan reports no pending work every door MEASURED previewDeferredSchemaWork() answers [], flushDeferredSchemaDdl() answers [], deferredSchemaObjectCount is 0

Triage'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 DROP or a type change. The only row writes are the canonical backfill's update, which rewrites a value's spelling and not the instant it names.

Not measured end to end: the os migrate plan binary 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 on 1cacfe4a42:

  • packages/cli/src/utils/schema-migrate.ts DeferSchemaDdlPlugin.init calls setDeferredDdl(true) in Phase 1.
  • packages/core/src/kernel.ts bootstrap() rethrows an init error unwrapped, and Runtime.start() is kernel.bootstrap().
  • plan prints error.message. Under --json it emits error plus code through errorCodeFields.

Dispatch assumptions, measured

  • A1 — confirmed, and widened. The isRemote arms of syncSchema and initObjects do run remote DDL plus backfillRemoteCanonicalTemporalQuietly(), and never read the flag (deferredDdl: 0 hits under packages/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 to RemoteTransport.syncSchemasBatch: DDL without the backfill. So on the ordinary plan path the DDL is certain, and the backfill arrives through the coverage pass.
  • A2 — confirmed. The CLI refuses only on typeof driver.setDeferredDdl !== 'function'. The measurement shows the inherited setter accepting true on the remote face without a throw.
  • A3 — five commands arm deferral; all five carry a dry-run or confirm-before-change promise. So the refusal disables nothing that promised to write, and there is no needs_decision stop. Enumerated from deferSchemaDdl: true under packages/cli/src/commands/**. setDeferredDdl has no other caller in this repository.
command its promise on remote Turso before this PR after
os migrate plan dry run, "never mutates the schema" DDL (plus the backfill on the coverage pass), then "no pending work", exit 0 refused, exit 1, nothing sent
os migrate apply "nothing is written before you say yes" DDL during boot, then the confirm prompt; the flush reports [] refused, exit 1
os migrate duplicates read-only inventory; "cannot change the install it is describing" DDL during boot refused (boot_failed plus the detail)
os migrate account-issuer read-only pre-flight DDL during boot refused
os migrate multi-value-columns dry run by default; --apply promises "the only statements this command may run are the remedy's" DDL during boot, in both modes refused
  • A4 — confirmed. previewDeferredSchemaWork / flushDeferredSchemaDdl read deferredSchemaObjects, which only the Knex SqlDriver.initObjects fills. On remote both answer [], per the table above. remote-canonical-backfill.ts already said so in prose.

Through the CLI, a Turso URL always builds a remote driver. standalone-stack.ts hands the driver { url, authToken } with no syncUrl, and a file: URL is classified sqlite. So all five commands refuse on every Turso URL the CLI accepts.

The fix

TursoDriver overrides setDeferredDdl: arming (true) in remote transport mode throws before any statement is sent. Disarming is accepted, and local / replica delegate to SqlDriver unchanged. The refusal (refuseRemoteDeferredDdl, beside the transaction and auto-number refusals) answers code: 'NOT_IMPLEMENTED', status: 501. That is a StandardErrorCode member 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.init unwrapped, and the command prints its message. So the driver's own message is the operator contract, and the CLI is untouched: packages/cli/src/utils/schema-migrate.ts and packages/cli/src/commands/migrate/* were read only. Its first sentence:

Deferred schema DDL is not supported by the Turso REMOTE transport (this datasource's transport mode is remote), so a command that promises a dry run or a confirmation before any schema change cannot keep that promise against it.

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 ordinary os serve / os start boot 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)

  • Refusal envelope: toBeInstanceOf(Error), code === 'NOT_IMPLEMENTED', status === 501, and the message starts with the first sentence above, spelled out in the test rather than imported.
  • The measurement's own scenario now performs NOTHING: the full deferred-boot replay against remote pending work rejects with the envelope. The recorder holds zero statements (so zero DDL and zero row writes), and the disk is byte-for-byte what the command found: the tables, the columns and the naive value.
  • Disarm: setDeferredDdl(false) is accepted and sends nothing.
  • Lit control, remote ordinary boot (deferral NOT armed): after a refused arm on the same driver, syncSchemasBatch still emits the CREATE and the ALTER, and syncSchema still runs the backfill update (the stored value becomes 2025-07-28T00:00:00.000Z).
  • Lit controls, local and replica: arming is accepted. The same replay records instead of performing (fresh absent, why absent, deferredSchemaObjectCount 2). The preview lists create_table fresh [label] and add_columns probe [why]. The flush performs exactly the previewed work, and the libsql client carried no DDL.
  • Pin: supports.batchSchemaSync === true and syncSchemasBatch is 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=2 gave 56 files / 1299 tests passed. pnpm --filter @objectstack/driver-turso typecheck exited 0, and tsc --noEmit --listFiles includes the new file (56 test files in the program).

Ablation — committed fix, then removed, then restored

At HEAD 67be9850fd, through scripts/ablation-replace.mjs in WRAP mode, with a shell trap restoring git checkout HEAD -- on the absolute path. The anchor was if (deferred && this.isRemote) refuseRemoteDeferredDdl();, replaced by a marker comment.

  • On disk: anchor 1 to 0, marker 0 to 1, blob 79960fb8a08f to d16059b40f0a. The in-mutation grep -c read anchor 0 and marker 1. The subject is imported from src/ (a relative ./turso-driver.js), so no dist/ leg applies.
  • Direction predicted before the run: the three tests that need the refusal go red (the envelope, performs-NOTHING, and the refused-arm control), and the five that do not stay green.
  • Observed: 3 failed | 5 passed (8), exactly those three: expected null to be an instance of Error, then expected undefined to be 'NOT_IMPLEMENTED' twice.
  • Restore: the blob after restore equals HEAD (79960fb8a08f), git diff HEAD is empty, and git status --porcelain is empty.

Gates — derived on the final commit 67be9850fd

node 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:

  • 58 exited 0. Among them: check:adr-0087-registration --base origin/main accepted the changeset as [BREAKING+clause-②-narrowing] not-required (no-migration-prescription), and check:changeset-no-major reported no major (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-inputs and check:engine-double-contract.
  • 3 exited 3, NOT MEASURED (PREREQUISITE NOT MET): check:dual-build-cjs-loads and check:type-check-debt need the whole workspace built, and check:lean-entry-closure needs objectql/dist. They are declared to CI. A narrow probe of the half this diff touches: the built driver-turso dist/index.js loads under require and dist/index.mjs under import, and both export TursoDriver (exit 0).
  • --ran reconciliation: ✓ 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.
  • CLI integration tier: declared to CI. No spawn entry or CLI file is touched.

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.
  • Dialect axis: 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 remote os migrate plan used 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), a minor bump for @objectstack/driver-turso, a BREAKING banner, and the ADR-0087 disposition not-required (no-migration-prescription). Nothing authorable is removed or renamed, and setDeferredDdl keeps its name and signature. The claim carries bare Clause-②: 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.
  • External callers of setDeferredDdl on a remote TursoDriver outside 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.
  • Two reproducible defects surfaced during the measurement. They are not fixed here and are reported to the seat for filing: the remote batch door's missing read-coercion registration and backfill, and remote detectManagedDrift() reading the dummy Knex connection.

Generated by Claude Code

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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-turso, touching 3 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via TursoDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via TursoDriver (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4112752ec3eeccf52623103ddb7f82bd5587190fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from a341395ae0152ea42e7f68da50c038090fab09da — the merge of head 67be9850fd360bec80eb28c672e4da42800cf855 into base 4112752ec3eeccf52623103ddb7f82bd5587190f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4112752ec3eeccf52623103ddb7f82bd5587190f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 67be9850fd360bec80eb28c672e4da42800cf855

Rendered by an isolated at-tier reviewer subagent that was fed the card, its thread and this PR only, and adopted by the domain:engine#1 seat. Merge-base 1cacfe4a42; three files, +401/−0.

① Derived judgments

  • (a) Every arming caller goes through TursoDriver.setDeferredDdl → right. On origin/main, setDeferredDdl(true) has one non-test call site, DeferSchemaDdlPlugin.init in packages/cli/src/utils/schema-migrate.ts. That plugin is armed by five commands: migrate plan, apply, duplicates, account-issuer and multi-value-columns. findSqlDriverVia finds the Turso driver through SQL_DRIVER_SERVICES, and the kernel rethrows the init throw unwrapped, so every one of the five surfaces the message. Through the CLI a Turso URL without syncUrl is always remote.
  • (b) The refusal comes before any statement and leaves the driver un-armed → right. It throws before super.setDeferredDdl, in Phase 1, before ObjectQLPlugin.start() syncs. isRemote is fixed at construction. Pinned by the un-armed batch-door test and the zero-statement assertion.
  • (c) Nothing that worked correctly is newly refused → right. An ordinary boot never calls the setter, setDeferredDdl(false) is accepted, and local / replica defer end to end (pinned: preview and flush). isRemote is the same discriminator every other remote arm of the class uses. The remote apply and multi-value-columns --apply are now refused where they used to perform boot DDL unconfirmed, which is the confirm-before-change promise kept. Callers outside this repo are NOT MEASURED: the sibling repository answers 403 from this session. The BREAKING banner is the channel for them.
  • (d) Error surface → right. NOT_IMPLEMENTED / 501 is an existing StandardErrorCode, and it is the exact envelope of the two sibling refusals in the same file (refuseRemoteAutonumber, refuseRemoteTransaction). The helper is module-private and src/index.ts is untouched: no new export, no new code.
  • (e) The runtime message → acceptable. It carries no tracker number, since the card id lives in the docblock. Its "Until this change…" sentence is a past fact an operator needs (an earlier plan may already have altered this database). Both sibling refusals in the file use the identical construction, so this PR follows the file's convention. The remedy is accurate: a file: URL defers through SqlDriver, and os serve / os start run the additive sync through the batch door. The message promises only the additive sync, which is correct because that door runs no backfill.
  • (f) Tests → load-bearing and honest. The envelope pin asserts code + status + the first sentence. The zero-statement pin (the recorder wraps execute and batch) is paired with a disk-unchanged check. Lit controls cover the remote ordinary boot, the syncSchema backfill, and local / replica via describe.each. The ablation came out as predicted, 3 failed | 5 passed. The characterisation states its instrument's limits: the stub does not model the network, and the end-to-end binary was not run.

② Semver level

minor on @objectstack/driver-turso, with Clause-②: no (narrowing), a BREAKING banner and adr-0087: not-required (no-migration-prescription)right. This IS a declared narrowing of a published accept set: setDeferredDdl(true) used to resolve on the remote face and now throws, and five documented commands exit 1 where they exited 0. check-changeset-no-major.mjs ships a declared narrowing as minor during the launch window. The adr-0087 reason is true: nothing authorable is removed or reshaped, and there is no FROM → TO rewrite. The shape is identical to sibling PR #19829 (4112752ec3) and precedent PR #19574 (2306a75ecb). The PR body's line agrees with the changeset's. The claim's bare no is recorded as the dispatch-authorised judgement.

③ Boundary flags

Implemented-by: claude/issue-19823-turso-remote-deferred-ddl
Reviewed-by: session_01TEhopqrWQYBycZzyJHpAZr

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant