Repository navigation
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 |
…swering "no drift" (objectstack-ai#19891) Fixes objectstack-ai#19845 Clause-②: no (narrowing) ## What changed A remote-mode `TursoDriver` now refuses `detectManagedDrift()` with the transport's `NOT_IMPLEMENTED` / `501` envelope, with or without explicit objects, where it used to answer `[]`. The local and embedded-replica modes inherit the Knex detector unchanged. - `packages/drivers/driver-turso/src/turso-driver.ts`: `refuseRemoteDriftDetection()` beside the deferred-DDL refusal, and a `detectManagedDrift` override in the schema-management section. The override spells the base's parameter shape key for key, as `check:object-def-param-keys` arm C requires. - `packages/drivers/driver-turso/src/turso-remote-drift-detection-refusal.test.ts`: the pin, with local and replica controls. - `.changeset/19845-turso-remote-drift-detection-refusal.md`: `minor`, BREAKING banner, `adr-0087: not-required (no-migration-prescription)`. This is the shape PR objectstack-ai#19842 used for deferred DDL on this face. This is the dispatched landing site. `packages/cli` is untouched. ## Zone 1: the chain is reachable Read on base `1f89ba0d70`: - `packages/cli/src/commands/serve.ts`: with `OS_ARTIFACT_URL` set, `pinnedArtifact` boots through `createDefaultHostConfig`, which calls `createStandaloneStack`. The `com.objectstack.cli.artifact-boot-migration-gate` plugin then runs `runArtifactBootMigrationGate({ driver: findSqlDriverForKernel(kernel) })` on `kernel:ready`. - `packages/runtime/src/standalone-stack.ts`, turso arm: a `libsql://` URL declares the `default` datasource with the Turso factory. `DefaultDatasourcePlugin.init` registers it as `driver.` plus the engine's default driver name. That name is the driver's `name`, `com.objectstack.driver.turso`. - `packages/cli/src/utils/schema-migrate.ts`: `SQL_DRIVER_SERVICES` lists `driver.com.objectstack.driver.turso`. Its duck-type check needs `detectManagedDrift` and `applyMigrationEntries`, and the driver inherits both. Measured once and not committed. The instrument was the real `findSqlDriverForKernel` and `runArtifactBootMigrationGate` from `packages/cli/src/utils/`, over a kernel stub that exposes the driver under `driver.com.objectstack.driver.turso`. Table `t` is synced, then an extra physical column `legacy` is added. | driver | found | gate verdict before the fix | gate verdict after the fix | |:--|:--|:--|:--| | local (`file:` URL) | yes | `ok: false`, 1 destructive entry, so the boot is refused | unchanged | | remote (synced through the batch door) | yes | `ok: true`, 0 entries, **no warning** | `ok: true`, 0 entries, plus the warning `⚠ Could not check the physical schema against the artifact (Schema drift detection is not supported by the Turso REMOTE transport …). Boot continues; …` | Not measured end to end: the `os serve` binary against a live remote libSQL endpoint. The boot-sync door is the batch door, as PR objectstack-ai#19863 measured on an `ObjectKernel` boot. ## A1: reproduced Setup: the `libsql` SQLite double; table `t` declared with `name: text`, plus an extra physical column `legacy`. | face | call | answer | |:--|:--|:--| | local | `detectManagedDrift()` | `t.legacy`: `unmapped_column`, op `drop_column`, `destructive` | | remote | `detectManagedDrift()` | `[]` | | remote | `detectManagedDrift([{ name: 't', fields }])` | `[]` | The remote table's physical columns were `id, created_at, updated_at, name, legacy`, so the drift was on disk. ## A2: how the gate treats a driver that cannot judge - **(i) A "cannot judge" channel exists.** `runArtifactBootMigrationGate` wraps `driver.detectManagedDrift()` in a `try`. On any throw it warns `Could not check the physical schema against the artifact (MESSAGE). Boot continues; run 'os migrate plan' to verify.` and returns `ok: true` with nothing applied. The CLI's own suite pins this: `artifact-boot-migration.test.ts`, "warns and continues when drift detection itself fails". I found no capability flag and no sentinel. The only other channel is `applyMigrationEntries`'s `skipped` list, for a driver that declines an op. - **(ii) A thrown `NOT_IMPLEMENTED` makes the gate warn and continue.** The boot does not stop and nothing crashes. This was measured after the fix; see the Zone 1 table. - **(iii) Real remote introspection is not cheap. The differ can be reused, but the reads that feed it cannot.** - The differ half, measured: tables built by the remote DDL (`plain`, and `rich` with 16 field types, a field-level `unique` and a declared index) were judged by a local Knex connection to the same SQLite file. `detectManagedDrift` reported 0 entries for each, the same as a local-face control. So the shared differ gives no false drift on remote-built tables. - The read half: every read that feeds the differ goes through `this.knex`. That covers `schema.hasTable`, `columnInfo` plus `PRAGMA table_info` ordering, the SQLite arm of `introspectIndexes` (`sqlite_master`, `index_list`, `index_info`) and `probeNullSafeUniqueDuplicates`. A remote version needs a second copy of each of those arms. - The remote managed registry would have to carry fields and indexes (see A4). - Once detection returns entries, the gate calls `applyMigrationEntries` on the safe ones. That inherited Knex path runs on the same placeholder. - A destructive verdict refuses the boot and names `os migrate apply --allow-destructive`. On this face that command refuses at `setDeferredDdl` (PR objectstack-ai#19842), so the refusal text would need a CLI change. The CLI is outside this card's surface. ## A3: the route taken (i) exists, and a refusal through it does not stop any remote boot: the gate warns and continues. (iii) is not cheap and would pull in a CLI change. So this PR takes the loud refusal, declared as a narrowing. The refusal message names the working remedy, running `os migrate plan` against a local SQLite copy (a `file:` URL). The gate embeds that message in its own warning line. ## A4: `managedObjectFields` Yes, the no-argument `detectManagedDrift()` needs it. That is the gate's call, and it iterates `managedObjectFields` / `managedObjectIndexes`. On the remote face these stay empty, because `registerRemoteFieldMetadata` → `registerExternalObject` fills the read-coercion registries and `remoteManagedObjects` only. It is deliberately **not** fed here. The explicit-objects call answered `[]` too, because the Knex `hasTable` probe reads the placeholder, so feeding the registry alone changes no answer. `managedObjectFields` also has other readers: `getManagedFields`, the base `paginationTieBreaker` and `planMediaColumnMove`. A real remote detector would need a remote registry of fields plus indexes. The remote doors already receive `indexes` on the object definition. ## Tests - New pin `turso-remote-drift-detection-refusal.test.ts`, 6 cases: - local and embedded-replica controls, no-argument and explicit calls: each still reports `t.legacy` as `unmapped_column` / `drop_column` / `destructive`; - remote, both call shapes: the call rejects with `code: 'NOT_IMPLEMENTED'`, `status: 501` and the operator-facing first sentence, and sends zero statements to the database. - `pnpm --filter @objectstack/driver-turso test`: 58 files, 1317 tests passed. - `pnpm --filter @objectstack/driver-turso typecheck`: exit 0. `tsc --listFiles` compiles all 58 test files, including the new one. **Ablation**, on HEAD `7d9a7e38c7`, through `scripts/ablation-replace.mjs`: - Mutation: the anchor `if (this.isRemote) refuseRemoteDriftDetection();` goes from x1 to x0, the marker from x0 to x1, and the blob from `4f2aabfcd133` to `87fe978db7c9`. - Result: the 2 remote cases turned red with `expected a refusal, got an answer: []`, which is the original defect. The 4 controls stayed green. - Restore: the blob equals HEAD (`4f2aabfcd133`), and `git diff HEAD` is empty. - No dist step was involved: the test imports `./turso-driver.js` from source. ## Gates, on HEAD `7d9a7e38c7` - `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived 61 commands. Every one exited 0. - `--ran` verdict: `✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED`. - The three dist-reading gates (`check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`) first exited 3 (PREREQUISITE NOT MET). They exited 0 after a workspace `turbo run build`. - `check:object-def-param-keys` went red once, on arm C (the override derived the parameter type). It is corrected in `6d1e4e9619` and green on HEAD. - `node scripts/check-issue-citations.mjs` (live): exit 0, `every citation this change adds resolves`. - `pnpm check:driver-conformance`: exit 0, `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt`. - Targeted eslint, measured: - Scope: the 2 changed TS files, both inside the `**/*.{ts,…}` block of `eslint.config.mjs`, run with `--no-inline-config --format json`. - Result: 2 files, 0 errors, 0 warnings. - Why the narrowing is sound: the config enables no type-aware linting (`parserOptions.project` and `projectService` are both unset for these files), so this diff cannot move any untouched file's verdict. The changeset `.md` is outside eslint's population. - Stale-tree note: `origin/main` gained 3 commits after the branch point (metadata, objectql, `scripts/pm/close-cards.mjs`). They are disjoint from this diff, and the derived list came out identical. ## Acceptance notes - The gate's warning ends with the CLI's generic `run 'os migrate plan' to verify`. Pointed at the remote URL, that command refuses, because it arms deferred DDL. Its refusal names the local-copy route, and the driver message embedded earlier in the same warning names that route first. Owner: `domain:cli` (`packages/cli/src/utils/artifact-boot-migration.ts`). Noted, not filed. - After this PR, remote `applyMigrationEntries` still runs the inherited Knex path on the placeholder. No caller in this repo reaches it: the gate gets no entries from a remote detector, and `os migrate apply` is refused at `setDeferredDdl`. There is no repro, so this is noted only. ## Out-of-scope findings, for the seat to file 1. **class (a).** Remote `planMediaColumnMove()` answers `{ plans: [], refusals: [] }`. - Measured on the SQLite double: table `m` with `doc: file` and `pic: image` was synced through the batch door, and its physical `TEXT` columns are present. The remote face returned 0 plans and 0 refusals. The local control returned 2 `unquote` plans. - It is the same class as this card: an inherited schema read on the remote face that answers from the placeholder or the empty `managedObjectFields`. - Reach, read from source and not run: `os migrate files-to-references` boots without deferral and calls `stack.driver.planMediaColumnMove()`. - Dedupe words: `turso remote planMediaColumnMove empty` · `files-to-references remote turso nothing to move` · `remote placeholder knex inherited schema read`. 2. **class (a).** Replica mode built from a remote `url` plus `syncUrl` runs every Knex CRUD against a process-local `:memory:` database. - `detectMode` answers `replica` for this pair, and the last branch of `toKnexConfig` gives it a `:memory:` connection. - Probe on the SQLite double: a table synced and a row created through the driver read back through the driver, while the libsql client's database held no tables at all. - Dedupe words: `turso replica remote url syncUrl memory` · `embedded replica knex memory writes lost` · `turso replica mode libsql url syncUrl`. --- _Generated by [Claude Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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