Skip to content

Commit 0cb45e9

Browse files
fix(runtime): an explicit sqlite / sqlite-wasm selection refuses a URL that is not a file path, on both hosts (#23896)
Fixes #23863 Clause-②: no Under an explicit `sqlite` or `sqlite-wasm` driver selection, the one SQLite file-name rule both hosts call (`sqliteFilenameFromUrl` in `@objectstack/runtime`) now refuses every value that is not a file path. It refuses an opaque scheme (`jdbc:postgresql://…`, `redis:h`) on both kinds, and on the `sqlite-wasm` kind it also refuses a `SCHEME://` URL (`postgres://…`, `redis://h/x.db`), as the `sqlite` kind already did. The refusal names the selected kind and the scheme only, never the URL, and the boot stops before it creates anything on disk. ## Why An explicit selection skips the URL classification (`resolveStandaloneDatabase`: `explicitDriver || detectDriverFromUrl(url)`), so the opaque-scheme refusal that PR #23861 added to that classification for #23765 (merged) never sees it. The file-name rule was the only check left, and it recognised only `SCHEME://`, and only for the `sqlite` kind. Measured at the public door on base `34b740f2d1` (each boot in a fresh directory outside the repo, `OS_HOME` pinned inside it): | boot (`os serve --dev`, plain config) | base `34b740f2d1` | head `c80f0ceaa` | |---|---|---| | `OS_DATABASE_DRIVER=sqlite OS_DATABASE_URL=jdbc:postgresql://127.0.0.1:5999/db` | `✓ Server is ready`, `Driver: SqlDriver(better-sqlite3) → jdbc:postgresql://127.0.0.1:5999/db`, created `./jdbc:postgresql:/127.0.0.1:5999/db` plus its `.telemetry.db`, `-shm` and `-wal` siblings | exit 1, `✗ [StandaloneStack] sqlite driver was selected but the URL does not look like a file path (its scheme is "jdbc:"). Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.`, directory empty | | `OS_DATABASE_DRIVER=sqlite-wasm OS_DATABASE_URL=postgres://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5999/db` | `✓ Server is ready`, created `./postgres:/PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5999/db` (the credentials in a directory name) | exit 1, `✗ [StandaloneStack] sqlite-wasm driver was selected but the URL does not look like a file path (its scheme is "postgres://"). …`, directory empty, 0 placeholder hits in the log | | CLI legacy path, `OS_MODE=off OS_DATABASE_DRIVER=sqlite-wasm OS_DATABASE_URL=jdbc:postgresql://127.0.0.1:5999/db` | not run on base | exit 1, `✗ sqlite-wasm driver was selected but the URL does not look like a file path (its scheme is "jdbc:"). …` (the typed `UnsupportedDriverError`, untagged), directory empty | ## Changes - `packages/runtime/src/standalone-stack.ts` - `schemeOf` (new, module-private, no new export): the opaque-scheme expression PR #23861 added inline to `detectDriverFromUrl` (`urlSchemeOf(dbUrl) ?? /^[a-z][a-z0-9+.-]+:/i.exec(dbUrl)?.[0]`), now one helper. `detectDriverFromUrl` calls it in place of the inline expression; nothing else in that function changed. - `sqliteFilenameFromUrl`: both kinds strip their prefixes as before, then ask `schemeOf` of a value the strip left as given, and `urlSchemeOf` of what a `file:` (or `wasm-sqlite:`) strip left. So `file:backup:2026.db` still names `backup:2026.db`. One sentence for both kinds, naming the kind; the `sqlite` kind's words are unchanged. An empty name is still `:memory:` on the WASM kind. - Its docblock now states the refusal, and it corrects the claim that every caller passes a normalized URL (see Acceptance notes). - `packages/cli/src/utils/storage-driver.ts`: `sqliteFileFor`'s docblock only. Its "Two inputs reach that" sentence was no longer true. No CLI code change: the CLI host already calls the runtime rule and re-raises its refusal as `UnsupportedDriverError`. - `packages/runtime/src/standalone-stack-explicit-sqlite-non-file-refusal.test.ts` (new, 80 cases): both kinds through both selection doors (`databaseDriver` + `databaseUrl`, `OS_DATABASE_DRIVER` + `OS_DATABASE_URL`) and through the exported rule itself. The refused rows assert a plain `Error` naming the kind and the scheme, and that no own property of the error carries `PLACEHOLDER-USER` / `PLACEHOLDER-SECRET` / `PLACEHOLDER-TOKEN`, with a positive control that the serialisation holds the message. The boot (`createStandaloneStack`) is refused in an empty cwd and leaves it empty. CONTROL rows, the one changed file name and the un-normalized rungs (Acceptance notes) are pinned too. - `packages/cli/src/utils/driver-vocabulary-parity.test.ts`: explicit-driver rows under both kinds. Both hosts refuse the same values in dev AND prod, with the CLI host's message equal to the runtime host's minus its `[StandaloneStack] ` tag, and `driverType` equal to the kind. Placeholder credentials are absent from every own property of both errors. Every CONTROL value names the same file on both hosts. The rows of the classification path (PR #23861) are untouched. - `.changeset/23863-explicit-sqlite-non-file-refusal.md`: `@objectstack/runtime` patch, `Clause-②: no`. It names the one file name that changes answer under an explicit selection: `backup:2026.db` is now refused, and `./backup:2026.db` and `file:backup:2026.db` still name it. ## Verification (head `c80f0ceaa`) - Build: `pnpm turbo run build --filter=@objectstack/cli... --concurrency=2` → 61/61 tasks, exit 0 (the CLI's dependency closure plus the CLI itself). - Both hosts through the built `dist`, under each kind, in dev and prod (`resolveStorageDefinition` vs `resolveStandaloneDatabase`). Both refuse `jdbc:postgresql://127.0.0.1:5999/db` (`jdbc:`), `redis:h` (`redis:`), `redis://h/x.db` (`redis://`), `postgres://PLACEHOLDER-USER:PLACEHOLDER-SECRET@…` (`postgres://`) and `backup:2026.db` (`backup:`). Both open `file:./x.db`, `./x.db`, `C:\data\x.db`, `C:/data/x.db`, `:memory:`, `file:backup:2026.db`, `./backup:2026.db`, `sqlite:./x.db` and `sqlite:///abs/x.db` as the same file. Under `sqlite-wasm`, both open `wasm-sqlite:///tmp/x.db` as `/tmp/x.db`. On base, both hosts opened every refused value as a file under `sqlite-wasm`, and every opaque one under `sqlite` too. - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2` with the new file, `standalone-stack-opaque-scheme-refusal.test.ts` and `standalone-stack-url-refusal-credentials.test.ts` → 3 files, 157 passed, exit 0. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` with `driver-vocabulary-parity.test.ts` and `storage-driver.test.ts` → 2 files, 204 passed, exit 0. - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2` (the runtime's whole local project) → 402 files, 6121 passed, 19 skipped, exit 0. - `pnpm --filter @objectstack/runtime typecheck` → exit 0. `pnpm --filter @objectstack/cli typecheck` → exit 0. - `pnpm exec eslint --no-inline-config` over the four changed source and test files → exit 0. - Gate families: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 65 commands. 63 exited 0. `pnpm check:dual-build-cjs-loads` and `pnpm check:i18n-coverage` exited 3 (`PREREQUISITE NOT MET`: packages outside the CLI closure, such as `connector-slack`, have no `dist/` in this worktree), so they are NOT MEASURED locally and left to CI. `--ran` reconciliation: 65 derived, 63 run, 2 NOT-MEASURED, 0 UNRUN. The repo-wide lint run is CI's `Lint & Repo Gates`. ### Ablation (each new refusal once; the CLI suite resolves `@objectstack/runtime` through `dist/`, so every leg rebuilt it) Each mutation went through `scripts/ablation-replace.mjs`: the anchor hit once, the blob changed, and the restore was proven by blob == HEAD with an empty `git diff HEAD`. Each leg rebuilt the runtime and ran `scripts/ablation-dist-preflight.mjs`. | leg | mutation | dist/ | new runtime file (80) | CLI parity (130) | |---|---|---|---|---| | A1, opaque scheme | `filename === dbUrl ? schemeOf(dbUrl) : urlSchemeOf(filename)` → `urlSchemeOf(filename)` | marker absent (was 1) | 25 red: every opaque row on both kinds and both doors, the exported-rule rows, `backup:2026.db`, the `sqlite:` boundary rows, and the `sqlite` boot over `jdbc:`. `SCHEME://` rows and CONTROL green | 8 red: the opaque rows on both kinds. The rest green | | A1 restore | — | marker present, 1 | 80 green | 130 green | | A2, WASM kind refuses | `if (!filename \|\| scheme)` → `if (!filename \|\| (scheme && kind.length === 6))` | marker present, 1 | 20 red: every `sqlite-wasm` refusal row (opaque and `SCHEME://`), the `sqlite-wasm` boot, and the `sqlite-wasm` boundary rows. All `sqlite` rows and CONTROL green | 6 red: the `sqlite-wasm` rows. The rest green | | A2 restore | — | marker absent, 0 | 80 green | 130 green | After both legs: file blob `f3f84355f733` == HEAD, `git status --porcelain` empty. ## Acceptance notes - **Mechanism assumption 1 (every caller passes a normalized URL) is false for two rungs, and this PR does not normalize them.** `resolveProjectDatabaseUrl` applies `normalizeDatabaseUrl` on the explicit and `OS_DATABASE_URL` / `DATABASE_URL` rungs. A declared default datasource's `url` (non-SQLite drivers only; a declared `sqlite` datasource is always expressed as `file:`) and `TURSO_DATABASE_URL` reach the rule as written. Under an explicit selection there, `sqlite:./x.db` is now refused as the scheme `sqlite:`. On base it opened a file named `sqlite:./x.db`, which meant a `sqlite:.` directory. `sqlite:///…` was already refused there under `sqlite`, and is now refused under `sqlite-wasm` too. The one-line alternative, calling `normalizeDatabaseUrl` inside `sqliteFilenameFromUrl`, would have made `sqlite:///abs/x.db` open on those rungs where it is refused today. That widens the accept set, against this card's ruled `Clause-②: no`, so the boundary is pinned instead (`the boundary: rungs that do not normalize the URL`). The classification path answers these values the same way since PR #23861, whose own Acceptance notes record the un-normalized declared-datasource tier ("No in-repo producer writes it"). No producer was found here either. - **Mechanism assumptions 2 and 3 held.** The CLI host follows the export in dev and prod (table above), and both hosts refuse before any directory is made (door rows above; the boot pin runs `createStandaloneStack` in an empty cwd). - **The refusal's remedy clause, unchanged by ruling, reads oddly under an explicit selection.** It says "or set OS_DATABASE_DRIVER explicitly" to an operator who just did. Reported to the seat, not changed here. - **`main` moved after the merge.** Six commits landed past `c5cf546a6` (spec, plugin-audit, docs, CI header). None touches `standalone-stack.ts`, `resolve-project-database.ts` or `packages/cli/src/utils/`. PR CI runs on the merge ref. --- _Generated by [Claude Code](https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dd11563 commit 0cb45e9

5 files changed

Lines changed: 447 additions & 22 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): an explicit `sqlite` or `sqlite-wasm` driver selection refuses a database URL that is not a file path, instead of opening a SQLite file at a path made of the URL
6+
7+
Clause-②: no
8+
9+
An explicit selection (`OS_DATABASE_DRIVER`, `--database-driver`, or the `databaseDriver` option) skips the URL classification, so the only check its URL meets is the SQLite file-name rule, `sqliteFilenameFromUrl`. Both hosts call that rule: the runtime's standalone host (`os dev`, `os start` and `os serve` over a plain config or an artifact, every `os migrate` subcommand, every embedder of `createStandaloneStack`) and the CLI's legacy `os serve` path, which re-raises its refusal as the fatal `UnsupportedDriverError`.
10+
11+
- **Before, an opaque scheme (two or more characters and a `:` with no `//`).** `OS_DATABASE_DRIVER=sqlite OS_DATABASE_URL=jdbc:postgresql://127.0.0.1:5999/db os serve --dev` printed `✓ Server is ready` and created the directories `./jdbc:postgresql:/127.0.0.1:5999/`. `redis:h` opened a file named `redis:h`. This happened under both kinds.
12+
- **Before, a server URL under `sqlite-wasm`.** The WASM kind refused nothing: `OS_DATABASE_DRIVER=sqlite-wasm` beside `OS_DATABASE_URL=postgres://USER:PASSWORD@127.0.0.1:5999/db` booted ready and created `./postgres:/USER:PASSWORD@127.0.0.1:5999/`, which put the credentials in a directory name. `redis://h/x.db` opened a file too.
13+
- **After.** Under either kind, all of these get the refusal the `sqlite` kind already gave a `postgres://` URL. The kind is named in the sentence: `[StandaloneStack] sqlite-wasm 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.` The message names the scheme only, never the URL, so credentials in the URL are not printed. The boot stops before it creates anything on disk.
14+
- **One file name changes answer.** Under an explicit selection a first path segment that holds a colon now reads as a scheme, as it already does when the URL selects the driver: `backup:2026.db` was a SQLite file and is now refused as the scheme `backup:`. Write `./backup:2026.db` or `file:backup:2026.db` for that file. A one-letter drive is a path, not a scheme (`C:\data\app.db`, `C:/data/app.db`).
15+
- **A `sqlite:` alias on a rung that does not normalize it.** `sqlite:` and `sqlite://` are aliases of `file:` in `OS_DATABASE_URL`, `--database` and the `databaseUrl` option. A declared default datasource's `url` and `TURSO_DATABASE_URL` reach the rule as written. There, under an explicit selection, `sqlite:./x.db` is now refused as the scheme `sqlite:`, where it used to open a file named `sqlite:./x.db`. `sqlite:///…` was already refused there under `sqlite` and is now refused under `sqlite-wasm` as well.
16+
17+
`file:./x.db`, `./x.db`, `:memory:`, `wasm-sqlite:///path` (under `sqlite-wasm`), `sqlite:./x.db` in `OS_DATABASE_URL`, and paths with no scheme resolve to the same file as before. An empty name under `sqlite-wasm` is still `:memory:`.

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

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -558,10 +558,11 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro
558558
// scheme and never the URL — the CLI host's is the runtime host's minus its
559559
// `[StandaloneStack]` tag, as for the retired engine above — and the CLI
560560
// host's refusal is typed, the class serve.ts makes fatal (it swallows every
561-
// other construction error). Two
562-
// inputs reach it: an explicit sqlite selection over a server URL, which the
563-
// CLI host used to open as a file at a path made of the URL, credentials
564-
// included, and a `file:` that names no file.
561+
// other construction error). What reaches it: an explicit selection over a
562+
// value that is not a file path (these rows hold a server URL; the
563+
// explicit-selection rows below hold every other shape, on both kinds),
564+
// which the CLI host used to open as a file at a path made of the URL,
565+
// credentials included, and a `file:` that names no file.
565566
it.each([
566567
['sqlite', 'postgres://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5432/db', 'its scheme is "postgres://"'],
567568
[undefined, 'file:', 'it names no file after "file:"'],
@@ -588,6 +589,102 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro
588589
}
589590
});
590591

