Skip to content

Commit d343c71

Browse files
committed
test(cli): pin the opaque-scheme refusal and the sqlite alias file on both hosts; changeset for both
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
1 parent 76b8bdc commit d343c71

3 files changed

Lines changed: 70 additions & 21 deletions

File tree

‎.changeset/23633-unrecognised-database-url-scheme.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
'@objectstack/cli': patch
33
---
44

5-
fix(cli): `os serve` refuses an `OS_DATABASE_URL` whose scheme no driver recognises, and reads `pg://` and a scheme-less path as the runtime host does
5+
fix(cli): `os serve` refuses an `OS_DATABASE_URL` whose scheme no driver recognises, reads `pg://` and a scheme-less path as the runtime host does, and opens `sqlite://x.db` as `./x.db`
66

77
Clause-②: no
88

@@ -11,5 +11,7 @@ This is the `os serve` path a host config takes (one whose `plugins` hold plugin
1111
- **Before, an unrecognised scheme (`OS_DATABASE_URL=redis://127.0.0.1:6399/0`).** `os serve --dev` and `os dev` printed `✓ Server is ready` and the banner `Driver: SqlDriver(better-sqlite3) → :memory:`. The database the operator named was dropped without a word, and every write was lost at restart. In production (`os serve`, `os start`) the boot met the no-storage refusal, which tells the operator to set `OS_DATABASE_URL` even though they had set it.
1212
- **After.** Both postures exit 1 before any plugin starts with `✗ Unsupported database URL scheme "redis://" (OS_DATABASE_URL / --database). Supported: …`. The message lists the recognised spellings and names only the scheme, never the URL, so credentials in the URL are not printed. The standalone runtime host (`os dev` / `os start` over a plain config, `os migrate`) already refused these schemes, so the two hosts now agree. A `.db` ending no longer rescues an unrecognised scheme: `redis://h/app.db` used to be handed to SQLite as a file path, and it is now refused too.
1313
- **`pg://…` and a path with no scheme (`./data/app`, `/var/lib/objectstack/app`).** The runtime host already reads these as postgres and as a SQLite file. Before, this path booted `--dev` on `:memory:` for them and production met the no-storage refusal. Now `pg://` is a postgres datasource and a scheme-less path is that SQLite file, in dev and production.
14+
- **An opaque scheme (two or more characters and a `:` with no `//`, such as `jdbc:postgresql://h/db` or `redis:h`).** Before, `--dev` booted on `:memory:` for these and production met the no-storage refusal. Now they get the same refusal, naming the scheme (`"jdbc:"`), in both postures. A one-letter drive is a path, not a scheme (`C:\data\app`, `C:/data/app`). A file name whose first segment contains a colon now reads as a scheme too: `backup:2026.db` was a SQLite file, and is now refused. Write `./backup:2026.db` or `file:backup:2026.db` for that file.
15+
- **`sqlite://x.db`.** Before, this path stripped `sqlite:` and opened `//x.db`, a file at the filesystem root. The banner read `→ sqlite://x.db`. The runtime host already reads `sqlite:` and `sqlite://` as aliases of `file:` and opens `./x.db`. This path now uses that same rule (`normalizeDatabaseUrl` from `@objectstack/runtime`), so `sqlite://x.db` and `sqlite:x.db` open `./x.db` and `sqlite:///abs/x.db` opens `/abs/x.db`, on both hosts.
1416

15-
`postgres://`, `postgresql://`, `mysql://`, `mongodb://`, `libsql://`, `wasm-sqlite://`, `file:`, `sqlite:`, `:memory:` and `*.db` paths resolve as before. An unset or blank `OS_DATABASE_URL` keeps the dev SQLite default. An explicit `OS_DATABASE_DRIVER` still wins over the URL, which is then not classified.
17+
`postgres://`, `postgresql://`, `mysql://`, `mongodb://`, `libsql://`, `wasm-sqlite://`, `file:`, `:memory:` and `*.db` paths resolve as before. An unset or blank `OS_DATABASE_URL` keeps the dev SQLite default. An explicit `OS_DATABASE_DRIVER` still wins over the URL, which is then not classified.

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

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -486,20 +486,45 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro
486486
}
487487
});
488488

