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 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-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:'); + }); +}); 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.` ); }