Skip to content

Commit 34b740f

Browse files
fix(runtime,cli): one file-URL filename rule for both hosts — FILE: strips case-insensitively, file://NAME opens NAME on the CLI host (#23862)
Fixes #23794 Fixes #23796 Clause-②: yes ## What changed One rule turns a SQLite database URL into a file name, and both hosts call it. - **Runtime host, #23794's half (commit `9c5853767`).** `sqliteFilenameFromUrl` in `packages/runtime/src/standalone-stack.ts` strips `file:` / `file://` case-insensitively on its `sqlite` path, as `detectDriverFromUrl` classifies (`/^file:/i`) and as its `sqlite-wasm` path already stripped. The function is now exported from `@objectstack/runtime` (one name added to the existing standalone-stack export line in `packages/runtime/src/index.ts`). Nothing else in `standalone-stack.ts` moved: not `detectDriverFromUrl`, not `createStandaloneStack`'s `mkdirSync` arms, not its config schema. - **CLI host, #23796's half (commits `71b52fdbf`, `48728977b`).** `resolveStorageDefinition`'s `sqlite` and `sqlite-wasm` arms in `packages/cli/src/utils/storage-driver.ts` drop 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's `sqliteFilenameFromUrl`. A URL the rule refuses is re-raised as `UnsupportedDriverError` (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.ts` re-throws `UnsupportedDriverError` and silently swallows every other construction error, so passing the runtime's plain `Error` through would have booted with no storage at all. - One changeset, `.changeset/23794-file-url-filename-rule.md`: `@objectstack/runtime` minor (the new export, `Clause-②: yes (widening)`), `@objectstack/cli` patch. ## #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 --dev` made a `FILE:.` 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`) and `OS_HOME` pointed into that scratch directory. Before = base `3fed1eb00`; after = this branch (CLI closure rebuilt). | host · input | before | after | |---|---|---| | runtime host, plain project: `OS_DATABASE_URL=FILE:./urd.db os serve` | directory `FILE:.` holding `urd.db` (700416 bytes) | `./urd.db` (700416 bytes), no `FILE:` entry | | runtime host, control: `OS_DATABASE_URL=file:./urd.db os serve` | `./urd.db` | (unchanged path) | | CLI host (`OS_MODE=off`): `OS_DATABASE_URL=file://proc/os23794-probe.db os serve --dev` | `Server 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.db` plus its telemetry sibling under the cwd, no write failures | | CLI host: `OS_DATABASE_URL=FILE:./urd.db os serve --dev` | directory `FILE:.` holding `urd.db` and `urd.telemetry.db*` | `./urd.db` and `./urd.telemetry.db*`, no `FILE:` entry | | CLI host: `OS_DATABASE_DRIVER=sqlite OS_DATABASE_URL=postgres://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5999/db os serve --dev` | `Server is ready`; directories `postgres:/PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5999/` created, holding `db` and `db.telemetry.db*` | exit 1, `✗ 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; `PLACEHOLDER` appears 0 times in the output | The runtime-host boots stop afterwards on the unrelated production prerequisite `OS_SECRET_KEY`, before and after alike, as the card recorded. The `/proc` aim 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 and `resolveStandaloneDatabase` for the runtime host, same `OS_DATABASE_URL`. Before = base; after = this branch. `kind (file)`; a refusal shows the thrown class. | driver · URL | CLI before | CLI after | runtime before | runtime after | |---|---|---|---|---| | - · `file:./x.db` | sqlite `./x.db` | sqlite `./x.db` | sqlite `./x.db` | sqlite `./x.db` | | - · `file://x.db` | sqlite `//x.db` | sqlite `x.db` | sqlite `x.db` | sqlite `x.db` | | - · `file:///abs/x.db` | sqlite `///abs/x.db` | sqlite `/abs/x.db` | sqlite `/abs/x.db` | sqlite `/abs/x.db` | | - · `FILE:./x.db` | sqlite `FILE:./x.db` | sqlite `./x.db` | sqlite `FILE:./x.db` | sqlite `./x.db` | | - · `File:///tmp/x.db` | sqlite `File:///tmp/x.db` | sqlite `/tmp/x.db` | Error, scheme `"File://"` | sqlite `/tmp/x.db` | | - · `sqlite:x.db` | sqlite `x.db` | sqlite `x.db` | sqlite `x.db` | sqlite `x.db` | | - · `sqlite://x.db` | sqlite `x.db` | sqlite `x.db` | sqlite `x.db` | sqlite `x.db` | | - · `./x.db` | sqlite `./x.db` | sqlite `./x.db` | sqlite `./x.db` | sqlite `./x.db` | | - · `x.wasm.db` | sqlite-wasm `x.wasm.db` | sqlite-wasm `x.wasm.db` | sqlite-wasm `x.wasm.db` | sqlite-wasm `x.wasm.db` | | - · `:memory:` | sqlite `:memory:` | sqlite `:memory:` | sqlite (no file) | sqlite (no file) | | - · `file://x.wasm.db` | sqlite-wasm `//x.wasm.db` | sqlite-wasm `x.wasm.db` | sqlite-wasm `x.wasm.db` | sqlite-wasm `x.wasm.db` | | - · `FILE:x.wasm.db` | sqlite-wasm `FILE:x.wasm.db` | sqlite-wasm `x.wasm.db` | sqlite-wasm `x.wasm.db` | sqlite-wasm `x.wasm.db` | | - · `WASM-SQLITE://w.db` | sqlite-wasm `WASM-SQLITE://w.db` | sqlite-wasm `w.db` | sqlite-wasm `w.db` | sqlite-wasm `w.db` | | - · `wasm-sqlite://` | sqlite-wasm (empty name) | sqlite-wasm `:memory:` | sqlite-wasm (no file) | sqlite-wasm (no file) | | sqlite · `postgres://u:p@h/db` | sqlite `postgres://u:p@h/db` | **UnsupportedDriverError**, scheme `"postgres://"` | Error, scheme `"postgres://"` | Error, scheme `"postgres://"` | | sqlite · `sql://x.db` | sqlite `x.db` | **UnsupportedDriverError**, scheme `"sql://"` | Error, scheme `"sql://"` | Error, scheme `"sql://"` | | sqlite · `libsql://pin.turso.io` | sqlite `libsql://pin.turso.io` | **UnsupportedDriverError**, scheme `"libsql://"` | Error | Error | | sqlite · `file:` | sqlite (empty name) | **UnsupportedDriverError**, names no file | Error, names no file | Error, names no file | | sqlite · empty or blank | sqlite (empty name) | sqlite `:memory:` | unified default file | unified default file | | sqlite · unset | sqlite `:memory:` | sqlite `:memory:` | unified default file | unified default file | ### Behaviour changes on the CLI host beyond `file://` and `FILE:` (reported, not hidden) 1. **Newly refused, fatally:** an explicit `sqlite` selection over a URL with a `SCHEME://` that is not `file:` (`postgres://`, `libsql://`, `wasm-sqlite://`, any other), and a `file:` / `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: point `OS_DATABASE_URL` at a file, or select the driver the URL is for. 2. **The undocumented `sql://` strip is gone.** It was reachable only under an explicit `sqlite` selection (URL inference refuses `sql://` as an unknown scheme). `git grep` over the tree finds no other `sql://` spelling. It is now refused like any other non-file scheme; the changeset says to write `file:`. 3. **An empty or blank URL under an explicit `sqlite` / `sqlite-wasm` selection is read as absent: `:memory:`.** It used to be an empty file name. Blank is absent the way `inferDriverTypeFromUrl` reads it; without this the rule would refuse it with "it names no file after `file:`", 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/runtime` has no API-surface snapshot or export ledger: its `package.json` declares `build`, `dev`, `typecheck`, `check:test-typecheck`, `gen:test-typecheck-debt`, `test`, `test:repo`; the `api-surface/` snapshot and `check:exported-any` are `packages/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-returns` would flag an `any` return; the export returns `string`. - Downstream typecheck sweep not needed, with evidence: the delta is one added named export whose name collides with nothing. `git grep` finds no `export * from '@objectstack/runtime'` in this repo or in `../objectui`, so no consumer can meet a star re-export ambiguity, and no other symbol is named `sqliteFilenameFromUrl`. 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. - **Runtime pins** (`packages/runtime`): `resolve-project-database.test.ts` (an `it.each` over `FILE:./x.db`, `File:///tmp/x.db`, `FILE://x.db`, `FILE:./x.wasm.db` and the lower-case / bare-path CONTROL rows, asserting `driver` and `sqliteFile`; a bare `FILE:` refused as plain `Error` with the names-no-file sentence) and `standalone-stack.test.ts` (`createStandaloneStack` from an empty cwd over `FILE:./upper/x.db` declares `./upper/x.db`, equal to the `file:` spelling, and the cwd holds no entry matching `/^file:/i`). - **CLI pins** (`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 explicit `sqlite` over a credentialed `postgres://` URL, `file:` and `FILE:`, the CLI's refusal an `UnsupportedDriverError` with `recognized: 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. - **Runs on the final head `48728977b`** (after merging `origin/main` at `1eac7ad75`; every heavy run through `scripts/pm/os-verify-lock.sh`, verdict lines quoted): - `pnpm --filter @objectstack/runtime test` (vitest `local` project): `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 uses `resolveStorageDefinition('sqlite', { databaseUrl: 'libsql://pin.turso.io' })` as an oracle: it now answers through the new typed refusal (`recognized: true`, `driverType: 'sqlite'`) and still derives `sqlite`. The CLI `integration` tier 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`; both `check:test-typecheck: OK` with their debt ledgers held. - `pnpm exec eslint --no-inline-config` over the six changed `.ts` files: exit 0, no output. The repo-wide lint run is the PR's `Lint & Repo Gates` check-run. - Derived gate families: `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 65 `exit 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`). Also `pnpm --filter @objectstack/spec check:generated` (spec moved on `main`'s side of the merge): exit 0, `All 14 generated artifacts are up to date`. - **Ablations** (run on `71b52fdbf`, before the merge and the tag-strip follow-up, neither of which touches the mutated lines), each through `node scripts/ablation-replace.mjs` (anchor hit 1 time, blob changed, restored to the `HEAD` blob with `git diff HEAD` empty). 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 a `dist/` rebuild. - Runtime: `/^file:(\/\/)?/i` back to `/^file:(\/\/)?/` on the `sqlite` path: `Tests 5 failed | 63 passed (68)`. Red: the `FILE:./x.db`, `File:///tmp/x.db`, `FILE://x.db` rows, the bare-`FILE:` refusal, and the `createStandaloneStack` case (`expected 'FILE:./upper/x.db' to be './upper/x.db'`). The lower-case and bare-path CONTROL rows stayed green. - CLI: `return sqliteFilenameFromUrl(url, kind);` replaced by the old local strip `return url.replace(/^file:/, '');`: `Tests 9 failed | 90 passed (99)` in the parity file. Red: the six `file://` / upper-case rows and the three refusal rows. The `sqlite:` / `sqlite://` rows, the lower-case and bare-path controls and the blank-URL case stayed green. - **Reverse verification of the cross-package type** (on `71b52fdbf`): with the CLI call changed to `sqliteFilenameFromUrl(url, 'postgres')`, `tsc --noEmit` in `packages/cli` failed with `error 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 - **Out of scope, measured at the public door, for the seat to file:** the rule's `sqlite-wasm` kind 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 --dev` printed `Server is ready` and created `postgres:/PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5999/db` under 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 rows `sqlite-wasm · libsql://…`). Not fixed here: the refusal for the WASM kind is its own wording and behaviour decision, and this card's surface is the `file:` strip. - The two hosts still differ on an **absent** URL under an explicit `sqlite` selection: 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. - `#23796` recorded a `[driver-sqlite-wasm]` prefix on write-failure lines while the banner read `SqlDriver(better-sqlite3)`; the base-tree probe here showed the same pairing. Not investigated. - `origin/main` moved to `a31ff2eb5` after the merge, by two commits that touch neither changed file nor `storage-driver.ts`'s neighbours (scaffold `create` / `init` and translations); not merged, the merge queue rebuilds on the current `main`. Overlap, in non-closing sentences: #23765 (`detectDriverFromUrl`, another dev's) is untouched here, as are its parity rows. #23737's `mkdirSync` arms in `createStandaloneStack` are untouched. #23792's memory-URL refusal landed on main as PR #23840 before this PR opened; `origin/main` was merged at `1eac7ad75` and both sides kept. #23633's `sqlite://` alias fix is the twin this completes for `file://`. --- _Generated by [Claude Code](https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7a6ba03 commit 34b740f

