From d453cf71c7a59784d518859f687bd2f51077cefe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 23:28:35 +0000 Subject: [PATCH 1/7] fix(cli): refuse an OS_DATABASE_URL whose scheme no driver recognises, and classify pg:// and scheme-less paths as the runtime host does inferDriverTypeFromUrl answered '' for an unrecognised `://` (redis://...), so `serve --dev` on the legacy path booted the SQLite :memory: default in silence and production met the no-storage refusal. It now throws UnsupportedDriverError (recognized: false) naming the scheme and the recognised set, and takes the runtime host's arms for pg:// (postgres) and a value with no scheme (a SQLite file path). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG --- packages/cli/src/utils/storage-driver.ts | 72 ++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/utils/storage-driver.ts b/packages/cli/src/utils/storage-driver.ts index 54c280a1799..8100f935394 100644 --- a/packages/cli/src/utils/storage-driver.ts +++ b/packages/cli/src/utils/storage-driver.ts @@ -187,7 +187,8 @@ function missingUrlMessage(kind: BuiltinDriverId): string { * value by name. A spelling of the retired in-memory engine (`memory`, * `mingo`, `in-memory`) is this case too, with the retirement's own wording, * and so is its `memory://` / `mingo://` scheme, which - * {@link inferDriverTypeFromUrl} refuses; + * {@link inferDriverTypeFromUrl} refuses — as it refuses every + * `://` it does not recognise, naming the scheme only; * - a recognized kind with **no local default** selected with **no URL** * (`postgres` / `mysql` / `mongodb` / `turso`). Only `turso` refused before; * the other three guessed — `url: undefined` into `pg`, an invented @@ -240,10 +241,40 @@ export class UnsupportedDriverError extends Error { } } +/** + * The `OS_DATABASE_URL` spellings {@link inferDriverTypeFromUrl} classifies, in + * the order the runtime host's refusal lists them — the sentence an operator + * reads when the scheme they typed is none of these. + * + * Written out rather than derived because the classifier is a chain of regexes, + * not a table. What keeps the two from drifting is + * `driver-vocabulary-parity.test.ts`, which drives BOTH hosts over the same + * URLs and holds their verdicts equal. + */ +const RECOGNISED_DATABASE_URL_SCHEMES = + 'postgres:// (or postgresql://, pg://), mysql:// (or mysql2://), mongodb:// (or mongodb+srv://), ' + + 'libsql:// or an https:// *.turso.* URL (optional @objectstack/driver-turso), wasm-sqlite://, ' + + 'file: (or sqlite:), or a file path with no scheme; :memory: is SQLite\'s in-memory database'; + +/** + * The refusal for an `OS_DATABASE_URL` whose `://` no arm recognises. + * + * It names the SCHEME and never the URL: the URL is where postgres/mysql/mongodb + * credentials ride, and this sentence is printed to the console on the way out. + */ +function unsupportedUrlSchemeMessage(scheme: string): string { + return ( + `Unsupported database URL scheme "${scheme}" (OS_DATABASE_URL / --database). ` + + `Supported: ${RECOGNISED_DATABASE_URL_SCHEMES}. ` + + 'Booting on the SQLite default instead would silently ignore the database you named ' + + 'and write into one you never chose. Fix the URL, or unset it.' + ); +} + /** * Infer a canonical driver kind from an `OS_DATABASE_URL` scheme. - * Returns `''` when the URL is absent or its scheme is unrecognized (the caller - * then falls back to the dev default / registers nothing in production). + * Returns `''` only when the URL is absent or blank (the caller then falls back + * to the dev default / registers nothing in production). * * Throws {@link UnsupportedDriverError} for `memory://` and `mingo://`: they name * the in-memory engine, which is no longer a boot store. Returning `''` instead @@ -251,12 +282,28 @@ export class UnsupportedDriverError extends Error { * #3276 shape again — and returning a kind would leave the refusal to * {@link resolveStorageDefinition}, whose spelling refusal names * `OS_DATABASE_DRIVER`, a variable this operator never set. + * + * Throws it too for any other `://` no arm below recognises + * (`redis://…`), for the same two reasons. That URL used to answer `''`, so + * `serve --dev` booted the SQLite `:memory:` default and threw away the database + * the operator named without a word, and a production boot met the no-storage + * refusal, which tells an operator who DID set `OS_DATABASE_URL` to set it. + * + * The arms are the runtime host's (`detectDriverFromUrl` in + * `packages/runtime/src/standalone-stack.ts`), so the two hosts give one answer + * per URL: `pg://` is postgres, and a value with no `://` is a SQLite + * file path, whatever its extension. The runtime refuses every other + * `://`, so refusing it here takes away nothing that host accepts. */ export function inferDriverTypeFromUrl(url: string | undefined): string { if (!url) return ''; const u = url.trim(); + // Blank is absent, as the runtime host's URL resolution reads it — never a + // file path named by whitespace (the scheme-less arm below would take it). + if (!u) return ''; if (/^mongodb(\+srv)?:\/\//i.test(u)) return 'mongodb'; - if (/^postgres(ql)?:\/\//i.test(u)) return 'postgres'; + // `pg://` is the runtime host's spelling too (`detectDriverFromUrl`). + if (/^(postgres(ql)?|pg):\/\//i.test(u)) return 'postgres'; if (/^mysql2?:\/\//i.test(u)) return 'mysql'; // libSQL / Turso URLs classify as `turso`, and since #5602 that classification // CONSTRUCTS: resolveStorageDefinition returns a `turso` definition and the CLI @@ -282,8 +329,21 @@ export function inferDriverTypeFromUrl(url: string | undefined): string { { recognized: false }, ); } - if (/^file:/i.test(u) || /^sqlite:/i.test(u) || u === ':memory:' || /\.(db|sqlite|sqlite3)$/i.test(u)) return 'sqlite'; - return ''; + if (/^file:/i.test(u) || /^sqlite:/i.test(u)) return 'sqlite'; + // No `://` at all: a SQLite file path — `:memory:`, `./app.db`, + // `data/app`. The runtime host's arm, character for character. It is what + // keeps a `.db` name from rescuing an unrecognised scheme: `redis://h/x.db` + // is refused below, as the runtime refuses it, rather than handed to SQLite + // as a file path. + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) return 'sqlite'; + const scheme = `${/^[a-z][a-z0-9+.-]*/i.exec(u)![0]}://`; + throw new UnsupportedDriverError( + scheme, + unsupportedUrlSchemeMessage(scheme), + // Not a kind this resolver can produce — `database-driver-allowlist.pin` + // reads `recognized` to tell the two apart. + { recognized: false }, + ); } /** From 54933125a003828a7149db8fd2690649bf4243fb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 23:37:27 +0000 Subject: [PATCH 2/7] test(cli): pin the unrecognised-scheme refusal and the URL door's agreement with the runtime host, in dev and prod Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG --- .../utils/driver-vocabulary-parity.test.ts | 114 ++++++++++++++++++ packages/cli/src/utils/storage-driver.test.ts | 68 ++++++++++- 2 files changed, 180 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/driver-vocabulary-parity.test.ts b/packages/cli/src/utils/driver-vocabulary-parity.test.ts index 73b1321eda9..a509b8f8cb4 100644 --- a/packages/cli/src/utils/driver-vocabulary-parity.test.ts +++ b/packages/cli/src/utils/driver-vocabulary-parity.test.ts @@ -372,3 +372,117 @@ 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 = {}; + 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.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 `://` 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'); + } + }); +}); diff --git a/packages/cli/src/utils/storage-driver.test.ts b/packages/cli/src/utils/storage-driver.test.ts index 26e19e677b1..4a7c815ef20 100644 --- a/packages/cli/src/utils/storage-driver.test.ts +++ b/packages/cli/src/utils/storage-driver.test.ts @@ -43,6 +43,9 @@ 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'); @@ -82,10 +85,63 @@ describe('inferDriverTypeFromUrl', () => { expect(inferDriverTypeFromUrl(':memory:')).toBe('sqlite'); }); - it('returns "" for an absent or unrecognized URL', () => { + // A value with no `://` is a SQLite file path whatever its name — the + // runtime host's arm. These used to answer '' (dev booted `:memory:`). + it.each(['./data/app', 'data/app', '/var/lib/objectstack/app', 'C:\\data\\app'])( + '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 `://` 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://'], + ])('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'); }); }); @@ -102,6 +158,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'); }); }); From c039b64a71a56a5b033b43fb84d67fd47b02a275 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 23:45:47 +0000 Subject: [PATCH 3/7] test(cli): pin that both hosts agree on an opaque-scheme value; changeset for the URL-scheme refusal Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG --- .../23633-unrecognised-database-url-scheme.md | 15 +++++++++++++++ .../src/utils/driver-vocabulary-parity.test.ts | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 .changeset/23633-unrecognised-database-url-scheme.md diff --git a/.changeset/23633-unrecognised-database-url-scheme.md b/.changeset/23633-unrecognised-database-url-scheme.md new file mode 100644 index 00000000000..d73d4a54344 --- /dev/null +++ b/.changeset/23633-unrecognised-database-url-scheme.md @@ -0,0 +1,15 @@ +--- +'@objectstack/cli': patch +--- + +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 + +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` 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. +- **`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. + +`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. diff --git a/packages/cli/src/utils/driver-vocabulary-parity.test.ts b/packages/cli/src/utils/driver-vocabulary-parity.test.ts index a509b8f8cb4..2cce3a9e812 100644 --- a/packages/cli/src/utils/driver-vocabulary-parity.test.ts +++ b/packages/cli/src/utils/driver-vocabulary-parity.test.ts @@ -485,4 +485,21 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro expect((error as Error).message).toContain('OS_DATABASE_URL'); } }); + + // A value carrying a scheme with no `//` (`jdbc:postgresql://…`, `redis:h`) + // matches neither host's `://` test, so the runtime host takes it as a + // SQLite file path and the CLI host mirrors it. Which answer is RIGHT is not + // this row's question; that the two hosts give the SAME one is — so a change + // to either host's scheme-less arm reddens here until the other follows. + it.each(['jdbc:postgresql://localhost:5432/db', 'redis:cache'])( + 'both hosts give %s the same verdict, in dev and prod', + (url) => { + const runtime = runtimeUrlVerdict(url); + for (const isDev of [false, true]) { + const cli = cliUrlVerdict(url, isDev); + expect(cli.accepted, `the hosts disagree on ${url} (isDev=${isDev})`).toBe(runtime.accepted); + if (cli.accepted && runtime.accepted) expect(cli.driverId).toBe(runtime.driverId); + } + }, + ); }); From 76b8bdc0a0ce710d1c9e32bb613c0ad3b90fc282 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:46:51 +0000 Subject: [PATCH 4/7] fix(cli): refuse an opaque OS_DATABASE_URL scheme, and translate sqlite:/sqlite:// with the runtime host's normalizeDatabaseUrl A value whose scheme is two or more characters and a colon with no `//` (jdbc:postgresql://..., redis:h) is refused with the same UnsupportedDriverError and sentence, naming the scheme only; a one-letter drive (C:\data\app) stays a path. The sqlite arm now derives its file through normalizeDatabaseUrl, so sqlite://x.db opens x.db, not //x.db. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG --- packages/cli/src/utils/storage-driver.ts | 53 +++++++++++++++--------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/utils/storage-driver.ts b/packages/cli/src/utils/storage-driver.ts index 8100f935394..77ea2f4ba98 100644 --- a/packages/cli/src/utils/storage-driver.ts +++ b/packages/cli/src/utils/storage-driver.ts @@ -104,6 +104,7 @@ import { devAutoMigrateConfig, loadTursoDriverFactory as loadRuntimeTursoDriverFactory, namesRetiredMemoryEngine, + normalizeDatabaseUrl, retiredMemoryEngineMessage, type LoadTursoDriverFactoryOptions, } from '@objectstack/runtime'; @@ -291,9 +292,11 @@ function unsupportedUrlSchemeMessage(scheme: string): string { * * The arms are the runtime host's (`detectDriverFromUrl` in * `packages/runtime/src/standalone-stack.ts`), so the two hosts give one answer - * per URL: `pg://` is postgres, and a value with no `://` is a SQLite - * file path, whatever its extension. The runtime refuses every other - * `://`, so refusing it here takes away nothing that host accepts. + * per URL: `pg://` is postgres, and a value with no scheme is a SQLite file + * path, whatever its extension. The runtime refuses every other + * `://`, so refusing it here takes away nothing that host means to + * accept. The one difference is an OPAQUE scheme (`jdbc:…`, `redis:h`), which + * this host refuses and the runtime host still opens as a file (#23765). */ export function inferDriverTypeFromUrl(url: string | undefined): string { if (!url) return ''; @@ -330,20 +333,29 @@ export function inferDriverTypeFromUrl(url: string | undefined): string { ); } if (/^file:/i.test(u) || /^sqlite:/i.test(u)) return 'sqlite'; - // No `://` at all: a SQLite file path — `:memory:`, `./app.db`, - // `data/app`. The runtime host's arm, character for character. It is what - // keeps a `.db` name from rescuing an unrecognised scheme: `redis://h/x.db` - // is refused below, as the runtime refuses it, rather than handed to SQLite - // as a file path. - if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) return 'sqlite'; - const scheme = `${/^[a-z][a-z0-9+.-]*/i.exec(u)![0]}://`; - throw new UnsupportedDriverError( - scheme, - unsupportedUrlSchemeMessage(scheme), - // Not a kind this resolver can produce — `database-driver-allowlist.pin` - // reads `recognized` to tell the two apart. - { recognized: false }, - ); + // What is left with a scheme is refused, naming the scheme and never the URL: + // - an unrecognised `://` (`redis://…`). Refusing it here, rather + // than letting a name that ends `.db` reach SQLite, is what keeps + // `redis://h/x.db` refused, as the runtime host refuses it; + // - an OPAQUE scheme, two or more characters and a `:` with no `//` + // (`jdbc:postgresql://…`, `redis:h`). The runtime host's scheme-less arm + // opens these as a SQLite file at a path made of the URL. That is an + // implementation gap on that host (#23765), against its own rule that an + // unknown scheme never falls back to SQLite, so it is not copied here. + // A single letter and a `:` is a drive (`C:\data\app`, `C:/data/app`), not + // a scheme, and stays a path. + const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(u)?.[0] ?? /^[a-z][a-z0-9+.-]+:/i.exec(u)?.[0]; + if (scheme) { + throw new UnsupportedDriverError( + scheme, + unsupportedUrlSchemeMessage(scheme), + // Not a kind this resolver can produce — `database-driver-allowlist.pin` + // reads `recognized` to tell the two apart. + { recognized: false }, + ); + } + // No scheme at all: a SQLite file path — `:memory:`, `./app.db`, `data/app`. + return 'sqlite'; } /** @@ -500,9 +512,12 @@ export function resolveStorageDefinition( } if (kind === 'sqlite') { - const filePath = (databaseUrl ?? ':memory:') + // `sqlite:` and `sqlite://` are aliases of `file:`, translated by the runtime + // host's own rule (`normalizeDatabaseUrl`) rather than a second copy of it, + // so both hosts open one file: `sqlite://x.db` is `x.db`, never `//x.db` + // (the filesystem root), and `sqlite:///abs/x.db` is `/abs/x.db`. + const filePath = normalizeDatabaseUrl(databaseUrl ?? ':memory:') .replace(/^file:/, '') - .replace(/^sqlite:/, '') .replace(/^sql:\/\//, ''); return { driverId: 'sqlite', From d343c715bd7c8c31afaaa49168e559be596e42d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:47:58 +0000 Subject: [PATCH 5/7] test(cli): pin the opaque-scheme refusal and the sqlite alias file on both hosts; changeset for both Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG --- .../23633-unrecognised-database-url-scheme.md | 6 +- .../utils/driver-vocabulary-parity.test.ts | 57 +++++++++++++------ packages/cli/src/utils/storage-driver.test.ts | 28 ++++++++- 3 files changed, 70 insertions(+), 21 deletions(-) diff --git a/.changeset/23633-unrecognised-database-url-scheme.md b/.changeset/23633-unrecognised-database-url-scheme.md index d73d4a54344..86534d56b21 100644 --- a/.changeset/23633-unrecognised-database-url-scheme.md +++ b/.changeset/23633-unrecognised-database-url-scheme.md @@ -2,7 +2,7 @@ '@objectstack/cli': patch --- -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 +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 @@ -11,5 +11,7 @@ This is the `os serve` path a host config takes (one whose `plugins` hold plugin - **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` 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. - **`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. -`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. +`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. diff --git a/packages/cli/src/utils/driver-vocabulary-parity.test.ts b/packages/cli/src/utils/driver-vocabulary-parity.test.ts index 2cce3a9e812..f4c72678b74 100644 --- a/packages/cli/src/utils/driver-vocabulary-parity.test.ts +++ b/packages/cli/src/utils/driver-vocabulary-parity.test.ts @@ -486,20 +486,45 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro } }); - // A value carrying a scheme with no `//` (`jdbc:postgresql://…`, `redis:h`) - // matches neither host's `://` test, so the runtime host takes it as a - // SQLite file path and the CLI host mirrors it. Which answer is RIGHT is not - // this row's question; that the two hosts give the SAME one is — so a change - // to either host's scheme-less arm reddens here until the other follows. - it.each(['jdbc:postgresql://localhost:5432/db', 'redis:cache'])( - 'both hosts give %s the same verdict, in dev and prod', - (url) => { - const runtime = runtimeUrlVerdict(url); - for (const isDev of [false, true]) { - const cli = cliUrlVerdict(url, isDev); - expect(cli.accepted, `the hosts disagree on ${url} (isDev=${isDev})`).toBe(runtime.accepted); - if (cli.accepted && runtime.accepted) expect(cli.driverId).toBe(runtime.driverId); - } - }, - ); + // An OPAQUE scheme — two or more characters and a `:`, no `//` + // (`jdbc:postgresql://…`, `redis:h`). The CLI host refuses it, typed, in dev + // and prod. The runtime host's answer is deliberately NOT pinned here: today + // its scheme-less arm 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:'], + ])('the CLI host REFUSES the opaque scheme of %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:x.db', 'x.db'], + ['sqlite:///var/lib/objectstack/x.db', '/var/lib/objectstack/x.db'], + ])('both hosts open %s as the sqlite file %s, in dev and prod', (url, file) => { + process.env.OS_DATABASE_URL = url; + const runtime = resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' }); + expect(runtime.driver).toBe('sqlite'); + 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('sqlite'); + expect(definition!.config.filename, `the CLI host's file for ${url} (isDev=${isDev})`).toBe(runtime.sqliteFile); + } + }); }); diff --git a/packages/cli/src/utils/storage-driver.test.ts b/packages/cli/src/utils/storage-driver.test.ts index 4a7c815ef20..bfcd527e908 100644 --- a/packages/cli/src/utils/storage-driver.test.ts +++ b/packages/cli/src/utils/storage-driver.test.ts @@ -85,9 +85,10 @@ describe('inferDriverTypeFromUrl', () => { expect(inferDriverTypeFromUrl(':memory:')).toBe('sqlite'); }); - // A value with no `://` is a SQLite file path whatever its name — the - // runtime host's arm. These used to answer '' (dev booted `:memory:`). - it.each(['./data/app', 'data/app', '/var/lib/objectstack/app', 'C:\\data\\app'])( + // 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'); @@ -112,6 +113,10 @@ describe('inferDriverTypeFromUrl', () => { ['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:'], ])('refuses %s at inference, naming the scheme and the recognised ones', (url, scheme) => { let err: unknown; try { @@ -256,6 +261,23 @@ 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('dev default (no driver) declares sqlite :memory: with NO telemetry sibling', () => { const r = resolveStorageDefinition('', { isDev: true }); expect(r!.driverId).toBe('sqlite'); From eff7af20b1165adcba1a57682af8a324285df381 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 01:31:00 +0000 Subject: [PATCH 6/7] fix(cli): a *.wasm.db name selects the WASM engine for a file only, and the sqlite-wasm arm takes the same alias rule A `.wasm.db` ending was tested ahead of every scheme check, so it rescued a scheme nothing recognises (redis://h/x.wasm.db, jdbc:x/x.wasm.db) into a SQLite file. It now applies to a file:/sqlite: URL or a scheme-less value only. The sqlite-wasm arm derives its file through normalizeDatabaseUrl as the sqlite arm does, so sqlite://x.wasm.db opens x.wasm.db. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG --- .../utils/driver-vocabulary-parity.test.ts | 30 +++++++++++-------- packages/cli/src/utils/storage-driver.test.ts | 12 ++++++++ packages/cli/src/utils/storage-driver.ts | 17 +++++++---- 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/utils/driver-vocabulary-parity.test.ts b/packages/cli/src/utils/driver-vocabulary-parity.test.ts index f4c72678b74..0b6a7f4889c 100644 --- a/packages/cli/src/utils/driver-vocabulary-parity.test.ts +++ b/packages/cli/src/utils/driver-vocabulary-parity.test.ts @@ -449,6 +449,7 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro ['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'], @@ -487,16 +488,18 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro }); // An OPAQUE scheme — two or more characters and a `:`, no `//` - // (`jdbc:postgresql://…`, `redis:h`). The CLI host refuses it, typed, in dev - // and prod. The runtime host's answer is deliberately NOT pinned here: today - // its scheme-less arm 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. + // (`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:'], - ])('the CLI host REFUSES the opaque scheme of %s, in dev AND in prod', (url, scheme) => { + ['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); @@ -513,17 +516,18 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro // 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:x.db', 'x.db'], - ['sqlite:///var/lib/objectstack/x.db', '/var/lib/objectstack/x.db'], - ])('both hosts open %s as the sqlite file %s, in dev and prod', (url, file) => { + ['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('sqlite'); + 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('sqlite'); + expect(definition!.driverId).toBe(kind); expect(definition!.config.filename, `the CLI host's file for ${url} (isDev=${isDev})`).toBe(runtime.sqliteFile); } }); diff --git a/packages/cli/src/utils/storage-driver.test.ts b/packages/cli/src/utils/storage-driver.test.ts index bfcd527e908..43d71b8bf7f 100644 --- a/packages/cli/src/utils/storage-driver.test.ts +++ b/packages/cli/src/utils/storage-driver.test.ts @@ -52,6 +52,8 @@ describe('inferDriverTypeFromUrl', () => { 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'); @@ -117,6 +119,9 @@ describe('inferDriverTypeFromUrl', () => { // 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 { @@ -278,6 +283,13 @@ describe('resolveStorageDefinition (#3826 — a definition, not a driver)', () = 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'); diff --git a/packages/cli/src/utils/storage-driver.ts b/packages/cli/src/utils/storage-driver.ts index 77ea2f4ba98..893ea05be3e 100644 --- a/packages/cli/src/utils/storage-driver.ts +++ b/packages/cli/src/utils/storage-driver.ts @@ -317,7 +317,12 @@ export function inferDriverTypeFromUrl(url: string | undefined): string { // MissingDriverPackageError). if (/^libsql:\/\//i.test(u)) return 'turso'; if (/^https?:\/\//i.test(u) && /\.turso\./i.test(u)) return 'turso'; - if (/^wasm-sqlite:\/\//i.test(u) || /\.wasm\.db$/i.test(u)) return 'sqlite-wasm'; + if (/^wasm-sqlite:\/\//i.test(u)) return 'sqlite-wasm'; + // A `*.wasm.db` file name selects the WASM engine only for a FILE: a `file:` / + // `sqlite:` URL or a value with no scheme (both returns below). Tested here, + // ahead of every scheme check, it let the name rescue a scheme nothing + // recognises (`redis://h/x.wasm.db`, `jdbc:x/x.wasm.db`) into a SQLite file. + const wasmFile = /\.wasm\.db$/i.test(u); // The retired in-memory engine's two schemes — character-for-character the // runtime's `detectDriverFromUrl` arm, so the two hosts recognise and refuse // the same URLs. Kept ABOVE the sqlite test so it is not shadowed, and @@ -332,7 +337,7 @@ export function inferDriverTypeFromUrl(url: string | undefined): string { { recognized: false }, ); } - if (/^file:/i.test(u) || /^sqlite:/i.test(u)) return 'sqlite'; + if (/^file:/i.test(u) || /^sqlite:/i.test(u)) return wasmFile ? 'sqlite-wasm' : 'sqlite'; // What is left with a scheme is refused, naming the scheme and never the URL: // - an unrecognised `://` (`redis://…`). Refusing it here, rather // than letting a name that ends `.db` reach SQLite, is what keeps @@ -355,7 +360,7 @@ export function inferDriverTypeFromUrl(url: string | undefined): string { ); } // No scheme at all: a SQLite file path — `:memory:`, `./app.db`, `data/app`. - return 'sqlite'; + return wasmFile ? 'sqlite-wasm' : 'sqlite'; } /** @@ -533,10 +538,10 @@ export function resolveStorageDefinition( } if (kind === 'sqlite-wasm') { - const filePath = (databaseUrl ?? ':memory:') + // The same alias rule as the sqlite arm: `sqlite://x.wasm.db` is `x.wasm.db`. + const filePath = normalizeDatabaseUrl(databaseUrl ?? ':memory:') .replace(/^file:/, '') - .replace(/^wasm-sqlite:\/\//, '') - .replace(/^sqlite:/, ''); + .replace(/^wasm-sqlite:\/\//, ''); return { driverId: 'sqlite-wasm', // `persist` passthrough: the CLI kept `on-disconnect` semantics here From 40fe66778a356be95d7a6e3118cdbd128ff60d6c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 01:31:15 +0000 Subject: [PATCH 7/7] docs(changeset): the .wasm.db ending and the sqlite-wasm alias in the URL-scheme changeset Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG --- .changeset/23633-unrecognised-database-url-scheme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/23633-unrecognised-database-url-scheme.md b/.changeset/23633-unrecognised-database-url-scheme.md index 86534d56b21..d4f9093ad28 100644 --- a/.changeset/23633-unrecognised-database-url-scheme.md +++ b/.changeset/23633-unrecognised-database-url-scheme.md @@ -9,9 +9,9 @@ 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` 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. +- **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.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.