Skip to content

Commit c80f0ce

Browse files
committed
test(cli): both hosts give one verdict per URL under an explicit sqlite / sqlite-wasm selection; changeset
Explicit-driver rows in the driver vocabulary parity pin: an opaque scheme, an unrecognised SCHEME:// and a server URL are refused by both hosts under both kinds with one sentence (the CLI host's typed and untagged), no own property of either error carries the placeholder credentials, and every file value names the same file on both hosts. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
1 parent 84cf695 commit c80f0ce

2 files changed

Lines changed: 118 additions & 4 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

0 commit comments

Comments
 (0)