7 files changed

Lines changed: 191 additions & 20 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/runtime': minor
3+
'@objectstack/cli': patch
4+
---
5+
6+
fix(runtime,cli): an upper-case `FILE:` database URL opens the file its lower-case spelling opens, `file://NAME` opens `NAME` on both hosts, and the URL-to-filename rule is exported as `sqliteFilenameFromUrl`
7+
8+
Clause-②: yes (widening)
9+
10+
- **`FILE:` is stripped case-insensitively.** The standalone host classified `FILE:./app.db` as SQLite case-insensitively but stripped the prefix case-sensitively, so the prefix stayed in the file name: `OS_DATABASE_URL=FILE:./app.db` created a directory literally named `FILE:.` and put the database inside it, and `File:///tmp/app.db` was refused as an unsupported `File://` scheme. Both now open the file the lower-case spelling opens (`./app.db`, `/tmp/app.db`), as the `sqlite-wasm` path already did. Lower-case `file:` URLs and bare paths are unchanged.
11+
- **New export: `sqliteFilenameFromUrl(url, kind)`** from `@objectstack/runtime`, `kind` being `'sqlite'` or `'sqlite-wasm'`. It is the one rule that turns a SQLite database URL (after `normalizeDatabaseUrl`) into a file name: `file:` / `file://` stripped case-insensitively, plus `wasm-sqlite:` for the WASM kind. For `'sqlite'` it throws a plain `Error` on a URL that is not a file path, naming the URL's scheme and never the URL. Nothing is removed.
12+
- **`os serve`'s legacy storage path (`OS_MODE=off`, `bootMode: 'off'`, or a host config) turns a SQLite URL into a file by that same rule**, so both hosts open one file per URL. It used to strip only a lower-case `file:`: `OS_DATABASE_URL=file://NAME` opened `//NAME` at the filesystem root, where the standalone host opens `./NAME`, and `FILE:./app.db` made a `FILE:.` directory there too. `file:///abs/app.db` opens `/abs/app.db`; `sqlite:` / `sqlite://` URLs are unchanged.
13+
- **What that rule refuses, the legacy path now refuses too, and fatally**, with the standalone host's own sentence (it names the URL's scheme, never the URL): an explicit `OS_DATABASE_DRIVER=sqlite` over a server URL such as `postgres://user:password@host/db`, which used to create directories named after the URL, credentials included, and a `file:` URL that names no file, which used to declare a database with an empty file name. Fix: point `OS_DATABASE_URL` at a file (`file:./app.db`), or select the driver the URL is for. The undocumented `sql://` prefix that path also stripped under an explicit `sqlite` selection is refused the same way; write `file:` instead. A blank `OS_DATABASE_URL` under an explicit `sqlite` selection is read as absent (`:memory:`), as an absent one always was.

