From affea1dd229c07b2174eaccf609091b584b35694 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:09:23 +0000 Subject: [PATCH 1/3] test(runtime): pin the explicit sqlite / sqlite-wasm refusal of a URL that is not a file path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rows go red on 34b740f2d1, where an explicit selection opens `jdbc:postgresql://…`, `redis:h` and (on sqlite-wasm) a `postgres://…` server URL as a SQLite file at a path made of the URL. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- ...k-explicit-sqlite-non-file-refusal.test.ts | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 packages/runtime/src/standalone-stack-explicit-sqlite-non-file-refusal.test.ts diff --git a/packages/runtime/src/standalone-stack-explicit-sqlite-non-file-refusal.test.ts b/packages/runtime/src/standalone-stack-explicit-sqlite-non-file-refusal.test.ts new file mode 100644 index 00000000000..839fb293620 --- /dev/null +++ b/packages/runtime/src/standalone-stack-explicit-sqlite-non-file-refusal.test.ts @@ -0,0 +1,265 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Under an explicit `sqlite` or `sqlite-wasm` selection, the standalone host +// refuses a database URL that is not a file path. The refusal names the +// selected kind and the URL's scheme, never the URL, and the boot stops before +// it makes a directory. +// +// ## The defect, measured on `origin/main` 34b740f2d1 +// +// An explicit selection (`databaseDriver`, `OS_DATABASE_DRIVER`) skips the URL +// classification, so the only check a value meets is `sqliteFilenameFromUrl`. +// That rule refused only a `SCHEME://` URL, and only for the `sqlite` kind: +// +// - `OS_DATABASE_DRIVER=sqlite OS_DATABASE_URL=jdbc:postgresql://127.0.0.1:5999/db +// os serve --dev` booted ready on a SQLite file and created the directories +// `./jdbc:postgresql:/127.0.0.1:5999/`; +// - `OS_DATABASE_DRIVER=sqlite-wasm` beside a `postgres://USER:SECRET@…` URL +// created `./postgres:/USER:SECRET@127.0.0.1:5999/`, the credentials in a +// directory name. +// +// ## What is asserted +// +// 1. Both kinds, through both selection doors: an OPAQUE scheme (two or more +// characters and a `:` with no `//`), an unrecognised `SCHEME://` and a +// server URL are refused with one sentence that names the kind and the +// scheme, and no own property of the error carries a credential. +// 2. The boot (`createStandaloneStack`) is refused before it makes anything. +// 3. CONTROL: every file value resolves to the file it named before. +// 4. The one file name that changes answer: `backup:2026.db` reads as the +// scheme `backup:` and is refused, as it is on the classification path; +// `./backup:2026.db` and `file:backup:2026.db` still name that file. +// 5. The boundary: the declared-datasource and `TURSO_DATABASE_URL` rungs hand +// the rule their URL without `normalizeDatabaseUrl`, so there a `sqlite:` +// alias is refused as a scheme rather than opened as a file of that name. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createStandaloneStack, resolveStandaloneDatabase, sqliteFilenameFromUrl } from './standalone-stack.js'; + +// Pay the first transform of what `createStandaloneStack` imports lazily at +// MODULE LOAD, never inside a clocked `it()` body: clocked windows measure +// behaviour, never loading (`scripts/check-test-source-alias.mjs`). +import '@objectstack/service-datasource'; +import '@objectstack/objectql'; +import '@objectstack/metadata'; +import './default-datasource-plugin.js'; +import './app-plugin.js'; + +// Placeholders only: nothing here is, or looks like, a real secret. +const USER = 'PLACEHOLDER-USER'; +const SECRET = 'PLACEHOLDER-SECRET'; +const TOKEN = 'PLACEHOLDER-TOKEN'; +/** A userinfo with a password, a host, a path and a token query. */ +const CREDENTIALED_TAIL = `${USER}:${SECRET}@127.0.0.1:5999/db?authToken=${TOKEN}`; + +const KINDS = ['sqlite', 'sqlite-wasm'] as const; +type SqliteKind = (typeof KINDS)[number]; + +/** Env keys these tests write or that could answer first; restored after every case. */ +const ENV_KEYS = [ + 'OS_DATABASE_URL', + 'DATABASE_URL', + 'TURSO_DATABASE_URL', + 'OS_DATABASE_DRIVER', + 'OS_HOME', + 'OS_ARTIFACT_PATH', +] as const; +const ORIGINAL_ENV: Record = Object.fromEntries( + ENV_KEYS.map((k) => [k, process.env[k]]), +); + +let root: string; +beforeEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + root = mkdtempSync(join(tmpdir(), 'os-explicit-sqlite-non-file-')); + // Every rung that could answer before the one under test points at this + // empty directory, so no developer state leaks into a case. + process.env.OS_HOME = root; +}); +afterEach(() => { + try { rmSync(root, { recursive: true, force: true }); } catch { /* noop */ } + for (const key of ENV_KEYS) { + const original = ORIGINAL_ENV[key]; + if (original === undefined) delete process.env[key]; + else process.env[key] = original; + } +}); + +/** The two selection doors, each with the URL from its own door. */ +const DOORS = { + 'the databaseDriver and databaseUrl options': (kind: SqliteKind, url: string) => + resolveStandaloneDatabase({ databaseDriver: kind, databaseUrl: url, artifactPath: join(root, 'none.json') }), + 'OS_DATABASE_DRIVER and OS_DATABASE_URL': (kind: SqliteKind, url: string) => { + process.env.OS_DATABASE_DRIVER = kind; + process.env.OS_DATABASE_URL = url; + return resolveStandaloneDatabase({ artifactPath: join(root, 'none.json') }); + }, +} as const; + +/** The error `run` throws; fails the case when it throws nothing. */ +function refusalOf(run: () => unknown): Error { + try { + run(); + } catch (err) { + return err as Error; + } + throw new Error('expected a refusal, but the URL resolved'); +} + +/** The rule's refusal: a plain `Error` naming the kind and the scheme, and no credential anywhere on it. */ +function expectSchemeRefusal(err: Error, kind: SqliteKind, scheme: string): void { + expect(err.constructor).toBe(Error); + expect(err.message).toContain(`[StandaloneStack] ${kind} driver was selected but the URL does not look like a file path`); + expect(err.message).toContain(`its scheme is "${scheme}"`); + const everything = JSON.stringify(err, Object.getOwnPropertyNames(err)); + // Positive control: the serialisation really holds the message, so the + // negative reads below read something. + expect(everything).toContain(JSON.stringify(err.message)); + for (const placeholder of [USER, SECRET, TOKEN]) { + expect(everything, `the refusal carries ${placeholder}`).not.toContain(placeholder); + } +} + +/** A compiled artifact whose declared default datasource carries `url` under a server driver. */ +function artifactDeclaring(url: string): string { + const artifactPath = join(root, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify({ + datasources: [{ name: 'primary', driver: 'postgres', config: { url } }], + datasourceMapping: [{ default: true, datasource: 'primary' }], + })); + return artifactPath; +} + +/** Every value the rule refuses under both kinds, with the scheme the refusal names. */ +const REFUSED: ReadonlyArray = [ + ['jdbc:postgresql://127.0.0.1:5999/db', 'jdbc:'], + [`jdbc:postgresql://${CREDENTIALED_TAIL}`, 'jdbc:'], + ['redis:h', 'redis:'], + [`redis:${CREDENTIALED_TAIL}`, 'redis:'], + ['redis://h/x.db', 'redis://'], + [`postgres://${CREDENTIALED_TAIL}`, 'postgres://'], + [`postgres://${USER}:${SECRET}@127.0.0.1:5999/db`, 'postgres://'], +]; + +describe('an explicit sqlite / sqlite-wasm selection refuses a URL that is not a file path', () => { + for (const [door, run] of Object.entries(DOORS)) { + describe(`via ${door}`, () => { + it.each(KINDS.flatMap((kind) => REFUSED.map(([url, scheme]) => [kind, url, scheme] as const)))( + '%s refuses %s, naming the scheme %s only', + (kind, url, scheme) => { + expectSchemeRefusal(refusalOf(() => run(kind, url)), kind, scheme); + }, + ); + }); + } + + it.each(KINDS)('the exported rule itself refuses the same values for %s', (kind) => { + for (const [url, scheme] of REFUSED) { + expectSchemeRefusal(refusalOf(() => sqliteFilenameFromUrl(url, kind)), kind, scheme); + } + }); +}); + +describe('the boot is refused before it makes a directory', () => { + // The two door values the defect was measured with, each under its kind. The + // URL is relative, so the bogus directory would land in the cwd: the case + // runs in an empty one and reads it back. + it.each([ + ['sqlite', 'jdbc:postgresql://127.0.0.1:5999/db', 'jdbc:'], + ['sqlite-wasm', `postgres://${USER}:${SECRET}@127.0.0.1:5999/db`, 'postgres://'], + ] as const)('%s over %s', async (kind, url, scheme) => { + const cwd = process.cwd(); + const work = mkdtempSync(join(tmpdir(), 'os-explicit-sqlite-boot-')); + process.chdir(work); + try { + let err: unknown; + try { + await createStandaloneStack({ databaseDriver: kind, databaseUrl: url, artifactPath: join(work, 'none.json') }); + } catch (e) { + err = e; + } + expect(err, 'the boot accepted a URL that is not a file path').toBeInstanceOf(Error); + expectSchemeRefusal(err as Error, kind, scheme); + expect(readdirSync(work)).toEqual([]); + } finally { + process.chdir(cwd); + rmSync(work, { recursive: true, force: true }); + } + }); +}); + +describe('CONTROL: a file value names the file it named before', () => { + // `null` is resolveStandaloneDatabase's answer for an in-memory database. + const FILES: ReadonlyArray = [ + ['file:./x.db', './x.db'], + ['./x.db', './x.db'], + ['C:\\data\\x.db', 'C:\\data\\x.db'], + ['C:/data/x.db', 'C:/data/x.db'], + [':memory:', null], + // A `file:` prefix the rule strips leaves a file name, colons and all. + ['file:backup:2026.db', 'backup:2026.db'], + ['./backup:2026.db', './backup:2026.db'], + // Normalized by these doors (`sqlite:` is an alias of `file:`). + ['sqlite:./x.db', './x.db'], + ['sqlite:///var/lib/objectstack/x.db', '/var/lib/objectstack/x.db'], + ]; + + for (const [door, run] of Object.entries(DOORS)) { + it.each(KINDS.flatMap((kind) => FILES.map(([url, file]) => [kind, url, file] as const)))( + `via ${door}: %s opens %s as %s`, + (kind, url, file) => { + const resolved = run(kind, url); + expect(resolved.driver).toBe(kind); + expect(resolved.sqliteFile).toBe(file); + }, + ); + } + + it('via the databaseUrl option: sqlite-wasm opens wasm-sqlite:///tmp/x.db as /tmp/x.db', () => { + const resolved = DOORS['the databaseDriver and databaseUrl options']('sqlite-wasm', 'wasm-sqlite:///tmp/x.db'); + expect(resolved.sqliteFile).toBe('/tmp/x.db'); + }); + + it.each([ + ['', ':memory:'], + ['wasm-sqlite://', ':memory:'], + [':memory:', ':memory:'], + ])('the exported rule: sqlite-wasm reads %j as %s', (url, file) => { + expect(sqliteFilenameFromUrl(url, 'sqlite-wasm')).toBe(file); + }); +}); + +describe('the one file name that changes answer: a first segment holding a colon', () => { + it.each(KINDS)('%s refuses backup:2026.db as the scheme "backup:", and names the file when written ./ or file:', (kind) => { + for (const run of Object.values(DOORS)) { + expectSchemeRefusal(refusalOf(() => run(kind, 'backup:2026.db')), kind, 'backup:'); + expect(run(kind, './backup:2026.db').sqliteFile).toBe('./backup:2026.db'); + expect(run(kind, 'file:backup:2026.db').sqliteFile).toBe('backup:2026.db'); + } + }); +}); + +describe('the boundary: rungs that do not normalize the URL', () => { + // `resolveProjectDatabaseUrl` applies `normalizeDatabaseUrl` (`sqlite:` and + // `sqlite://` become `file:`) on the explicit and OS_DATABASE_URL rungs only. + // A declared datasource's `url` and `TURSO_DATABASE_URL` reach the rule as + // written, so there the alias is a scheme: refused, where it used to open a + // file named `sqlite:./x.db` (and, on the WASM kind, `sqlite:///…`). + it.each(KINDS.flatMap((kind) => [ + [kind, 'sqlite:./x.db', 'sqlite:'], + [kind, 'sqlite:///var/lib/objectstack/x.db', 'sqlite://'], + ] as const))('%s refuses the declared datasource url %s as the scheme %s', (kind, url, scheme) => { + const err = refusalOf(() => + resolveStandaloneDatabase({ databaseDriver: kind, projectRoot: root, artifactPath: artifactDeclaring(url) })); + expectSchemeRefusal(err, kind, scheme); + }); + + it.each(KINDS)('%s refuses TURSO_DATABASE_URL=sqlite:./x.db as the scheme "sqlite:"', (kind) => { + process.env.TURSO_DATABASE_URL = 'sqlite:./x.db'; + const err = refusalOf(() => resolveStandaloneDatabase({ databaseDriver: kind, artifactPath: join(root, 'none.json') })); + expectSchemeRefusal(err, kind, 'sqlite:'); + }); +}); From 84cf69519fefb4be425f35185006d8b9e30187ce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:10:20 +0000 Subject: [PATCH 2/3] fix(runtime): an explicit sqlite / sqlite-wasm selection refuses a URL that is not a file path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sqliteFilenameFromUrl` is the only check an explicit selection's URL meets, and it refused only a `SCHEME://` URL on the `sqlite` kind. Both kinds now refuse an opaque scheme (`jdbc:postgresql://…`, `redis:h`) on the value as given, and the `sqlite-wasm` kind refuses a `SCHEME://` URL too. A value whose `file:` (or `wasm-sqlite:`) prefix the rule strips stays a file, so `file:backup:2026.db` still names `backup:2026.db`. One sentence for both kinds, naming the kind and the scheme only. The opaque-scheme expression `detectDriverFromUrl` gained in the merged opaque-scheme fix is now one module-private helper, `schemeOf`, that both functions call. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- packages/cli/src/utils/storage-driver.ts | 13 +++-- packages/runtime/src/standalone-stack.ts | 69 +++++++++++++++++++----- 2 files changed, 64 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/utils/storage-driver.ts b/packages/cli/src/utils/storage-driver.ts index d635d518614..c89a3778755 100644 --- a/packages/cli/src/utils/storage-driver.ts +++ b/packages/cli/src/utils/storage-driver.ts @@ -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 `://`, 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. diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index 36f8ed96c14..f3f84355f73 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -427,6 +427,25 @@ function urlSchemeOf(dbUrl: string): string | undefined { return /^[a-z][a-z0-9+.-]*:\/\//i.exec(dbUrl)?.[0]; } +/** + * The scheme a database URL opens with — its `://` ({@link urlSchemeOf}) + * or an OPAQUE scheme, two or more characters and a `:` with no `//` + * (`jdbc:`, `redis:`) — or `undefined` when it has neither and so is a path. + * + * A single letter and a `:` is a Windows drive (`C:\data\app.db`, + * `C:/data/app.db`), never a scheme. An opaque scheme ends at its FIRST `:`, so + * like a `://` it cannot hold a userinfo, a host or a path, and a + * refusal may name it. ONE answer for the two places this host asks whether a + * value is a path: the URL classification ({@link detectDriverFromUrl}) and the + * SQLite filename rule an explicit selection meets instead + * ({@link sqliteFilenameFromUrl}). The CLI host draws the same line + * (`inferDriverTypeFromUrl` in `utils/storage-driver.ts`), and + * `driver-vocabulary-parity.test.ts` holds the hosts equal. + */ +function schemeOf(dbUrl: string): string | undefined { + return urlSchemeOf(dbUrl) ?? /^[a-z][a-z0-9+.-]+:/i.exec(dbUrl)?.[0]; +} + function detectDriverFromUrl(dbUrl: string): ResolvedDriverKind { // The in-memory (mingo) engine's two schemes — character-for-character the // CLI's `inferDriverTypeFromUrl` regex, for the reason the mysql and turso @@ -473,7 +492,7 @@ function detectDriverFromUrl(dbUrl: string): ResolvedDriverKind { // `C:/data/app.db`), not a scheme, and stays a path. The CLI host draws the // same line (`inferDriverTypeFromUrl` in `utils/storage-driver.ts`), and // `driver-vocabulary-parity.test.ts` holds the two equal. - const scheme = urlSchemeOf(dbUrl) ?? /^[a-z][a-z0-9+.-]+:/i.exec(dbUrl)?.[0]; + const scheme = schemeOf(dbUrl); // Bare path without a scheme — treat as a sqlite file path. if (!scheme) return wasmFile ? 'sqlite-wasm' : 'sqlite'; // Names the scheme, never the URL ({@link urlSchemeOf}). An opaque scheme @@ -603,8 +622,32 @@ function refuseRetiredMemoryEngine( * URL→filename for the two sqlite kinds: the ONE rule both hosts turn a SQLite * database URL into a file with. Throws on a URL that isn't a path. * - * Takes the URL as {@link resolveStandaloneDatabase} has it, i.e. after - * `normalizeDatabaseUrl` (`sqlite:` / `sqlite://` are already `file:`). + * Takes the URL as {@link resolveStandaloneDatabase} has it. The explicit and + * `OS_DATABASE_URL` rungs have applied `normalizeDatabaseUrl` (`sqlite:` / + * `sqlite://` are `file:` there); a declared datasource's `url` and + * `TURSO_DATABASE_URL` arrive as written, so on those two rungs a `sqlite:` + * alias is a scheme here and is refused. + * + * An explicit `sqlite` / `sqlite-wasm` selection skips + * {@link detectDriverFromUrl}, so this rule is the only check its URL meets, and + * on BOTH kinds it refuses every value that is not a file path: + * - a `://` left after the strip below: a server URL + * (`postgres://user:password@…`) or a scheme nothing recognises + * (`redis://h/x.db`); + * - an OPAQUE scheme ({@link schemeOf}: two or more characters and a `:` with + * no `//`, as in `jdbc:postgresql://…` or `redis:h`), judged on the value as + * given. A value whose `file:` (or, on the WASM kind, `wasm-sqlite:`) prefix + * the strip removed is a file, colons and all: `file:backup:2026.db` names + * `backup:2026.db`, while `backup:2026.db` is the scheme `backup:`. + * Both used to pass through as a file name, and the boot made directories out of + * the URL: `./jdbc:postgresql:/127.0.0.1:5999/`, and on the WASM kind, which + * refused nothing, `./postgres:/USER:SECRET@127.0.0.1:5999/`, the credentials + * in a directory name. The refusal comes before any directory is made, and it + * names the selected kind and the scheme, never the URL. + * + * An empty name is `:memory:` on the WASM kind; on the `sqlite` kind a `file:` + * that names no file is refused. + * * `file:` and `file://` are stripped case-insensitively on BOTH kinds, as * {@link detectDriverFromUrl} classifies them (`/^file:/i`): a prefix the * classifier accepts and this strip kept became part of the filename, so @@ -618,21 +661,21 @@ function refuseRetiredMemoryEngine( * `//NAME` at the filesystem root there while this host opened `NAME`. */ export function sqliteFilenameFromUrl(dbUrl: string, kind: 'sqlite' | 'sqlite-wasm'): string { - if (kind === 'sqlite-wasm') { - return dbUrl - .replace(/^wasm-sqlite:(\/\/)?/i, '') - .replace(/^file:(\/\/)?/i, '') || ':memory:'; - } - const filename = dbUrl.replace(/^file:(\/\/)?/i, ''); - const scheme = urlSchemeOf(filename); + const filename = kind === 'sqlite-wasm' + ? dbUrl.replace(/^wasm-sqlite:(\/\/)?/i, '').replace(/^file:(\/\/)?/i, '') + : dbUrl.replace(/^file:(\/\/)?/i, ''); + if (!filename && kind === 'sqlite-wasm') return ':memory:'; + // A prefix the strip removed made the value a file, so only a value left as + // given is asked for an opaque scheme (`file:` is one itself). + const scheme = filename === dbUrl ? schemeOf(dbUrl) : urlSchemeOf(filename); if (!filename || scheme) { - // Names the scheme, never the URL ({@link urlSchemeOf}). This is the - // refusal an explicit `sqlite` selection meets over a server URL + // Names the scheme, never the URL ({@link schemeOf}). This is the + // refusal an explicit selection meets over a server URL // (`OS_DATABASE_DRIVER=sqlite` beside a `postgres://user:password@…`), // so the URL it would otherwise echo is exactly the credentialed kind. const why = scheme ? `its scheme is "${scheme}"` : 'it names no file after "file:"'; throw new Error( - `[StandaloneStack] sqlite driver was selected but the URL does not look like a file path (${why}). ` + + `[StandaloneStack] ${kind} driver was selected but the URL does not look like a file path (${why}). ` + `Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.` ); } From c80f0ceaaa8d9f7d1fb3982a386d97fd48dc23eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:11:40 +0000 Subject: [PATCH 3/3] 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 --- .../23863-explicit-sqlite-non-file-refusal.md | 17 +++ .../utils/driver-vocabulary-parity.test.ts | 105 +++++++++++++++++- 2 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 .changeset/23863-explicit-sqlite-non-file-refusal.md diff --git a/.changeset/23863-explicit-sqlite-non-file-refusal.md b/.changeset/23863-explicit-sqlite-non-file-refusal.md new file mode 100644 index 00000000000..66721b526d0 --- /dev/null +++ b/.changeset/23863-explicit-sqlite-non-file-refusal.md @@ -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:`. diff --git a/packages/cli/src/utils/driver-vocabulary-parity.test.ts b/packages/cli/src/utils/driver-vocabulary-parity.test.ts index 081f686ec4a..119b70e3d40 100644 --- a/packages/cli/src/utils/driver-vocabulary-parity.test.ts +++ b/packages/cli/src/utils/driver-vocabulary-parity.test.ts @@ -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:"'], @@ -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 `://` 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