Skip to content
Merged
17 changes: 17 additions & 0 deletions .changeset/23633-unrecognised-database-url-scheme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/cli': patch
---

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`

Clause-②: no

This is the `os serve` path a host config takes (one whose `plugins` hold plugin instances), and the one `OS_MODE=off|none|legacy` takes. `os dev` and `os start` reach it through their `serve` child.

- **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.
- **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` or `.wasm.db` ending no longer rescues an unrecognised scheme: `redis://h/app.db` and `redis://h/app.wasm.db` used to be handed to SQLite as a file path, and both are now refused. A `*.wasm.db` name still selects the WASM engine for a `file:` / `sqlite:` URL or a path with no scheme.
- **`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.
- **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.
- **`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. `sqlite://x.wasm.db` likewise opens `./x.wasm.db` on the WASM engine.

`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.
160 changes: 160 additions & 0 deletions packages/cli/src/utils/driver-vocabulary-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,3 +372,163 @@ describe('fork 2: no local default + no URL is refused on BOTH sides — all 8 c
expect(resolveStorageDefinition(kind, { isDev: false })!.driverId).toBe(kind);
});
});

/**
* The URL door: one verdict per `OS_DATABASE_URL`, on both hosts, in dev AND
* production.
*
* The rows above vary the DRIVER spelling. These vary the URL with no driver
* named, which is the question `inferDriverTypeFromUrl` (CLI host) and
* `detectDriverFromUrl` (runtime host) both answer. Before this block the CLI
* answered '' for every URL its regexes missed: `redis://…` booted `serve --dev`
* on the SQLite `:memory:` default in silence and met the no-storage refusal in
* production, while the runtime host refused the scheme by name; and `pg://…`
* and a scheme-less path, which the runtime host accepts, got the same silent
* `:memory:` from the CLI.
*
* An accepted row asserts the definition was built FROM THE URL
* (`displayUrl === url`), not just that a driver id came back: the dev default
* is a `sqlite` definition too, so a bare driver-id check would read `:memory:`
* as an agreement about `./data/app`.
*/
describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and prod', () => {
const saved: Record<string, string | undefined> = {};
const ENV_KEYS = [
'OS_DATABASE_DRIVER', 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_HOME',
];

beforeEach(() => {
for (const key of ENV_KEYS) saved[key] = process.env[key];
for (const key of ENV_KEYS) delete process.env[key];
process.env.OS_HOME = mkdtempSync(join(tmpdir(), 'os-url-door-parity-'));
});

afterEach(() => {
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key]!;
}
});

/** The CLI host's verdict for a URL with no driver named — serve.ts's own two calls. */
function cliUrlVerdict(url: string, isDev: boolean):
| { accepted: true; driverId: string; displayUrl: string | undefined }
| { accepted: false; error: unknown } {
try {
const definition = resolveStorageDefinition(resolveDriverType(undefined, url), { databaseUrl: url, isDev });
return definition
? { accepted: true, driverId: definition.driverId, displayUrl: definition.displayUrl }
: { accepted: false, error: null };
} catch (error) {
return { accepted: false, error };
}
}