‎packages/cli/src/utils/driver-vocabulary-parity.test.ts‎

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -533,15 +533,27 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro
533533
}
534534
});
535535

536-
// `sqlite:` / `sqlite://` are aliases of `file:` on both hosts, by ONE rule —
537-
// the runtime host's `normalizeDatabaseUrl`, which the CLI host calls. Both
538-
// hosts must name the same FILE: `sqlite://x.db` used to open `//x.db` (the
539-
// filesystem root) on the CLI host while the runtime host opened `x.db`.
536+
// `sqlite:` / `sqlite://` are aliases of `file:` on both hosts, and a `file:`
537+
// URL becomes a file name by ONE rule — the runtime host's
538+
// `normalizeDatabaseUrl` and `sqliteFilenameFromUrl`, both of which the CLI
539+
// host calls. Both hosts must name the same FILE: `sqlite://x.db` and
540+
// `file://x.db` used to open `//x.db` (the filesystem root) on the CLI host
541+
// while the runtime host opened `x.db`, and `FILE:./x.db` kept its upper-case
542+
// prefix as part of the file name on both, making a `FILE:.` directory. The
543+
// lower-case `file:` and bare-path rows are the CONTROL.
540544
it.each([
541545
['sqlite://x.db', 'x.db', 'sqlite'],
542546
['sqlite:x.db', 'x.db', 'sqlite'],
543547
['sqlite:///var/lib/objectstack/x.db', '/var/lib/objectstack/x.db', 'sqlite'],
544548
['sqlite://x.wasm.db', 'x.wasm.db', 'sqlite-wasm'],
549+
['file://x.db', 'x.db', 'sqlite'],
550+
['file:///var/lib/objectstack/x.db', '/var/lib/objectstack/x.db', 'sqlite'],
551+
['file://x.wasm.db', 'x.wasm.db', 'sqlite-wasm'],
552+
['FILE:./x.db', './x.db', 'sqlite'],
553+
['File:///var/lib/objectstack/x.db', '/var/lib/objectstack/x.db', 'sqlite'],
554+
['FILE:./x.wasm.db', './x.wasm.db', 'sqlite-wasm'],
555+
['file:./x.db', './x.db', 'sqlite'],
556+
['./x.db', './x.db', 'sqlite'],
545557
])('both hosts open %s as the file %s (%s), in dev and prod', (url, file, kind) => {
546558
process.env.OS_DATABASE_URL = url;
547559
const runtime = resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' });
@@ -553,4 +565,50 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro
553565
expect(definition!.config.filename, `the CLI host's file for ${url} (isDev=${isDev})`).toBe(runtime.sqliteFile);
554566
}
555567
});
568+
569+
// What that rule refuses, both hosts refuse, with ONE sentence that names the
570+
// scheme and never the URL — the CLI host's is the runtime host's minus its
571+
// `[StandaloneStack]` tag, as for the retired engine above — and the CLI
572+
// host's refusal is typed, the class serve.ts makes fatal (it swallows every
573+
// other construction error). Two
574+
// inputs reach it: an explicit sqlite selection over a server URL, which the
575+
// CLI host used to open as a file at a path made of the URL, credentials
576+
// included, and a `file:` that names no file.
577+
it.each([
578+
['sqlite', 'postgres://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5432/db', 'its scheme is "postgres://"'],
579+
[undefined, 'file:', 'it names no file after "file:"'],
580+
[undefined, 'FILE:', 'it names no file after "file:"'],
581+
])('both hosts REFUSE driver %s over %s, in dev AND in prod, with one sentence', (driver, url, why) => {
582+
if (driver) process.env.OS_DATABASE_DRIVER = driver;
583+
const runtime = runtimeUrlVerdict(url);
584+
expect(runtime.accepted, `the runtime host accepted ${url}`).toBe(false);
585+
const sentence = (runtime as { message: string }).message;
586+
expect(sentence).toContain(why);
587+
for (const isDev of [false, true]) {
588+
let error: unknown;
589+
try {
590+
resolveStorageDefinition(resolveDriverType(driver, url), { databaseUrl: url, isDev });
591+
} catch (err) {
592+
error = err;
593+
}
594+
expect(error, `the CLI host (isDev=${isDev}) did not refuse ${url} by type`).toBeInstanceOf(UnsupportedDriverError);
595+
expect((error as UnsupportedDriverError).recognized).toBe(true);
596+
expect((error as UnsupportedDriverError).driverType).toBe('sqlite');
597+
expect((error as Error).message).toBe(sentence.replace(/^\[StandaloneStack\] /, ''));
598+
expect((error as Error).message).not.toBe(sentence);
599+
expect((error as Error).message).not.toContain('PLACEHOLDER');
600+
}
601+
});
602+
603+
// CLI host only: under an explicit sqlite selection a blank URL is the absent
604+
// URL, `:memory:`, never a file named by whitespace — blank is absent, as this
605+
// host's URL inference reads it. The runtime host's absent URL is its default
606+
// file, a different answer for absence that this pin does not judge.
607+
it.each(['', ' '])('the CLI host reads the blank URL %j under an explicit sqlite selection as :memory:', (url) => {
608+
for (const isDev of [false, true]) {
609+
const definition = resolveStorageDefinition(resolveDriverType('sqlite', url), { databaseUrl: url, isDev });
610+
expect(definition!.driverId).toBe('sqlite');
611+
expect(definition!.config.filename).toBe(':memory:');
612+
}
613+
});
556614
});

