Repository navigation
fix(runtime,cli): one file-URL filename rule for both hosts — FILE: strips case-insensitively, file://NAME opens NAME on the CLI host - #23862
Conversation
…ameFromUrl The standalone host classifies a database URL as SQLite with `/^file:/i` but its `sqlite` path stripped the prefix with a case-sensitive `/^file:(\/\/)?/`. An upper-case `FILE:./app.db` therefore kept the prefix in the file name, and the boot's directory preparation created a directory literally named `FILE:.` holding the database (#23794); `File:///tmp/x.db` was refused as an unsupported `File://` scheme. The strip is now case-insensitive, as the classifier and the `sqlite-wasm` path already were. The rule is exported from `@objectstack/runtime` so the CLI host can call it instead of keeping a second copy. Pins: `resolveStandaloneDatabase` maps `FILE:./x.db`, `File:///tmp/x.db` and `FILE://x.db` to the files their lower-case spellings name, with the lower-case and bare-path rows as the control; a bare `FILE:` is refused as `file:` is; `createStandaloneStack` over `FILE:./upper/x.db` declares the same filename as `file:` and creates no `FILE:` directory in its cwd. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
`resolveStorageDefinition`'s `sqlite` and `sqlite-wasm` arms kept their own copy of the URL-to-filename rule, a case-sensitive `/^file:/` strip. `OS_DATABASE_URL=file://NAME` on `os serve`'s legacy path therefore opened `//NAME` at the filesystem root while the runtime host opened `NAME` (#23796), and `FILE:./x.db` made a `FILE:.` directory there as well. Both arms now call the runtime's exported `sqliteFilenameFromUrl` after `normalizeDatabaseUrl`, so there is one rule, not two copies. What that rule refuses, this host now refuses too, as the typed `UnsupportedDriverError` that `serve.ts` makes fatal (a plain `Error` is swallowed there and would boot with no storage): an explicit `sqlite` selection over a server URL, which used to create directories named after the URL, and a `file:` naming no file. A blank URL under an explicit `sqlite` selection is read as absent (`:memory:`), as URL inference reads blank. The undocumented `sql://` strip is gone with the copy. Pins in `driver-vocabulary-parity.test.ts`'s alias block: `file://x.db`, `file:///var/lib/objectstack/x.db`, `file://x.wasm.db` and the upper-case spellings open the same file on both hosts, in dev and prod; both hosts refuse the three non-path inputs with one sentence, the CLI's typed. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
…le-url-filename-rule
The refusal the CLI host's SQLite arms re-raise from the runtime's `sqliteFilenameFromUrl` now reads as the runtime host's sentence minus its `[StandaloneStack]` tag, the shape the CLI host's retired-engine URL refusal took when it landed on main. The parity pin for #23796's refusals holds the CLI message equal to the runtime's with the tag removed. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 44 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 0a2247ca442785490dd212583e72cb387926354f && git checkout 0a2247ca442785490dd212583e72cb387926354f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f7e836da1ed344aaa6936b9de8b173d0f326b9ec 48728977bf8c8eab7b7d0c2b4543ab17adc25897 && git checkout -B drift-repro f7e836da1ed344aaa6936b9de8b173d0f326b9ec && git merge --no-ff 48728977bf8c8eab7b7d0c2b4543ab17adc25897
node scripts/docs-audit/affected-docs.mjs --json f7e836da1ed344aaa6936b9de8b173d0f326b9ec |
Contract reviewServed-tier: The face: the ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
席位核对ACCEPT · #23794 + #23796 (folded) ·
Landing: once every check on the head is green or an expected skip, this seat flips the PR ready and arms auto-merge through the relay, then follows it to Generated by Claude Code |
Fixes #23794
Fixes #23796
Clause-②: yes
What changed
One rule turns a SQLite database URL into a file name, and both hosts call it.
FILE:database URL is classified as SQLite but its prefix is not stripped, soOS_DATABASE_URL=FILE:./app.dbcreates a directory literally namedFILE:.and a database inside it #23794's half (commit9c5853767).sqliteFilenameFromUrlinpackages/runtime/src/standalone-stack.tsstripsfile:/file://case-insensitively on itssqlitepath, asdetectDriverFromUrlclassifies (/^file:/i) and as itssqlite-wasmpath already stripped. The function is now exported from@objectstack/runtime(one name added to the existing standalone-stack export line inpackages/runtime/src/index.ts). Nothing else instandalone-stack.tsmoved: notdetectDriverFromUrl, notcreateStandaloneStack'smkdirSyncarms, not its config schema.OS_DATABASE_URL=file://NAMEopens//NAMEat the filesystem root on the CLI host, where the runtime host opens./NAME(thefile:twin of thesqlite://alias #23633 fixed) #23796's half (commits71b52fdbf,48728977b).resolveStorageDefinition'ssqliteandsqlite-wasmarms inpackages/cli/src/utils/storage-driver.tsdrop their local copy (a case-sensitive/^file:/strip, plus/^sql:\/\//on the sqlite arm and/^wasm-sqlite:\/\//on the wasm arm) and call one module-private helper,sqliteFileFor:normalizeDatabaseUrl, then the runtime'ssqliteFilenameFromUrl. A URL the rule refuses is re-raised asUnsupportedDriverError(recognized,driverType= the kind) carrying the runtime host's sentence minus its[StandaloneStack]tag, the shape the CLI host's retired-engine URL refusal took on main. The class is load-bearing:serve.tsre-throwsUnsupportedDriverErrorand silently swallows every other construction error, so passing the runtime's plainErrorthrough would have booted with no storage at all..changeset/23794-file-url-filename-rule.md:@objectstack/runtimeminor (the new export,Clause-②: yes (widening)),@objectstack/clipatch.#23794's open question, measured
The card left this unmeasured: does the CLI host strip the prefix the same way? It does not. At base
3fed1eb00,OS_MODE=off OS_DATABASE_URL=FILE:./urd.db os serve --devmade aFILE:.directory on the CLI host as well (listing below). The one shared rule fixes both hosts.Disk proof at the public door
Every probe ran in a scratch directory outside any repo tree, with the built CLI (
node packages/cli/bin/run.js) andOS_HOMEpointed into that scratch directory. Before = base3fed1eb00; after = this branch (CLI closure rebuilt).OS_DATABASE_URL=FILE:./urd.db os serveFILE:.holdingurd.db(700416 bytes)./urd.db(700416 bytes), noFILE:entryOS_DATABASE_URL=file:./urd.db os serve./urd.dbOS_MODE=off):OS_DATABASE_URL=file://proc/os23794-probe.db os serve --devServer is ready, then[driver-sqlite-wasm] Writing //proc/os23794-probe.db to disk failed (ENOENT …)for the main and telemetry files; nothing created under the cwd./proc/os23794-probe.dbplus its telemetry sibling under the cwd, no write failuresOS_DATABASE_URL=FILE:./urd.db os serve --devFILE:.holdingurd.dbandurd.telemetry.db*./urd.dband./urd.telemetry.db*, noFILE:entryOS_DATABASE_DRIVER=sqlite OS_DATABASE_URL=postgres://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5999/db os serve --devServer is ready; directoriespostgres:/PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5999/created, holdingdbanddb.telemetry.db*✗ sqlite driver was selected but the URL does not look like a file path (its scheme is "postgres://"). Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.; nothing created;PLACEHOLDERappears 0 times in the outputThe runtime-host boots stop afterwards on the unrelated production prerequisite
OS_SECRET_KEY, before and after alike, as the card recorded. The/procaim of the CLI-host probe is kept so nothing could be written under/.What each host answers, before and after (mechanism assumption 1)
resolveDriverType+resolveStorageDefinition(…, { isDev: true })for the CLI host andresolveStandaloneDatabasefor the runtime host, sameOS_DATABASE_URL. Before = base; after = this branch.kind (file); a refusal shows the thrown class.file:./x.db./x.db./x.db./x.db./x.dbfile://x.db//x.dbx.dbx.dbx.dbfile:///abs/x.db///abs/x.db/abs/x.db/abs/x.db/abs/x.dbFILE:./x.dbFILE:./x.db./x.dbFILE:./x.db./x.dbFile:///tmp/x.dbFile:///tmp/x.db/tmp/x.db"File://"/tmp/x.dbsqlite:x.dbx.dbx.dbx.dbx.dbsqlite://x.dbx.dbx.dbx.dbx.db./x.db./x.db./x.db./x.db./x.dbx.wasm.dbx.wasm.dbx.wasm.dbx.wasm.dbx.wasm.db:memory::memory::memory:file://x.wasm.db//x.wasm.dbx.wasm.dbx.wasm.dbx.wasm.dbFILE:x.wasm.dbFILE:x.wasm.dbx.wasm.dbx.wasm.dbx.wasm.dbWASM-SQLITE://w.dbWASM-SQLITE://w.dbw.dbw.dbw.dbwasm-sqlite://:memory:postgres://u:p@h/dbpostgres://u:p@h/db"postgres://""postgres://""postgres://"sql://x.dbx.db"sql://""sql://""sql://"libsql://pin.turso.iolibsql://pin.turso.io"libsql://"file::memory::memory::memory:Behaviour changes on the CLI host beyond
file://andFILE:(reported, not hidden)sqliteselection over a URL with aSCHEME://that is notfile:(postgres://,libsql://,wasm-sqlite://, any other), and afile:/FILE:naming no file. The first used to boot "ready" and create directories named after the URL, credentials included (measured above); the second declared an empty file name. Both are what the runtime host already refused for the same input. Remedy in the changeset: pointOS_DATABASE_URLat a file, or select the driver the URL is for.sql://strip is gone. It was reachable only under an explicitsqliteselection (URL inference refusessql://as an unknown scheme).git grepover the tree finds no othersql://spelling. It is now refused like any other non-file scheme; the changeset says to writefile:.sqlite/sqlite-wasmselection is read as absent::memory:. It used to be an empty file name. Blank is absent the wayinferDriverTypeFromUrlreads it; without this the rule would refuse it with "it names no file afterfile:", a sentence about something the operator never wrote.wasm-sqlite://with nothing after it is:memory:the same way, by the runtime rule's own empty answer for the WASM kind.Public surface (mechanism assumption 2)
packages/runtimehas no API-surface snapshot or export ledger: itspackage.jsondeclaresbuild,dev,typecheck,check:test-typecheck,gen:test-typecheck-debt,test,test:repo; theapi-surface/snapshot andcheck:exported-anyarepackages/spec's. Repo gates reading built output (check:dts-closure,check:published-files,check:dual-build-cjs-loads,check:lean-entry-closure,check:published-readme-exports) take no registration for a new named export.check:exported-any-returnswould flag ananyreturn; the export returnsstring.git grepfinds noexport * from '@objectstack/runtime'in this repo or in../objectui, so no consumer can meet a star re-export ambiguity, and no other symbol is namedsqliteFilenameFromUrl. The one consumer that imports it,@objectstack/cli, was typechecked against the rebuilt.d.ts(below).Verification
Local runs are on the head named in each line; CI runs the full farm.
packages/runtime):resolve-project-database.test.ts(anit.eachoverFILE:./x.db,File:///tmp/x.db,FILE://x.db,FILE:./x.wasm.dband the lower-case / bare-path CONTROL rows, assertingdriverandsqliteFile; a bareFILE:refused as plainErrorwith the names-no-file sentence) andstandalone-stack.test.ts(createStandaloneStackfrom an empty cwd overFILE:./upper/x.dbdeclares./upper/x.db, equal to thefile:spelling, and the cwd holds no entry matching/^file:/i).driver-vocabulary-parity.test.ts, alias block):file://x.db,file:///var/lib/objectstack/x.db,file://x.wasm.db,FILE:./x.db,File:///var/lib/objectstack/x.db,FILE:./x.wasm.db(plus the lower-case and bare-path controls) open the same file on both hosts, dev and prod; both hosts refuse explicitsqliteover a credentialedpostgres://URL,file:andFILE:, the CLI's refusal anUnsupportedDriverErrorwithrecognized: true,driverType: 'sqlite', a message equal to the runtime's minus its tag, never the runtime's verbatim, and with no credential; the blank-URL reading on the CLI host.48728977b(after mergingorigin/mainat1eac7ad75; every heavy run throughscripts/pm/os-verify-lock.sh, verdict lines quoted):pnpm --filter @objectstack/runtime test(vitestlocalproject):Test Files 399 passed (399),Tests 5990 passed | 19 skipped (6009),VERDICT command-exit 0.pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 src/utils/driver-vocabulary-parity.test.ts src/utils/storage-driver.test.ts src/commands/database-driver-allowlist.pin.test.ts src/utils/dev-self-heal-host-parity.test.ts:Test Files 4 passed (4),Tests 188 passed (188),VERDICT command-exit 0. The allowlist pin is in the set because it usesresolveStorageDefinition('sqlite', { databaseUrl: 'libsql://pin.turso.io' })as an oracle: it now answers through the new typed refusal (recognized: true,driverType: 'sqlite') and still derivessqlite. The CLIintegrationtier is declared to CI: the diff touches no integration-tier file and no spawn entry.pnpm --filter @objectstack/runtime typecheck && pnpm --filter @objectstack/cli typecheck:VERDICT command-exit 0; bothcheck:test-typecheck: OKwith their debt ledgers held.pnpm exec eslint --no-inline-configover the six changed.tsfiles: exit 0, no output. The repo-wide lint run is the PR'sLint & Repo Gatescheck-run.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(65 commands), each run once with its exit code captured before any pipe: 65 of 65exit 0. Reconciled with--ran:65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN(a DERIVED zero — all 65 recorded an exit code and none of them is 3). Alsopnpm --filter @objectstack/spec check:generated(spec moved onmain's side of the merge): exit 0,All 14 generated artifacts are up to date.71b52fdbf, before the merge and the tag-strip follow-up, neither of which touches the mutated lines), each throughnode scripts/ablation-replace.mjs(anchor hit 1 time, blob changed, restored to theHEADblob withgit diff HEADempty). Both subjects are reached through relative source imports (the runtime pins import./standalone-stack.js, the parity file imports./storage-driver.js), so neither needed adist/rebuild./^file:(\/\/)?/iback to/^file:(\/\/)?/on thesqlitepath:Tests 5 failed | 63 passed (68). Red: theFILE:./x.db,File:///tmp/x.db,FILE://x.dbrows, the bare-FILE:refusal, and thecreateStandaloneStackcase (expected 'FILE:./upper/x.db' to be './upper/x.db'). The lower-case and bare-path CONTROL rows stayed green.return sqliteFilenameFromUrl(url, kind);replaced by the old local stripreturn url.replace(/^file:/, '');:Tests 9 failed | 90 passed (99)in the parity file. Red: the sixfile:/// upper-case rows and the three refusal rows. Thesqlite:/sqlite://rows, the lower-case and bare-path controls and the blank-URL case stayed green.71b52fdbf): with the CLI call changed tosqliteFilenameFromUrl(url, 'postgres'),tsc --noEmitinpackages/clifailed witherror TS2345: Argument of type '"postgres"' is not assignable to parameter of type '"sqlite" | "sqlite-wasm"', so the CLI read the rebuilt runtime.d.ts; restored and re-checked as above.Acceptance notes
sqlite-wasmkind refuses nothing. On the runtime host,OS_DATABASE_DRIVER=sqlite-wasm OS_DATABASE_URL=postgres://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5999/db os serve --devprintedServer is readyand createdpostgres:/PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5999/dbunder the cwd: credentials in a directory name, against the module header's rule that a URL is never treated as a file path to make bogus directories. The CLI host's wasm arm gave the same file name before this PR and gives it now by the same rule (table rowssqlite-wasm · libsql://…). Not fixed here: the refusal for the WASM kind is its own wording and behaviour decision, and this card's surface is thefile:strip.sqliteselection: the CLI host's legacy path opens:memory:, the runtime host its unified default file. That difference predates this PR (rows "unset" and "empty or blank" above) and is not judged here.#23796recorded a[driver-sqlite-wasm]prefix on write-failure lines while the banner readSqlDriver(better-sqlite3); the base-tree probe here showed the same pairing. Not investigated.origin/mainmoved toa31ff2eb5after the merge, by two commits that touch neither changed file norstorage-driver.ts's neighbours (scaffoldcreate/initand translations); not merged, the merge queue rebuilds on the currentmain.Overlap, in non-closing sentences: #23765 (
detectDriverFromUrl, another dev's) is untouched here, as are its parity rows. #23737'smkdirSyncarms increateStandaloneStackare untouched. #23792's memory-URL refusal landed on main as PR #23840 before this PR opened;origin/mainwas merged at1eac7ad75and both sides kept. #23633'ssqlite://alias fix is the twin this completes forfile://.Generated by Claude Code