/** The runtime host's verdict for the same URL, from `OS_DATABASE_URL`. */
function runtimeUrlVerdict(url: string): { accepted: true; driverId: string } | { accepted: false; message: string } {
process.env.OS_DATABASE_URL = url;
try {
return { accepted: true, driverId: resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' }).driver };
} catch (err) {
return { accepted: false, message: (err as Error).message };
}
}

// Every spelling the runtime host's arms accept, each with the kind it means.
// ⛔ The CLI never refuses one of these.
it.each([
['postgres://u:p@localhost:5432/db', 'postgres'],
['postgresql://u:p@localhost:5432/db', 'postgres'],
['pg://u:p@localhost:5432/db', 'postgres'],
['PG://u:p@localhost:5432/db', 'postgres'],
['mysql://u:p@localhost:3306/db', 'mysql'],
['mysql2://u:p@localhost:3306/db', 'mysql'],
['mongodb://localhost:27017/db', 'mongodb'],
['mongodb+srv://cluster.example.net/db', 'mongodb'],
['libsql://my-db.turso.io', 'turso'],
['https://my-db.turso.io', 'turso'],
['wasm-sqlite://data.db', 'sqlite-wasm'],
['./local.wasm.db', 'sqlite-wasm'],
['file:./data/app.wasm.db', 'sqlite-wasm'],
['file:./data/app.db', 'sqlite'],
['sqlite:./data/app.db', 'sqlite'],
[':memory:', 'sqlite'],
['./data/app.db', 'sqlite'],
['data/app.sqlite3', 'sqlite'],
['./data/app', 'sqlite'],
['/var/lib/objectstack/app', 'sqlite'],
])('both hosts ACCEPT %s as %s, and the CLI builds it from that URL in dev and prod', (url, kind) => {
expect(runtimeUrlVerdict(url), `the runtime host refused ${url}`).toEqual({ accepted: true, driverId: kind });
for (const isDev of [false, true]) {
expect(cliUrlVerdict(url, isDev), `the CLI host (isDev=${isDev}) did not build ${url}`)
.toEqual({ accepted: true, driverId: kind, displayUrl: url });
}
});

// A `<scheme>://` neither host recognises: refused by both, the CLI's refusal
// being the typed one serve.ts makes fatal, in dev as in production.
it.each([
['redis://127.0.0.1:6399/0', 'redis://'],
['rediss://cache.example.com:6380', 'rediss://'],
['https://db.example.com/app', 'https://'],
['mssql://h/db', 'mssql://'],
['redis://h/app.db', 'redis://'],
])('both hosts REFUSE %s, in dev AND in prod', (url, scheme) => {
const runtime = runtimeUrlVerdict(url);
expect(runtime.accepted, `the runtime host accepted ${url}`).toBe(false);
for (const isDev of [false, true]) {
const cli = cliUrlVerdict(url, isDev);
expect(cli.accepted, `the CLI host (isDev=${isDev}) accepted ${url}`).toBe(false);
const error = (cli as { error: unknown }).error;
expect(error, `the CLI host (isDev=${isDev}) did not refuse ${url} by type`).toBeInstanceOf(UnsupportedDriverError);
expect((error as UnsupportedDriverError).recognized).toBe(false);
expect((error as Error).message).toContain(`"${scheme}"`);
expect((error as Error).message).toContain('OS_DATABASE_URL');
}
});

// An OPAQUE scheme — two or more characters and a `:`, no `//`
// (`jdbc:postgresql://…`, `redis:h`) — and any scheme a `*.wasm.db` file name
// used to rescue. The CLI host refuses them, typed, in dev and prod. The
// runtime host's answer is deliberately NOT pinned here: today it opens these
// as a SQLite file at a path made of the URL, an implementation gap against
// its own "unknown schemes throw" rule that is #23765's to close, so asserting
// either agreement or its current answer would pin that gap.
it.each([
['jdbc:postgresql://localhost:5432/db', 'jdbc:'],
['redis:cache', 'redis:'],
['redis://h/app.wasm.db', 'redis://'],
['jdbc:x/app.wasm.db', 'jdbc:'],
])('the CLI host REFUSES %s (scheme %s), in dev AND in prod', (url, scheme) => {
for (const isDev of [false, true]) {
const cli = cliUrlVerdict(url, isDev);
expect(cli.accepted, `the CLI host (isDev=${isDev}) accepted ${url}`).toBe(false);
const error = (cli as { error: unknown }).error;
expect(error, `the CLI host (isDev=${isDev}) did not refuse ${url} by type`).toBeInstanceOf(UnsupportedDriverError);
expect((error as UnsupportedDriverError).recognized).toBe(false);
expect((error as Error).message).toContain(`"${scheme}"`);
expect((error as Error).message).toContain('OS_DATABASE_URL');
}
});

// `sqlite:` / `sqlite://` are aliases of `file:` on both hosts, by ONE rule —
// the runtime host's `normalizeDatabaseUrl`, which the CLI host calls. Both
// hosts must name the same FILE: `sqlite://x.db` used to open `//x.db` (the
// filesystem root) on the CLI host while the runtime host opened `x.db`.
it.each([
['sqlite://x.db', 'x.db', 'sqlite'],
['sqlite:x.db', 'x.db', 'sqlite'],
['sqlite:///var/lib/objectstack/x.db', '/var/lib/objectstack/x.db', 'sqlite'],
['sqlite://x.wasm.db', 'x.wasm.db', 'sqlite-wasm'],
])('both hosts open %s as the file %s (%s), in dev and prod', (url, file, kind) => {
process.env.OS_DATABASE_URL = url;
const runtime = resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' });
expect(runtime.driver).toBe(kind);
expect(runtime.sqliteFile, `the runtime host's file for ${url}`).toBe(file);
for (const isDev of [false, true]) {
const definition = resolveStorageDefinition(resolveDriverType(undefined, url), { databaseUrl: url, isDev });
expect(definition!.driverId).toBe(kind);
expect(definition!.config.filename, `the CLI host's file for ${url} (isDev=${isDev})`).toBe(runtime.sqliteFile);
}
});
});
102 changes: 100 additions & 2 deletions packages/cli/src/utils/storage-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,17 @@ describe('inferDriverTypeFromUrl', () => {
expect(inferDriverTypeFromUrl('mongodb+srv://cluster/db')).toBe('mongodb');
expect(inferDriverTypeFromUrl('postgres://u:p@h/db')).toBe('postgres');
expect(inferDriverTypeFromUrl('postgresql://u:p@h/db')).toBe('postgres');
// The runtime host's third postgres spelling (`detectDriverFromUrl`).
expect(inferDriverTypeFromUrl('pg://u:p@h/db')).toBe('postgres');
expect(inferDriverTypeFromUrl('PG://u:p@h/db')).toBe('postgres');
expect(inferDriverTypeFromUrl('mysql://u:p@h/db')).toBe('mysql');
expect(inferDriverTypeFromUrl('mysql2://u:p@h/db')).toBe('mysql');
expect(inferDriverTypeFromUrl('libsql://x.turso.io')).toBe('turso');
expect(inferDriverTypeFromUrl('https://x.turso.io')).toBe('turso');
expect(inferDriverTypeFromUrl('wasm-sqlite://data.db')).toBe('sqlite-wasm');
expect(inferDriverTypeFromUrl('./local.wasm.db')).toBe('sqlite-wasm');
expect(inferDriverTypeFromUrl('file:./local.wasm.db')).toBe('sqlite-wasm');
expect(inferDriverTypeFromUrl('sqlite:./local.wasm.db')).toBe('sqlite-wasm');
expect(inferDriverTypeFromUrl('file:./app.db')).toBe('sqlite');
expect(inferDriverTypeFromUrl('sqlite:./app.db')).toBe('sqlite');
expect(inferDriverTypeFromUrl('./app.sqlite')).toBe('sqlite');
Expand Down Expand Up @@ -82,10 +87,71 @@ describe('inferDriverTypeFromUrl', () => {
expect(inferDriverTypeFromUrl(':memory:')).toBe('sqlite');
});

it('returns "" for an absent or unrecognized URL', () => {
// A value with no scheme is a SQLite file path whatever its name — the runtime
// host's arm. These used to answer '' (dev booted `:memory:`). A one-letter
// drive and a `:` is a drive, not a scheme.
it.each(['./data/app', 'data/app', '/var/lib/objectstack/app', 'C:\\data\\app', 'C:/data/app', './backup:2026.db'])(
'classifies the scheme-less path %s as a sqlite file',
(url) => {
expect(inferDriverTypeFromUrl(url)).toBe('sqlite');
},
);

it('returns "" only for an absent or blank URL', () => {
expect(inferDriverTypeFromUrl(undefined)).toBe('');
expect(inferDriverTypeFromUrl('')).toBe('');
expect(inferDriverTypeFromUrl('redis://localhost')).toBe('');
// Blank is absent — never a sqlite file named by whitespace.
expect(inferDriverTypeFromUrl(' ')).toBe('');
});

// An unrecognised `<scheme>://` is REFUSED at inference, as `memory://` is: it
// used to answer '', and `serve --dev` booted `:memory:` over the database the
// operator named. Asserted on the error's TYPE and on what it names (the
// variable, the scheme, recognised schemes), never on its prose.
it.each([
['redis://127.0.0.1:6399/0', 'redis://'],
['REDIS://h/0', 'REDIS://'],
['https://db.example.com/app', 'https://'],
['mssql://h/db', 'mssql://'],
// A `.db` name does not rescue a scheme nothing recognises.
['redis://h/app.db', 'redis://'],
// An OPAQUE scheme (two or more characters and a `:`, no `//`) is refused
// too, rather than opened as a file at a path made of the URL.
['jdbc:postgresql://localhost:5432/db', 'jdbc:'],
['redis:cache', 'redis:'],
// Nor does a `.wasm.db` name: it selects the WASM engine for a FILE only.
['redis://h/app.wasm.db', 'redis://'],
['jdbc:x/app.wasm.db', 'jdbc:'],
])('refuses %s at inference, naming the scheme and the recognised ones', (url, scheme) => {
let err: unknown;
try {
inferDriverTypeFromUrl(url);
} catch (e) {
err = e;
}
expect(err, `inferDriverTypeFromUrl accepted ${url}`).toBeInstanceOf(UnsupportedDriverError);
const refusal = err as UnsupportedDriverError;
expect(refusal.recognized).toBe(false);
expect(refusal.driverType).toBe(scheme);
expect(refusal.message).toContain(`"${scheme}"`);
expect(refusal.message).toContain('OS_DATABASE_URL');
for (const named of ['postgres://', 'pg://', 'mysql://', 'mongodb://', 'libsql://', 'file:', ':memory:']) {
expect(refusal.message, `the refusal does not name ${named}`).toContain(named);
}
});

// The refusal is printed on the way out, and the URL is where credentials ride.
it('names the scheme of a refused URL, never the URL and its credentials', () => {
const url = 'redis://admin:pw-not-a-real-secret@127.0.0.1:6399/0';
let message = '';
try {
inferDriverTypeFromUrl(url);
} catch (e) {
message = (e as Error).message;
}
expect(message, `${url} was not refused`).toContain('"redis://"');
expect(message).not.toContain('pw-not-a-real-secret');
expect(message).not.toContain('127.0.0.1');
});
});

Expand All @@ -102,6 +168,14 @@ describe('resolveDriverType', () => {
expect(resolveDriverType(' ', undefined)).toBe('');
// …and inference's refusal reaches the caller through this function too.
expect(() => resolveDriverType('', 'memory://')).toThrow(UnsupportedDriverError);
expect(() => resolveDriverType(undefined, 'redis://h/0')).toThrow(UnsupportedDriverError);
});

// An explicit driver still wins: the URL is not classified at all, so an
// unrecognised scheme beside OS_DATABASE_DRIVER is not refused here.
it('lets an explicit driver win over a URL whose scheme it does not recognise', () => {
expect(resolveDriverType('postgres', 'redis://h/0')).toBe('postgres');
expect(resolveDriverType('sqlite', 'redis://h/0')).toBe('sqlite');
});
});