‎packages/cli/src/utils/storage-driver.ts‎

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ import {
106106
namesRetiredMemoryEngine,
107107
normalizeDatabaseUrl,
108108
retiredMemoryEngineMessage,
109+
sqliteFilenameFromUrl,
109110
type LoadTursoDriverFactoryOptions,
110111
} from '@objectstack/runtime';
111112
import {
@@ -527,13 +528,11 @@ export function resolveStorageDefinition(
527528
}
528529

529530
if (kind === 'sqlite') {
530-
// `sqlite:` and `sqlite://` are aliases of `file:`, translated by the runtime
531-
// host's own rule (`normalizeDatabaseUrl`) rather than a second copy of it,
532-
// so both hosts open one file: `sqlite://x.db` is `x.db`, never `//x.db`
533-
// (the filesystem root), and `sqlite:///abs/x.db` is `/abs/x.db`.
534-
const filePath = normalizeDatabaseUrl(databaseUrl ?? ':memory:')
535-
.replace(/^file:/, '')
536-
.replace(/^sql:\/\//, '');
531+
// The runtime host's file for this URL ({@link sqliteFileFor}), never a
532+
// second copy of its rule: `sqlite://x.db` and `file://x.db` are `x.db`,
533+
// never `//x.db` (the filesystem root), `file:///abs/x.db` is `/abs/x.db`,
534+
// and `FILE:./x.db` is `./x.db`.
535+
const filePath = sqliteFileFor(databaseUrl, 'sqlite');
537536
return {
538537
driverId: 'sqlite',
539538
config: { filename: filePath, ...devAutoMigrateConfig('sqlite', isDev) },
@@ -548,10 +547,9 @@ export function resolveStorageDefinition(
548547
}
549548

550549
if (kind === 'sqlite-wasm') {
551-
// The same alias rule as the sqlite arm: `sqlite://x.wasm.db` is `x.wasm.db`.
552-
const filePath = normalizeDatabaseUrl(databaseUrl ?? ':memory:')
553-
.replace(/^file:/, '')
554-
.replace(/^wasm-sqlite:\/\//, '');
550+
// The same rule as the sqlite arm, `wasm-sqlite://` included:
551+
// `sqlite://x.wasm.db` and `file://x.wasm.db` are `x.wasm.db`.
552+
const filePath = sqliteFileFor(databaseUrl, 'sqlite-wasm');
555553
return {
556554
driverId: 'sqlite-wasm',
557555
// `persist` passthrough: the CLI kept `on-disconnect` semantics here
@@ -629,6 +627,40 @@ export function resolveStorageDefinition(
629627
return null;
630628
}
631629

630+
/**
631+
* The SQLite file a `default` definition opens for this URL — the runtime
632+
* host's answer, by the runtime host's rule: `normalizeDatabaseUrl` (`sqlite:` /
633+
* `sqlite://` are `file:`), then `sqliteFilenameFromUrl` (`file:` / `file://`,
634+
* and `wasm-sqlite:` for the WASM kind, stripped case-insensitively). This file
635+
* used to keep its own copy, a case-sensitive `/^file:/` strip, so
636+
* `file://NAME` opened `//NAME` at the filesystem root, where the runtime host
637+
* opens `NAME`, and `FILE:./x.db` made a directory named `FILE:.`.
638+
*
639+
* An absent or blank URL is `:memory:`: blank is absent, as
640+
* {@link inferDriverTypeFromUrl} reads it, never a file named by whitespace.
641+
*
642+
* A URL the rule refuses is re-raised as {@link UnsupportedDriverError} with
643+
* the runtime host's own sentence, which names the scheme and never the URL,
644+
* minus its `[StandaloneStack]` tag — the shape this host's retired-engine
645+
* refusal already has (`driver-vocabulary-parity.test.ts` holds both).
646+
* Two inputs reach that: an explicit `sqlite` selection over a server URL
647+
* (`OS_DATABASE_DRIVER=sqlite` beside `postgres://user:password@…`), and a
648+
* `file:` that names no file. Both used to build a definition: the first opened
649+
* a file at a path made of the URL, credentials included, and the second
650+
* declared an empty file name. The type is
651+
* what makes the refusal fatal: `serve.ts` re-throws this class and swallows
652+
* every other construction error, so a plain `Error` would boot with no
653+
* storage at all.
654+
*/
655+
function sqliteFileFor(databaseUrl: string | undefined, kind: 'sqlite' | 'sqlite-wasm'): string {
656+
const url = normalizeDatabaseUrl(databaseUrl?.trim() ? databaseUrl : ':memory:');
657+
try {
658+
return sqliteFilenameFromUrl(url, kind);
659+
} catch (err) {
660+
throw new UnsupportedDriverError(kind, (err as Error).message.replace(/^\[StandaloneStack\] /, ''));
661+
}
662+
}
663+
632664
/**
633665
* Load the OPTIONAL libSQL/Turso driver package and wrap it as the host driver
634666
* factory `DefaultDatasourcePlugin` accepts (#5602).

‎packages/runtime/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export { Runtime } from './runtime.js';
88
export type { RuntimeConfig } from './runtime.js';
99

1010
// Export Standalone Stack
11-
export { createStandaloneStack, resolveObjectStackHome, resolveStandaloneDatabase } from './standalone-stack.js';
11+
export { createStandaloneStack, resolveObjectStackHome, resolveStandaloneDatabase, sqliteFilenameFromUrl } from './standalone-stack.js';
1212
export type { StandaloneStackConfig, StandaloneStackResult, ResolvedStandaloneDatabase } from './standalone-stack.js';
1313

1414
// The dev schema self-heal decision (#21733) — public because `@objectstack/cli`

‎packages/runtime/src/resolve-project-database.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,36 @@ describe('resolveStandaloneDatabase — #6469 behaviours through the migrate sea
335335
expect(r.sqliteFile).toBe(join(root, 'a.db'));
336336
});
337337

338+
// `file:` is classified case-insensitively, so it is stripped case-insensitively
339+
// too: an upper-case prefix the strip kept became part of the file name, and
340+
// `FILE:./app.db` made a directory literally named `FILE:.`. Every row names
341+
// the file its lower-case spelling names; the lower-case and bare rows are the
342+
// CONTROL, unchanged.
343+
it.each([
344+
['FILE:./x.db', './x.db', 'sqlite'],
345+
['File:///tmp/x.db', '/tmp/x.db', 'sqlite'],
346+
['FILE://x.db', 'x.db', 'sqlite'],
347+
['file:./x.db', './x.db', 'sqlite'],
348+
['file:///tmp/x.db', '/tmp/x.db', 'sqlite'],
349+
['file://x.db', 'x.db', 'sqlite'],
350+
['./x.db', './x.db', 'sqlite'],
351+
['/tmp/x.db', '/tmp/x.db', 'sqlite'],
352+
['FILE:./x.wasm.db', './x.wasm.db', 'sqlite-wasm'],
353+
])('%s opens the sqlite file %s (%s)', (url, file, driver) => {
354+
const r = resolveStandaloneDatabase({ databaseUrl: url });
355+
expect(r.driver).toBe(driver);
356+
expect(r.sqliteFile).toBe(file);
357+
});
358+
359+
it('an upper-case FILE: that names no file is refused like file:, never opened as a file named "FILE:"', () => {
360+
let err: unknown;
361+
try { resolveStandaloneDatabase({ databaseUrl: 'FILE:' }); } catch (e) { err = e; }
362+
expect(err).toBeInstanceOf(Error);
363+
expect((err as Error).constructor).toBe(Error);
364+
expect((err as Error).message).toContain('sqlite driver was selected but the URL does not look like a file path');
365+
expect((err as Error).message).toContain('it names no file after "file:"');
366+
});
367+
338368
it('still refuses a genuinely unsupported scheme, message intact', () => {
339369
expect(() => resolveStandaloneDatabase({ databaseUrl: 'redis://localhost:6379' }))
340370
.toThrow(/Unsupported database URL scheme/);

‎packages/runtime/src/standalone-stack.test.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
// owner-only `member_default`.
1616

1717
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
18-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
18+
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
1919
import { tmpdir } from 'node:os';
2020
import { join } from 'node:path';
2121
import { createStandaloneStack } from './standalone-stack.js';
@@ -397,6 +397,27 @@ describe('createStandaloneStack — default datasource declared, built via the s
397397
expect(r.titles).toContain('hello-driver');
398398
}, BOOT_TIMEOUT);
399399

400+
// An upper-case `FILE:` is the same file as `file:`, and the boot's directory
401+
// preparation creates no `FILE:` directory on the way. Run from an empty cwd:
402+
// the prefix used to survive into the relative filename, so the directory it
403+
// made landed beside the process.
404+
it('FILE: → declares the same file as file: and creates no FILE: directory', async () => {
405+
const cwd = process.cwd();
406+
const work = mkdtempSync(join(tmpdir(), 'os-file-url-case-'));
407+
process.chdir(work);
408+
try {
409+
const upper = defaultDefOf(await createStandaloneStack({ databaseUrl: 'FILE:./upper/x.db' })).def;
410+
const lower = defaultDefOf(await createStandaloneStack({ databaseUrl: 'file:./upper/x.db' })).def;
411+
expect(upper.driver).toBe('sqlite');
412+
expect(upper.config?.filename).toBe('./upper/x.db');
413+
expect(upper.config?.filename).toBe(lower.config?.filename);
414+
expect(readdirSync(work).filter((name) => /^file:/i.test(name))).toEqual([]);
415+
} finally {
416+
process.chdir(cwd);
417+
rmSync(work, { recursive: true, force: true });
418+
}
419+
}, BOOT_TIMEOUT);
420+
400421
it('databaseDriver:sqlite-wasm → declares driver "sqlite-wasm"; factory builds SqliteWasmDriver that round-trips', async () => {
401422
const r = await definitionRoundTrip({ databaseDriver: 'sqlite-wasm', databaseUrl: `file:${join(dir, 'wasm.db')}` });
402423
expect(r.driverId).toBe('sqlite-wasm');

‎packages/runtime/src/standalone-stack.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -582,14 +582,31 @@ function refuseRetiredMemoryEngine(
582582
throw new Error(`[StandaloneStack] ${retiredMemoryEngineMessage(selection)}`);
583583
}
584584

585-
/** URL→filename for the two sqlite kinds. Throws on a URL that isn't a path. */
586-
function sqliteFilenameFromUrl(dbUrl: string, kind: 'sqlite' | 'sqlite-wasm'): string {
585+
/**
586+
* URL→filename for the two sqlite kinds: the ONE rule both hosts turn a SQLite
587+
* database URL into a file with. Throws on a URL that isn't a path.
588+
*
589+
* Takes the URL as {@link resolveStandaloneDatabase} has it, i.e. after
590+
* `normalizeDatabaseUrl` (`sqlite:` / `sqlite://` are already `file:`).
591+
* `file:` and `file://` are stripped case-insensitively on BOTH kinds, as
592+
* {@link detectDriverFromUrl} classifies them (`/^file:/i`): a prefix the
593+
* classifier accepts and this strip kept became part of the filename, so
594+
* `FILE:./app.db` opened `./FILE:./app.db` and the boot's directory creation
595+
* made a `FILE:.` directory — the "bogus directory from a URL" this module's
596+
* header rules out.
597+
*
598+
* Exported because the CLI host (`resolveStorageDefinition` in
599+
* `@objectstack/cli`'s `utils/storage-driver.ts`) calls it rather than keeping
600+
* a second copy; its copy stripped only `/^file:/`, so `file://NAME` opened
601+
* `//NAME` at the filesystem root there while this host opened `NAME`.
602+
*/
603+
export function sqliteFilenameFromUrl(dbUrl: string, kind: 'sqlite' | 'sqlite-wasm'): string {
587604
if (kind === 'sqlite-wasm') {
588605
return dbUrl
589606
.replace(/^wasm-sqlite:(\/\/)?/i, '')
590607
.replace(/^file:(\/\/)?/i, '') || ':memory:';
591608
}
592-
const filename = dbUrl.replace(/^file:(\/\/)?/, '');
609+
const filename = dbUrl.replace(/^file:(\/\/)?/i, '');
593610
const scheme = urlSchemeOf(filename);
594611
if (!filename || scheme) {
595612
// Names the scheme, never the URL ({@link urlSchemeOf}). This is the

0 commit comments

Comments
 (0)