Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/23863-explicit-sqlite-non-file-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/runtime': patch
---

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

Clause-②: no

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`.

- **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.
- **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.
- **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.
- **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`).
- **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.

`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:`.
105 changes: 101 additions & 4 deletions packages/cli/src/utils/driver-vocabulary-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,10 +558,11 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro
// scheme and never the URL — the CLI host's is the runtime host's minus its
// `[StandaloneStack]` tag, as for the retired engine above — and the CLI
// host's refusal is typed, the class serve.ts makes fatal (it swallows every
// other construction error). Two
// inputs reach it: an explicit sqlite selection over a server URL, which the
// CLI host used to open as a file at a path made of the URL, credentials
// included, and a `file:` that names no file.
// other construction error). What reaches it: an explicit selection over a
// value that is not a file path (these rows hold a server URL; the
// explicit-selection rows below hold every other shape, on both kinds),
// which the CLI host used to open as a file at a path made of the URL,
// credentials included, and a `file:` that names no file.
it.each([
['sqlite', 'postgres://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5432/db', 'its scheme is "postgres://"'],
[undefined, 'file:', 'it names no file after "file:"'],
Expand All @@ -588,6 +589,102 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro
}
});

// An explicit `sqlite` or `sqlite-wasm` selection skips both hosts' URL
// classification, so the filename rule is the only check its URL meets.
// Under either kind both hosts refuse every value that is not a file path —
// an OPAQUE scheme (two or more characters and a `:` with no `//`), an
// unrecognised `<scheme>://` and a server URL, on the WASM kind too, which
// used to refuse nothing — with one sentence naming the kind and the scheme,
// and the same CONTROL file for every file value. `backup:2026.db` is the one
// file name that changes answer: it reads as the scheme `backup:`, as it does
// on the classification path above.
describe('an explicit sqlite / sqlite-wasm selection: one verdict per URL on both hosts', () => {
const KINDS = ['sqlite', 'sqlite-wasm'] as const;
const PLACEHOLDERS = ['PLACEHOLDER-USER', 'PLACEHOLDER-SECRET', 'PLACEHOLDER-TOKEN'];
const REFUSED = [
['jdbc:postgresql://127.0.0.1:5999/db', 'jdbc:'],
['jdbc:postgresql://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5999/db?authToken=PLACEHOLDER-TOKEN', 'jdbc:'],
['redis:h', 'redis:'],
['redis://h/x.db', 'redis://'],
['postgres://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:5999/db', 'postgres://'],
['backup:2026.db', 'backup:'],
] as const;

/** Every own property of `error`, serialised, after a check that the message is in it. */
function ownProperties(error: unknown): string {
const everything = JSON.stringify(error, Object.getOwnPropertyNames(error));
expect(everything).toContain(JSON.stringify((error as Error).message));
return everything;
}

it.each(KINDS.flatMap((kind) => REFUSED.map(([url, scheme]) => [kind, url, scheme] as const)))(
'both hosts REFUSE an explicit %s over %s, naming %s only, in dev AND in prod, with one sentence',
(kind, url, scheme) => {
process.env.OS_DATABASE_DRIVER = kind;
process.env.OS_DATABASE_URL = url;
let runtime: unknown;
try {
resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' });
} catch (err) {
runtime = err;
}
expect(runtime, `the runtime host accepted an explicit ${kind} over ${url}`).toBeInstanceOf(Error);
const sentence = (runtime as Error).message;
expect(sentence).toContain(`[StandaloneStack] ${kind} driver was selected`);
expect(sentence).toContain(`its scheme is "${scheme}"`);
for (const placeholder of PLACEHOLDERS) {
expect(ownProperties(runtime), `the runtime host's refusal carries ${placeholder}`).not.toContain(placeholder);
}
for (const isDev of [false, true]) {
let error: unknown;
try {
resolveStorageDefinition(resolveDriverType(kind, url), { databaseUrl: url, isDev });
} catch (err) {
error = err;
}
expect(error, `the CLI host (isDev=${isDev}) did not refuse ${url} by type`).toBeInstanceOf(UnsupportedDriverError);
expect((error as UnsupportedDriverError).recognized).toBe(true);
expect((error as UnsupportedDriverError).driverType).toBe(kind);
expect((error as Error).message).toBe(sentence.replace(/^\[StandaloneStack\] /, ''));
for (const placeholder of PLACEHOLDERS) {
expect(ownProperties(error), `the CLI host's refusal carries ${placeholder}`).not.toContain(placeholder);
}
}
},
);

type FileRow = readonly [(typeof KINDS)[number], string, string];
const FILES: FileRow[] = [
...KINDS.flatMap((kind): FileRow[] => [
[kind, 'file:./x.db', './x.db'],
[kind, './x.db', './x.db'],
[kind, 'C:\\data\\x.db', 'C:\\data\\x.db'],
[kind, 'C:/data/x.db', 'C:/data/x.db'],
[kind, ':memory:', ':memory:'],
[kind, 'file:backup:2026.db', 'backup:2026.db'],
[kind, './backup:2026.db', './backup:2026.db'],
]),
['sqlite-wasm', 'wasm-sqlite:///tmp/x.db', '/tmp/x.db'],
];

it.each(FILES)(
'CONTROL: both hosts open an explicit %s over %s as the file %s, in dev and prod',
(kind, url, file) => {
process.env.OS_DATABASE_DRIVER = kind;
process.env.OS_DATABASE_URL = url;
const runtime = resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' });
expect(runtime.driver).toBe(kind);
// The runtime host reports an in-memory database as no file at all.
expect(runtime.sqliteFile).toBe(file === ':memory:' ? null : file);
for (const isDev of [false, true]) {
const definition = resolveStorageDefinition(resolveDriverType(kind, url), { databaseUrl: url, isDev });
expect(definition!.driverId).toBe(kind);
expect(definition!.config.filename, `the CLI host's file for ${url} (isDev=${isDev})`).toBe(file);
}
},
);
});

// CLI host only: under an explicit sqlite selection a blank URL is the absent
// URL, `:memory:`, never a file named by whitespace — blank is absent, as this
// host's URL inference reads it. The runtime host's absent URL is its default
Expand Down
13 changes: 8 additions & 5 deletions packages/cli/src/utils/storage-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,11 +644,14 @@ export function resolveStorageDefinition(
* the runtime host's own sentence, which names the scheme and never the URL,
* minus its `[StandaloneStack]` tag — the shape this host's retired-engine
* refusal already has (`driver-vocabulary-parity.test.ts` holds both).
* Two inputs reach that: an explicit `sqlite` selection over a server URL
* (`OS_DATABASE_DRIVER=sqlite` beside `postgres://user:password@…`), and a
* `file:` that names no file. Both used to build a definition: the first opened
* a file at a path made of the URL, credentials included, and the second
* declared an empty file name. The type is
* What reaches that is an explicit `sqlite` or `sqlite-wasm` selection over a
* value that is not a file path, which this host's URL inference never sees —
* a server URL (`OS_DATABASE_DRIVER=sqlite` beside
* `postgres://user:password@…`), an unrecognised `<scheme>://`, or an opaque
* scheme (`jdbc:postgresql://…`, `redis:h`) — and, on the `sqlite` kind, a
* `file:` that names no file. Each used to build a definition: a non-file value
* opened a file at a path made of the URL, credentials included, and the empty
* `file:` declared an empty file name. The type is
* what makes the refusal fatal: `serve.ts` re-throws this class and swallows
* every other construction error, so a plain `Error` would boot with no
* storage at all.
Expand Down
Loading
Loading