Expand Down Expand Up @@ -192,6 +266,30 @@ describe('resolveStorageDefinition (#3826 — a definition, not a driver)', () =
expect(r!.sqliteFilePath).toBe('./app.db');
});

// `sqlite:` / `sqlite://` are aliases of `file:` (the runtime host's
// `normalizeDatabaseUrl`): `sqlite://x.db` is `x.db`, never `//x.db`, which
// is the filesystem root. The cross-host equality is pinned in
// `driver-vocabulary-parity.test.ts`.
it.each([
['sqlite://x.db', 'x.db'],
['sqlite:x.db', 'x.db'],
['sqlite:///var/lib/objectstack/x.db', '/var/lib/objectstack/x.db'],
['SQLITE://x.db', 'x.db'],
])('reads %s as the sqlite file %s', (url, file) => {
const r = resolveStorageDefinition(resolveDriverType(undefined, url), { databaseUrl: url, isDev: false });
expect(r!.config.filename).toBe(file);
expect(r!.sqliteFilePath).toBe(file);
// The banner keeps what the operator typed.
expect(r!.displayUrl).toBe(url);
});

it('reads sqlite://x.wasm.db as the WASM engine on the file x.wasm.db, by the same alias rule', () => {
const url = 'sqlite://x.wasm.db';
const r = resolveStorageDefinition(resolveDriverType(undefined, url), { databaseUrl: url, isDev: false });
expect(r!.driverId).toBe('sqlite-wasm');
expect(r!.config.filename).toBe('x.wasm.db');
});

it('dev default (no driver) declares sqlite :memory: with NO telemetry sibling', () => {
const r = resolveStorageDefinition('', { isDev: true });
expect(r!.driverId).toBe('sqlite');
Expand Down
Loading
Loading