592+
// An explicit `sqlite` or `sqlite-wasm` selection skips both hosts' URL
593+
// classification, so the filename rule is the only check its URL meets.
594+
// Under either kind both hosts refuse every value that is not a file path —
595+
// an OPAQUE scheme (two or more characters and a `:` with no `//`), an
596+
// unrecognised `<scheme>://` and a server URL, on the WASM kind too, which
597+
// used to refuse nothing — with one sentence naming the kind and the scheme,
598+
// and the same CONTROL file for every file value. `backup:2026.db` is the one
599+
// file name that changes answer: it reads as the scheme `backup:`, as it does
600+
// on the classification path above.
601+
describe('an explicit sqlite / sqlite-wasm selection: one verdict per URL on both hosts', () => {
602+
const KINDS = ['sqlite', 'sqlite-wasm'] as const;
603+
const PLACEHOLDERS = ['PLACEHOLDER-USER', 'PLACEHOLDER-SECRET', 'PLACEHOLDER-TOKEN'];
604+
const REFUSED = [
605+
['jdbc:postgresql://127.0.0.1:5999/db', 'jdbc:'],
606+
['jdbc:postgresql://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5999/db?authToken=PLACEHOLDER-TOKEN', 'jdbc:'],
607+
['redis:h', 'redis:'],
608+
['redis://h/x.db', 'redis://'],
609+
['postgres://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5999/db', 'postgres://'],
610+
['backup:2026.db', 'backup:'],
611+
] as const;
612+
613+
/** Every own property of `error`, serialised, after a check that the message is in it. */
614+
function ownProperties(error: unknown): string {
615+
const everything = JSON.stringify(error, Object.getOwnPropertyNames(error));
616+
expect(everything).toContain(JSON.stringify((error as Error).message));
617+
return everything;
618+
}
619+
620+
it.each(KINDS.flatMap((kind) => REFUSED.map(([url, scheme]) => [kind, url, scheme] as const)))(
621+
'both hosts REFUSE an explicit %s over %s, naming %s only, in dev AND in prod, with one sentence',
622+
(kind, url, scheme) => {
623+
process.env.OS_DATABASE_DRIVER = kind;
624+
process.env.OS_DATABASE_URL = url;
625+
let runtime: unknown;
626+
try {
627+
resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' });
628+
} catch (err) {
629+
runtime = err;
630+
}
631+
expect(runtime, `the runtime host accepted an explicit ${kind} over ${url}`).toBeInstanceOf(Error);
632+
const sentence = (runtime as Error).message;
633+
expect(sentence).toContain(`[StandaloneStack] ${kind} driver was selected`);
634+
expect(sentence).toContain(`its scheme is "${scheme}"`);
635+
for (const placeholder of PLACEHOLDERS) {
636+
expect(ownProperties(runtime), `the runtime host's refusal carries ${placeholder}`).not.toContain(placeholder);
637+
}
638+
for (const isDev of [false, true]) {
639+
let error: unknown;
640+
try {
641+
resolveStorageDefinition(resolveDriverType(kind, url), { databaseUrl: url, isDev });
642+
} catch (err) {
643+
error = err;
644+
}
645+
expect(error, `the CLI host (isDev=${isDev}) did not refuse ${url} by type`).toBeInstanceOf(UnsupportedDriverError);
646+
expect((error as UnsupportedDriverError).recognized).toBe(true);
647+
expect((error as UnsupportedDriverError).driverType).toBe(kind);
648+
expect((error as Error).message).toBe(sentence.replace(/^\[StandaloneStack\] /, ''));
649+
for (const placeholder of PLACEHOLDERS) {
650+
expect(ownProperties(error), `the CLI host's refusal carries ${placeholder}`).not.toContain(placeholder);
651+
}
652+
}
653+
},
654+
);
655+
656+
type FileRow = readonly [(typeof KINDS)[number], string, string];
657+
const FILES: FileRow[] = [
658+
...KINDS.flatMap((kind): FileRow[] => [
659+
[kind, 'file:./x.db', './x.db'],
660+
[kind, './x.db', './x.db'],
661+
[kind, 'C:\\data\\x.db', 'C:\\data\\x.db'],
662+
[kind, 'C:/data/x.db', 'C:/data/x.db'],
663+
[kind, ':memory:', ':memory:'],
664+
[kind, 'file:backup:2026.db', 'backup:2026.db'],
665+
[kind, './backup:2026.db', './backup:2026.db'],
666+
]),
667+
['sqlite-wasm', 'wasm-sqlite:///tmp/x.db', '/tmp/x.db'],
668+
];
669+
670+
it.each(FILES)(
671+
'CONTROL: both hosts open an explicit %s over %s as the file %s, in dev and prod',
672+
(kind, url, file) => {
673+
process.env.OS_DATABASE_DRIVER = kind;
674+
process.env.OS_DATABASE_URL = url;
675+
const runtime = resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' });
676+
expect(runtime.driver).toBe(kind);
677+
// The runtime host reports an in-memory database as no file at all.
678+
expect(runtime.sqliteFile).toBe(file === ':memory:' ? null : file);
679+
for (const isDev of [false, true]) {
680+
const definition = resolveStorageDefinition(resolveDriverType(kind, url), { databaseUrl: url, isDev });
681+
expect(definition!.driverId).toBe(kind);
682+
expect(definition!.config.filename, `the CLI host's file for ${url} (isDev=${isDev})`).toBe(file);
683+
}
684+
},
685+
);
686+
});
687+
591688
// CLI host only: under an explicit sqlite selection a blank URL is the absent
592689
// URL, `:memory:`, never a file named by whitespace — blank is absent, as this
593690
// host's URL inference reads it. The runtime host's absent URL is its default

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -644,11 +644,14 @@ export function resolveStorageDefinition(
644644
* the runtime host's own sentence, which names the scheme and never the URL,
645645
* minus its `[StandaloneStack]` tag — the shape this host's retired-engine
646646
* refusal already has (`driver-vocabulary-parity.test.ts` holds both).
647-
* Two inputs reach that: an explicit `sqlite` selection over a server URL
648-
* (`OS_DATABASE_DRIVER=sqlite` beside `postgres://user:password@…`), and a
649-
* `file:` that names no file. Both used to build a definition: the first opened
650-
* a file at a path made of the URL, credentials included, and the second
651-
* declared an empty file name. The type is
647+
* What reaches that is an explicit `sqlite` or `sqlite-wasm` selection over a
648+
* value that is not a file path, which this host's URL inference never sees —
649+
* a server URL (`OS_DATABASE_DRIVER=sqlite` beside
650+
* `postgres://user:password@…`), an unrecognised `<scheme>://`, or an opaque
651+
* scheme (`jdbc:postgresql://…`, `redis:h`) — and, on the `sqlite` kind, a
652+
* `file:` that names no file. Each used to build a definition: a non-file value
653+
* opened a file at a path made of the URL, credentials included, and the empty
654+
* `file:` declared an empty file name. The type is
652655
* what makes the refusal fatal: `serve.ts` re-throws this class and swallows
653656
* every other construction error, so a plain `Error` would boot with no
654657
* storage at all.

0 commit comments

Comments
 (0)