Repository navigation
fix(driver-turso,cli): the v18 organization-ownership ceremony runs on a Turso remote database - #23843
Conversation
…ex and column-drop entries The Turso REMOTE face refused detectManagedDrift and applyMigrationEntries, which left the v18 organization-ownership ceremony with no remote path. The detector now runs the shared differ over reads sent to the remote catalog (pragma_table_info, sqlite_master, pragma_index_list/info, the ADR-0120 D4 duplicate probe), against the declarations every remote schema door and the DDL-free registerObjectMetadata record. The apply door runs drop_index, create_index, recreate_index, replace_unique_index and drop_column as one libSQL write batch each, verified by read-back; other column entries need the SQLite rebuild and are skipped and logged. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
…ync on a transport that cannot defer DDL bootSchemaStack gains deferredDdlUnsupported: 'skip-schema-sync'. When the primary datasource resolves to the Turso REMOTE transport (asked of TursoDriver.detectMode), the boot passes skipSchemaSync to the standalone stack instead of arming deferral on that driver: no DDL is attempted, every object reaches its driver through the DDL-free registerObjectMetadata, and every other datasource is still armed. measureComposedCoverage binds the composed host's objects DDL-free on such a boot. The ceremony's plan and apply declare the posture, and the apply reads the ledger's presence from the catalog when the boot measured no table absent. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
…ies, against the local face over one file The drift-detection refusal suite becomes the detection suite: a clean remote-synced database drifts nowhere on either face, each drifted shape reads alike on both, and registerObjectMetadata fills the registry the no-argument call walks. A new suite applies the remote report's entries over a real libSQL client and reads each outcome back through a second connection: drop_column with its indexes, the rolled-back refusal, drop/create/recreate index, the D4 re-probe, the skipped rebuild-only entry, and convergence. The remote face's config now names its url, so the CLI's database label tells remote databases apart. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
…, and the DDL-free coverage binding Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
…s two drift members Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
📓 Docs Drift CheckThis PR changes 3 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 46 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 53854f3f87b584651bbbad95c4c944930a2969d8 && git checkout 53854f3f87b584651bbbad95c4c944930a2969d8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1eac7ad751e833115544be405993efced6a726d3 26b6acefac48d6afbc4706c33a7835b285b1bed1 && git checkout -B drift-repro 1eac7ad751e833115544be405993efced6a726d3 && git merge --no-ff 26b6acefac48d6afbc4706c33a7835b285b1bed1
node scripts/docs-audit/affected-docs.mjs --json 1eac7ad751e833115544be405993efced6a726d3
|
Contract review: CHANGES REQUESTED (head
|
|
Pointer to the review above: the two follow-ups are filed as #23847 (the remote face honours |
…nd-boot answer on a Turso remote primary The remote face's schema sync creates no declared NOT NULL, so a healthy remote database reads as destructive tighten_not_null, and the gate's remedy cannot run on that transport. TursoDriver declares driftReportCaveat (a sentence on the remote face, null elsewhere) and the gate, reading that declaration, warns and boots, applying and refusing nothing. Also from the contract review: the embedded-replica drift control is back beside the remote suite; the remote detector reads its catalog in three round trips per call; catalog reads refuse a non-row answer; a created index counts as applied only with the uniqueness and key columns asked for; the parity suite is cited by its real name. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
… two opt-ins, transportRefusesDeferredDdl and the primary exemption Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
…urso remote primary The maintainer's ruling on the contract review: say plainly that the boot migration gate does not judge a remote database. It warns and boots, applies nothing and refuses nothing, and decides that from the driver's declared driftReportCaveat. It stays that way until the remote sync honours storage.notNull and os migrate apply has a remedy on that transport. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
Both are cited as follow-ups that this change does not close. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
Contract reviewServed-tier: TIER_DEFAULT
① Contract surface. No
The new CLI symbols ( ② Semver. Minor for driver-turso, cli and runtime, matching the changeset. On a Turso remote primary the ③ Round-1 flags resolved:
The PR body is updated to round 2. Follow-ups #23847 and #23848 stay OPEN. Non-blocking, recorded for later:
PASS |
Fixes #23816
Clause-②: yes (the remote face's refused drift and migration doors become accepted ones; triage: not the protocol-first class unless a spec key moves)
Summary
The v18 organization-ownership ceremony (
os migrate organization-ownership, the plan and--apply --backup-taken) now completes on a Turso database reached over the REMOTE transport, which is the transport every hosted tenant uses. It was measured end to end on a realsqld(libsql-server 0.24.32), reached aslibsql://127.0.0.1:PORT?tls=0.Two things stopped it, and both are in this repository:
deferSchemaDdlunconditionally, and the remote face refuses that.bootSchemaStackgainsdeferredDdlUnsupported: 'skip-schema-sync'. When the PRIMARY datasource resolves to the Turso remote transport (the boot asksTursoDriver.detectModeabout the URL it resolved), the boot passesskipSchemaSyncto the standalone stack instead of arming the deferral on that driver.registerObjectMetadata;measureComposedCoveragebinds a composed host's objects the same way on such a boot.os migratecommand still refuses on the remote face: their dry runs list held-back schema work, which a boot that holds nothing back cannot list.detectManagedDrift()answers. The verdict is the shared differ's. Its physical-schema reads go to the remote catalog:pragma_table_info,sqlite_master,pragma_index_list/pragma_index_info, and the ADR-0120 D4 duplicate probe.registerObjectMetadata.applyMigrationEntries()appliesdrop_index,create_index,recreate_index,replace_unique_indexanddrop_column. Each entry runs as ONE libSQLwritebatch:drop_columndrops the indexes keyed on its column in the same transaction;appliedonly after a read-back.skippedand logged.create_index,recreate_index,replace_unique_index) is read back by its live unique flag and key-column order, not by its name alone.sqlite_masterread, thenpragma_table_info/pragma_index_listin one read batch, thenpragma_index_infofor the indexes whose DDL does not parse. Batches are chunked at 200 statements, so it takes 3 round trips up to 100 tables and one more per further 100.driftReportCaveat: its drift report cannot be trusted for a boot verdict yet, because the remote sync creates no declaredNOT NULL(driver-turso remote face: create table and add column never emit NOT NULL, so storage.notNull is unenforced and every such field reads as destructive drift #23847) andos migrate applyrefuses there (os migrate apply cannot run on a Turso remote database (it arms deferred DDL), so destructive drift on a remote primary has no CLI remedy #23848). The local and embedded-replica faces declarenull.There is one more change. The remote driver's
confignow carries itsurl, so the CLI labels a remote databaselibsql://host:port(credential-free) instead ofbetter-sqlite3. The ceremony writes that label into its plan and refuses an apply whose plan names another database. While every remote database was labelledbetter-sqlite3, that refusal could not tell two tenants apart.The remote face does NOT implement deferred DDL. A remote preview would also have to preview the remote canonical-temporal and codec-residue backfills, which have no read-only probe today. The embedded-replica route was not taken (the card measured it as a dead end).
Evidence — real
sqld,libsql://127.0.0.1:PORT?tls=0The v17 fixture, built once, snapshotted, and copied fresh for each run:
os startboot against the server: the additive sync on the remote face, 70 objects;organization_idplus its tenant index onsys_joband onsys_metadata_activation, and twosys_jobrows.Positive control, unchanged
main(1ecb08a1). The plan exits 1:With the fix: first measured at
39b9abdd, then re-run atcc73b809after the contract-review round.cc73b809is code-identical to the head26b6acef. Both runs read the same:os migrate organization-ownership --jsonc0790dff…before =c0790dff…after, 341 objects / 12 rowsdatabaselibsql://127.0.0.1:38187(it wasbetter-sqlite3before the label change)--apply --plan-file … --backup-taken --jsoncomplete: true·blocking: 0·advisory: 0; every stepdone;column-drops{"dropped": 2, "due": 2}sys_job.organization_id,sys_metadata_activation.organization_id: absentidx_sys_job_organization_id,idx_sys_metadata_activation_organization_id: absentsys_migrationrowadr-0131-organization-ownership:applied_atandverified_atsetsys_job: 2 rows, keptAn earlier run over a fixture that also put the column on
sys_audit_logended withblocking 1. The cause is not the transport: the composition booted from an empty project declares no object forsys_audit_log, so the step reportsnot-declaredthere. The same happens onfile:. The fixture was corrected to tables that composition declares.Ablation. Both runs used
scripts/ablation-replace.mjs(WRAP mode); each mutation was proven to land on disk, restored toHEAD, and the tree checked clean.if (!this.isRemote || this.isRemote) return super.detectManagedDrift(objects);). driver-turso was rebuilt, andablation-dist-preflightfound the marker indist/index.jsanddist/index.mjs.{"error":"apply_failed","detail":"Unable to acquire a connection"}.--absent(marker gone from all 6 built files, tree clean); 19/19 and 1/1 green.boot_failed,Deferred schema DDL is not supported by the Turso REMOTE transport …). Restored blob =HEAD.The contract-review round added three ablations, made the same way, each restored to
HEADwithgit diff HEADempty:deferredDdlUnsupported: 'skip-schema-sync'removed fromorganization-ownership.ts:organization-ownership.remote-posture.test.tsred, 1 failed (the apply-boot case).DeferSchemaDdlPlugin.armremoved:schema-migrate.remote-posture.test.tsred, 2 failed. The no-exemption control stayed green.artifact-boot-migration.ts:artifact-boot-migration.test.tsred, 1 failed (the caveat case). The null-caveat control stayed green.Tests (head
26b6acef)@objectstack/driver-turso: typecheck exit 0;vitest run90 files, 2415 passed / 33 skipped.turso-remote-drift-detection.test.tsalso carries:TursoDriverover a drifted file reports the destructivedrop_column, for the no-argument and the explicit-objects call. This is the same-layer replacement for the replica case of the deletedturso-remote-drift-detection-refusal.test.ts;driftReportCaveatcase: a sentence namingNOT NULLon the remote face,nullon local and replica;turso-remote-drift-detection.test.ts(wasturso-remote-drift-detection-refusal.test.ts): the remote face over a real@libsql/clientfile:client, asserted EQUAL to the local Knex face over the same file. It covers:registerObjectMetadataregistry;turso-remote-migration-entries.test.ts: each outcome is read back through a second connection. It covers:drop_columnwith its indexes, and the rolled-back refusal;drop_index,create_indexandrecreate_index, with the D4 re-probe over duplicates;turso-remote-inherited-members.test.ts:applyMigrationEntriesis no longer in the refused set.@objectstack/cli: typecheck (and the test-layer typecheck) exit 0;--project unit299 files, 4453 passed. A targeted--project integrationrun (20 files: everyorganization-ownership*andschema-migrate*integration suite plusunmanaged-tables, the sqld e2e included withOS_TEST_SQLD_BINset) had 182 passed, 0 skipped. It ran locally because this diff touches the boot path.organization-ownership.remote-posture.test.ts(new, unit tier, runs in CI) pins two things: both ceremony boots passdeferredDdlUnsupported: 'skip-schema-sync', and the apply reads its ledger from the catalog when the boot skipped schema sync.schema-migrate.remote-posture.test.ts(new, unit tier, runs in CI):transportRefusesDeferredDdlover 8 resolved databases;registerDriver;artifact-boot-migration.test.ts: the caveat case (warn with the reason; no detect, no apply; the boot proceeds) and the null-caveat control (the same report refuses the boot).organization-ownership.remote-face.integration.test.ts(new) runs the real command, plan then apply, against asqldit starts itself. It is skipped unlessOS_TEST_SQLD_BINnames a sqld binary: it ran green here, and it reads1 skippedwithout the variable.schema-migration-plugins.test.tsgains a case for the DDL-free binding.@objectstack/runtime: typecheck exit 0;vitest run402 files, 6787 passed / 19 skipped.node scripts/pm/dispatch-gates.mjs --ranreports 70 derived families, 69 run with exit 0, and 1 NOT MEASURED:pnpm check:dual-build-cjs-loadsexited 3, PREREQUISITE NOT MET, because it needs a whole-repopnpm buildand only the closure was built here; CI runs it. Two families first read exit 3 for environment reasons and were re-run alone with exit 0:check-plugin-teardown-shape --self-test: the shallow clone lacked its pinned fixture commit;check:i18n-coverage: contention..tsfiles: exit 0.Acceptance notes
packages/cli/src/utils/organization-ownership-*.ts. The boot posture lives one file over:packages/cli/src/utils/schema-migrate.ts(bootSchemaStack, the option and the primary exemption) andpackages/cli/src/utils/schema-migration-plugins.ts(measureComposedCoverage's DDL-free binding), with that file's test.OS_ARTIFACT_URLboot migration gate is unchanged on a Turso remote primary. It still answers "cannot judge": it warns and boots, with nodetectManagedDriftcall, no apply and no refusal. It decides that from the driver's declareddriftReportCaveat, not from the transport-mode string. The contract review measured why it must not judge yet: the remote sync creates no declaredNOT NULL, so everystorage.notNullfield would read as destructive drift, andos migrate applyrefuses on that transport. The gate judges remote databases again only after the follow-ups driver-turso remote face: create table and add column never emit NOT NULL, so storage.notNull is unenforced and every such field reads as destructive drift #23847 and os migrate apply cannot run on a Turso remote database (it arms deferred DDL), so destructive drift on a remote primary has no CLI remedy #23848, which this PR does not close. The local, embedded-replica and other SQL drivers are unchanged.rebuildTenantOverlayIndex:detectManagedDrift([subject]), apply the onerecreate_index, read again) is the shape the new overlay-index test drives on the remote face. It has not been run against cloud's code.sqldis provisioned there. One way to provision it is a devDependency on the npm package that ships the binary (@sqld/linux-x64), which is a dependency decision this PR does not make.Generated by Claude Code