489-
// A value carrying a scheme with no `//` (`jdbc:postgresql://…`, `redis:h`)
490-
// matches neither host's `<scheme>://` test, so the runtime host takes it as a
491-
// SQLite file path and the CLI host mirrors it. Which answer is RIGHT is not
492-
// this row's question; that the two hosts give the SAME one is — so a change
493-
// to either host's scheme-less arm reddens here until the other follows.
494-
it.each(['jdbc:postgresql://localhost:5432/db', 'redis:cache'])(
495-
'both hosts give %s the same verdict, in dev and prod',
496-
(url) => {
497-
const runtime = runtimeUrlVerdict(url);
498-
for (const isDev of [false, true]) {
499-
const cli = cliUrlVerdict(url, isDev);
500-
expect(cli.accepted, `the hosts disagree on ${url} (isDev=${isDev})`).toBe(runtime.accepted);
501-
if (cli.accepted && runtime.accepted) expect(cli.driverId).toBe(runtime.driverId);
502-
}
503-
},
504-
);
489+
// An OPAQUE scheme — two or more characters and a `:`, no `//`
490+
// (`jdbc:postgresql://…`, `redis:h`). The CLI host refuses it, typed, in dev
491+
// and prod. The runtime host's answer is deliberately NOT pinned here: today
492+
// its scheme-less arm opens these as a SQLite file at a path made of the URL,
493+
// an implementation gap against its own "unknown schemes throw" rule that is
494+
// #23765's to close, so asserting either agreement or its current answer
495+
// would pin that gap.
496+
it.each([
497+
['jdbc:postgresql://localhost:5432/db', 'jdbc:'],
498+
['redis:cache', 'redis:'],
499+
])('the CLI host REFUSES the opaque scheme of %s, in dev AND in prod', (url, scheme) => {
500+
for (const isDev of [false, true]) {
501+
const cli = cliUrlVerdict(url, isDev);
502+
expect(cli.accepted, `the CLI host (isDev=${isDev}) accepted ${url}`).toBe(false);
503+
const error = (cli as { error: unknown }).error;
504+
expect(error, `the CLI host (isDev=${isDev}) did not refuse ${url} by type`).toBeInstanceOf(UnsupportedDriverError);
505+
expect((error as UnsupportedDriverError).recognized).toBe(false);
506+
expect((error as Error).message).toContain(`"${scheme}"`);
507+
expect((error as Error).message).toContain('OS_DATABASE_URL');
508+
}
509+
});
510+
511+
// `sqlite:` / `sqlite://` are aliases of `file:` on both hosts, by ONE rule —
512+
// the runtime host's `normalizeDatabaseUrl`, which the CLI host calls. Both
513+
// hosts must name the same FILE: `sqlite://x.db` used to open `//x.db` (the
514+
// filesystem root) on the CLI host while the runtime host opened `x.db`.
515+
it.each([
516+
['sqlite://x.db', 'x.db'],
517+
['sqlite:x.db', 'x.db'],
518+
['sqlite:///var/lib/objectstack/x.db', '/var/lib/objectstack/x.db'],
519+
])('both hosts open %s as the sqlite file %s, in dev and prod', (url, file) => {
520+
process.env.OS_DATABASE_URL = url;
521+
const runtime = resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' });
522+
expect(runtime.driver).toBe('sqlite');
523+
expect(runtime.sqliteFile, `the runtime host's file for ${url}`).toBe(file);
524+
for (const isDev of [false, true]) {
525+
const definition = resolveStorageDefinition(resolveDriverType(undefined, url), { databaseUrl: url, isDev });
526+
expect(definition!.driverId).toBe('sqlite');
527+
expect(definition!.config.filename, `the CLI host's file for ${url} (isDev=${isDev})`).toBe(runtime.sqliteFile);
528+
}
529+
});
505530
});

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

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,10 @@ describe('inferDriverTypeFromUrl', () => {
8585
expect(inferDriverTypeFromUrl(':memory:')).toBe('sqlite');
8686
});
8787

88-
// A value with no `<scheme>://` is a SQLite file path whatever its name — the
89-
// runtime host's arm. These used to answer '' (dev booted `:memory:`).
90-
it.each(['./data/app', 'data/app', '/var/lib/objectstack/app', 'C:\\data\\app'])(
88+
// A value with no scheme is a SQLite file path whatever its name — the runtime
89+
// host's arm. These used to answer '' (dev booted `:memory:`). A one-letter
90+
// drive and a `:` is a drive, not a scheme.
91+
it.each(['./data/app', 'data/app', '/var/lib/objectstack/app', 'C:\\data\\app', 'C:/data/app', './backup:2026.db'])(
9192
'classifies the scheme-less path %s as a sqlite file',
9293
(url) => {
9394
expect(inferDriverTypeFromUrl(url)).toBe('sqlite');
@@ -112,6 +113,10 @@ describe('inferDriverTypeFromUrl', () => {
112113
['mssql://h/db', 'mssql://'],
113114
// A `.db` name does not rescue a scheme nothing recognises.
114115
['redis://h/app.db', 'redis://'],
116+
// An OPAQUE scheme (two or more characters and a `:`, no `//`) is refused
117+
// too, rather than opened as a file at a path made of the URL.
118+
['jdbc:postgresql://localhost:5432/db', 'jdbc:'],
119+
['redis:cache', 'redis:'],
115120
])('refuses %s at inference, naming the scheme and the recognised ones', (url, scheme) => {
116121
let err: unknown;
117122
try {
@@ -256,6 +261,23 @@ describe('resolveStorageDefinition (#3826 — a definition, not a driver)', () =
256261
expect(r!.sqliteFilePath).toBe('./app.db');
257262
});
258263

264+
// `sqlite:` / `sqlite://` are aliases of `file:` (the runtime host's
265+
// `normalizeDatabaseUrl`): `sqlite://x.db` is `x.db`, never `//x.db`, which
266+
// is the filesystem root. The cross-host equality is pinned in
267+
// `driver-vocabulary-parity.test.ts`.
268+
it.each([
269+
['sqlite://x.db', 'x.db'],
270+
['sqlite:x.db', 'x.db'],
271+
['sqlite:///var/lib/objectstack/x.db', '/var/lib/objectstack/x.db'],
272+
['SQLITE://x.db', 'x.db'],
273+
])('reads %s as the sqlite file %s', (url, file) => {
274+
const r = resolveStorageDefinition(resolveDriverType(undefined, url), { databaseUrl: url, isDev: false });
275+
expect(r!.config.filename).toBe(file);
276+
expect(r!.sqliteFilePath).toBe(file);
277+
// The banner keeps what the operator typed.
278+
expect(r!.displayUrl).toBe(url);
279+
});
280+
259281
it('dev default (no driver) declares sqlite :memory: with NO telemetry sibling', () => {
260282
const r = resolveStorageDefinition('', { isDev: true });
261283
expect(r!.driverId).toBe('sqlite');

0 commit comments

Comments
 (0)