Skip to content

Commit c5cf546

Browse files
fix(runtime): the standalone host refuses an opaque-scheme OS_DATABASE_URL, and a .wasm.db ending no longer rescues an unrecognised scheme (#23861)
Fixes #23765 Clause-②: no ## What The runtime's standalone host (`detectDriverFromUrl` in `packages/runtime/src/standalone-stack.ts`) now refuses two URL shapes it used to open as a SQLite file at a path made of the URL. Both broke the module header's own rule (`standalone-stack.ts:34`): "Unknown URL schemes throw — we never silently fall back to sqlite". - **An opaque scheme**: two or more characters and a `:` with no `//` (`jdbc:postgresql://…`, `redis:h`). The scheme-less arm read "no `SCHEME://`" as "a file path". - **A scheme rescued by a `.wasm.db` ending** (`redis://h/x.wasm.db`). The `.wasm.db` test ran ahead of every scheme check. It now selects the WASM engine only for a `file:` URL or a value with no scheme. Both meet the existing unsupported-scheme refusal, unchanged: plain `Error`, same words, naming the scheme only. A single letter and a `:` stays a Windows drive path. `file:`, `sqlite:`, plain paths, `wasm-sqlite://` and `:memory:` resolve as before. Files: - `packages/runtime/src/standalone-stack.ts`: `detectDriverFromUrl` only (the `.wasm.db` test moves under the `file:` and no-scheme returns; the scheme computation gains the opaque-scheme fallback). - `packages/runtime/src/standalone-stack-opaque-scheme-refusal.test.ts` (new): pins beside the existing standalone database-URL tests. - `packages/cli/src/utils/driver-vocabulary-parity.test.ts`: the four rows that named this card move into the "both hosts REFUSE" block. That block now also asserts the runtime names the same scheme as the CLI. Two drive-letter rows join the "both hosts ACCEPT" block as the control. The CLI-only block is deleted. - `packages/cli/src/utils/storage-driver.ts`: comments only (commit `5866f58f4`). The `inferDriverTypeFromUrl` docblock and its opaque-scheme arm comment described this card's gap as open; they now say both hosts refuse an opaque scheme, naming the scheme, and `driver-vocabulary-parity.test.ts` holds the two equal. No other line of the file changes. - `.changeset/23765-opaque-scheme-refusal.md`: `@objectstack/runtime` patch. ## The PM's mechanism assumptions, measured 1. **One rule, two hosts.** The CLI's rule after PR #23761 is written inline in `inferDriverTypeFromUrl` (`packages/cli/src/utils/storage-driver.ts`): the `SCHEME://` regex, falling back to the opaque regex `/^[a-z][a-z0-9+.-]+:/i`. No package both hosts depend on has an opaque-scheme classifier. `git grep` of that regex over `packages/` (tests excluded) has one hit, the CLI's own. `@objectstack/spec` and `@objectstack/types` have no such classifier. The runtime cannot import from the CLI, which depends on it. A shared copy would need a new runtime export plus an edit to `storage-driver.ts`, which the claim holds off. So this PR writes the runtime's arm with the same regex, character for character, and the parity rows hold the two hosts equal. 2. **Who reads the answer.** `detectDriverFromUrl` is module-private, with one call site: `resolveStandaloneDatabase`. That function feeds `createStandaloneStack` and, in the CLI, `migrate-occupancy-gate.ts`, `schema-migrate.ts` and `telemetry-datasource.ts`. In-repo producers, at `3fed1eb00`: - `git grep -n "jdbc:"`, excluding CHANGELOGs: 9 hits. All are CLI refusal pins, CLI comments or the sibling changeset. - Scheme-prefixed `*.wasm.db` literals: 15 hits. 2 `file:` and 6 `sqlite:` (positive control), and 7 others, all CLI refusal pins, comments or the changeset. - URL literals assigned to a URL key across `packages examples apps content scripts`: 2,703. Of these, 367 start with `file:` or `sqlite:` (positive control). The opaque-scheme values that remain do not reach this host: driver-turso constructor tests (`libsql:db.example.turso.io`), a banner display string, `data:` / `javascript:` / `pkg:` literals, and grep noise. No producer, example or fixture that this refusal now breaks. 3. **The refusal's shape.** This PR reuses the existing throw and does not edit it. The pins compare against a reference refusal (`wat://nope`) rather than pinning prose: same constructor (`Error`), same own property names, same `code` (none; this refusal has no ADR-0112 code), and the same message once the scheme is masked. The first sentence names the scheme. A placeholder credential (`PLACEHOLDER-USER` / `PLACEHOLDER-SECRET` / `PLACEHOLDER-TOKEN`) appears in no own property of the error. 4. **Reproduction at the public door.** `os serve --dev` over a plain config, each run in a fresh scratch directory outside the repo: | `OS_DATABASE_URL` | base `3fed1eb00` | this branch | |---|---|---| | `jdbc:postgresql://127.0.0.1:5999/db` | `✓ Server is ready`, `Driver: SqlDriver(better-sqlite3) → jdbc:postgresql://127.0.0.1:5999/db`; created `./jdbc:postgresql:/127.0.0.1:5999/db` (+ telemetry siblings) | exit 1 after `Loading objectstack.config.ts...`: `✗ [StandaloneStack] Unsupported database URL scheme "jdbc:". Supported schemes: …`; directory holds only the config | | `redis://h/x.wasm.db` | `✓ Server is ready`; created `./redis:/h/x.wasm.db` | exit 1, `… scheme "redis://"`; nothing created | | `redis:h` | `✓ Server is ready`; created `./redis:h` (+ telemetry siblings) | exit 1, `… scheme "redis:"`; nothing created | CONTROL on this branch: `file:./x.db`, `sqlite:./x.db` and `./x.db` boot ready on `./x.db`, `./x.wasm.db` boots ready on `./x.wasm.db`, and `:memory:` boots ready on `:memory:`. `C:\data\x.db` and `C:/data/x.db` are pinned as string fixtures (sqlite, same file). Round 2 (`5866f58f4`, comment-only, after the seat's REWORK `6212986188`): the parity and `storage-driver` unit files 164 passed, cli `typecheck` exit 0, gate union 64 run / 1 NOT-MEASURED (`check:type-check-debt`, CI's), CI all success. The sections below cite round 1 at `c01539665` for the runtime readings, whose files this round does not touch. ## Tests (final head `c01539665`) - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2`: `Test Files 400 passed (400)`, `Tests 6026 passed | 19 skipped (6045)`, `VERDICT command-exit 0`. - `pnpm --filter @objectstack/runtime typecheck`: exit 0 (`check:test-typecheck: OK`; the new test file adds no debt). - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 src/utils/driver-vocabulary-parity.test.ts src/utils/storage-driver.test.ts`: `2 passed`, `164 passed`, exit 0. The parity file resolves `@objectstack/runtime` through `dist/`, rebuilt from this head (the opaque regex is present in `dist/index.js` and `dist/index.cjs`). - `pnpm --filter @objectstack/cli typecheck`: exit 0. - `pnpm exec eslint --no-inline-config` on the three changed source files: exit 0, no output. - The CLI's `integration` tier is left to CI; this diff touches no integration-layer file and no spawn entry. The runtime's `repo` project (`test:repo`) is left to CI (`Test Core`); this diff touches none of its three files. **Ablation.** The pin file imports `./standalone-stack.js` from source, so no rebuild was needed. Mutations went through `node scripts/ablation-replace.mjs` (wrap mode, restore proven by blob hash equal to HEAD and an empty `git diff HEAD`): - Opaque arm: `urlSchemeOf(dbUrl) ?? /^[a-z][a-z0-9+.-]+:/i.exec(dbUrl)?.[0]` reverted to `urlSchemeOf(dbUrl)`. Blob `7fe20179cd6d` to `2d4dd5eb306e`. Result: `Tests 21 failed | 26 passed (47)`: all 18 opaque rows across the three doors, plus the 3 `jdbc:x/app.wasm.db` rows. CONTROL stayed green. Restored. - `.wasm.db` position (the early return put back ahead of `file:`): - The first attempt was refused by the tool itself (the replacement contained the anchor, so the count went 1 to 1). No test ran. - The second attempt landed and read `9 failed | 38 passed`. The credentialed `redis://…` row stayed green: its URL ended in a `?authToken=` query, so it never exercised the rescue. Commit `60575c3df` moves the token into the path. - The rerun read `12 failed | 35 passed (47)`: every rescue row across the three doors. Restored. ## Gates (final head `c01539665`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (merge base `1eac7ad75`, 4 paths) derived 65 commands. 64 ran with exit 0. - `pnpm check:type-check-debt` was not run on this head: NOT MEASURED. On the first pass my 590 s foreground timeout killed it (exit 124). That kill interrupted its closure refresh mid-build and left `@objectstack/organizations` with a `dist/` missing `index.d.ts`. `check:dts-closure` and `check:dual-build-cjs-loads` read that state red on the second pass. I rebuilt that package, and both reran exit 0. CI runs `check:type-check-debt` in `Type Check · debt ledger`. - `--ran` reconciliation: `65 derived famil(ies) accounted for — 64 run, 1 NOT-MEASURED`, exit 0. - The repo-wide lint run belongs to this PR's `Lint & Repo Gates` check-run. ## Acceptance notes - **The runtime's refusal lists fewer schemes than the CLI's.** Its "Supported schemes" list names neither `wasm-sqlite://` nor "a path with no scheme"; the CLI's list names both. That is polish, so not filed. - **A declared datasource's URL is not normalized.** On the config-datasource tier the URL is not passed through `normalizeDatabaseUrl`, so a non-sqlite declared datasource whose `url` is `sqlite:./x.db` is now refused as `"sqlite:"`. By reading, not measured: before this PR it opened a file literally named `sqlite:./x.db`, and `sqlite://` was already refused on that tier. No in-repo producer writes it. - **`main` moved after my merge.** It moved 8 commits past `1eac7ad75`, among them `f7a5ebed7` and `ae4dfafa1` (PR #23843), which edit other functions of `standalone-stack.ts`. `git merge-tree` reports a clean merge with disjoint hunks. The incoming test (`standalone-stack-uncreatable-db-directory.test.ts`) uses only `file:` and `wasm-sqlite://` URLs, which this change leaves alone. PR CI runs on the merge ref. - **Out-of-scope finding, reported to the seat and not fixed here.** An explicit `OS_DATABASE_DRIVER=sqlite` skips `detectDriverFromUrl` and reaches `sqliteFilenameFromUrl`, which recognises only `SCHEME://`. Measured at the door on this branch: `OS_DATABASE_DRIVER=sqlite OS_DATABASE_URL=jdbc:postgresql://127.0.0.1:5999/db os serve --dev` still boots ready on `./jdbc:postgresql:/127.0.0.1:5999/db`, while `redis://h/x.db` is refused. That function is held off here (#23794 and #23796 are the open cards on it), so this shape goes to the seat for that family. The body edits of round 2 (the `storage-driver.ts` file bullet, the round-2 line, and the removed stale Acceptance note) were written by the `domain:cli` seat 2 PM (session `session_018t6a5rvqPRvEDMCVVW9kYT`) from the dev's round-2 report. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 105902f commit c5cf546

5 files changed

Lines changed: 269 additions & 36 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): the standalone host refuses an `OS_DATABASE_URL` with an opaque scheme (`jdbc:postgresql://…`, `redis:h`), and a `.wasm.db` ending no longer turns an unrecognised scheme into a SQLite file
6+
7+
Clause-②: no
8+
9+
This is the runtime's standalone host: `os dev`, `os start` and `os serve` over a plain config or an artifact, every `os migrate` subcommand, and every embedder of `createStandaloneStack`.
10+
11+
- **Before, an opaque scheme (two or more characters and a `:` with no `//`).** `OS_DATABASE_URL=jdbc:postgresql://127.0.0.1:5999/db os serve --dev` printed `✓ Server is ready` and opened a SQLite file at a path made of the URL, creating the directories `./jdbc:postgresql:/127.0.0.1:5999/`. An operator who believed they were on Postgres wrote to that local file. This broke the host's own rule that an unknown URL scheme is refused and never falls back to SQLite.
12+
- **Before, a `.wasm.db` ending.** It was tested ahead of every scheme check, so `OS_DATABASE_URL=redis://h/x.wasm.db` booted on a WASM SQLite file written under `./redis:/h/`.
13+
- **After.** Both shapes get the refusal an unrecognised `<scheme>://` already got, unchanged: `[StandaloneStack] Unsupported database URL scheme "jdbc:". Supported schemes: …`. The boot stops before any plugin starts and creates nothing on disk. The message names only the scheme, never the URL, so credentials in the URL are not printed. A `*.wasm.db` name still selects the WASM engine for a `file:` URL or a path with no scheme. The CLI's legacy `os serve` path already refuses these values, so the two hosts now give the same answer.
14+
- **A one-letter drive is a path, not a scheme** (`C:\data\app.db`, `C:/data/app.db`). A file name whose first segment contains a colon now reads as a scheme, as it does on the CLI path: `backup:2026.db` was a SQLite file and is now refused. Write `./backup:2026.db` or `file:backup:2026.db` for that file.
15+
16+
`postgres://`, `pg://`, `mysql://`, `mongodb://`, `libsql://`, `wasm-sqlite://`, `file:`, `sqlite:`, `:memory:` and paths with no scheme resolve as before. An explicit `OS_DATABASE_DRIVER` still wins over the URL, which is then not classified.

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

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,9 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro
479479
['data/app.sqlite3', 'sqlite'],
480480
['./data/app', 'sqlite'],
481481
['/var/lib/objectstack/app', 'sqlite'],
482+
// A single letter and a `:` is a Windows drive, not an opaque scheme.
483+
['C:\\data\\app.db', 'sqlite'],
484+
['C:/data/app.db', 'sqlite'],
482485
])('both hosts ACCEPT %s as %s, and the CLI builds it from that URL in dev and prod', (url, kind) => {
483486
expect(runtimeUrlVerdict(url), `the runtime host refused ${url}`).toEqual({ accepted: true, driverId: kind });
484487
for (const isDev of [false, true]) {
@@ -487,41 +490,26 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro
487490
}
488491
});
489492

490-
// A `<scheme>://` neither host recognises: refused by both, the CLI's refusal
491-
// being the typed one serve.ts makes fatal, in dev as in production.
493+
// A scheme neither host recognises: refused by both, naming the same scheme,
494+
// the CLI's refusal being the typed one serve.ts makes fatal, in dev as in
495+
// production. That is an unrecognised `<scheme>://`; an OPAQUE scheme — two
496+
// or more characters and a `:`, no `//` (`jdbc:postgresql://…`, `redis:h`),
497+
// which the runtime host used to open as a SQLite file at a path made of the
498+
// URL; and any scheme a `*.wasm.db` file name used to rescue there (#23765).
492499
it.each([
493500
['redis://127.0.0.1:6399/0', 'redis://'],
494501
['rediss://cache.example.com:6380', 'rediss://'],
495502
['https://db.example.com/app', 'https://'],
496503
['mssql://h/db', 'mssql://'],
497504
['redis://h/app.db', 'redis://'],
498-
])('both hosts REFUSE %s, in dev AND in prod', (url, scheme) => {
499-
const runtime = runtimeUrlVerdict(url);
500-
expect(runtime.accepted, `the runtime host accepted ${url}`).toBe(false);
501-
for (const isDev of [false, true]) {
502-
const cli = cliUrlVerdict(url, isDev);
503-
expect(cli.accepted, `the CLI host (isDev=${isDev}) accepted ${url}`).toBe(false);
504-
const error = (cli as { error: unknown }).error;
505-
expect(error, `the CLI host (isDev=${isDev}) did not refuse ${url} by type`).toBeInstanceOf(UnsupportedDriverError);
506-
expect((error as UnsupportedDriverError).recognized).toBe(false);
507-
expect((error as Error).message).toContain(`"${scheme}"`);
508-
expect((error as Error).message).toContain('OS_DATABASE_URL');
509-
}
510-
});
511-
512-
// An OPAQUE scheme — two or more characters and a `:`, no `//`
513-
// (`jdbc:postgresql://…`, `redis:h`) — and any scheme a `*.wasm.db` file name
514-
// used to rescue. The CLI host refuses them, typed, in dev and prod. The
515-
// runtime host's answer is deliberately NOT pinned here: today it opens these
516-
// as a SQLite file at a path made of the URL, an implementation gap against
517-
// its own "unknown schemes throw" rule that is #23765's to close, so asserting
518-
// either agreement or its current answer would pin that gap.
519-
it.each([
520505
['jdbc:postgresql://localhost:5432/db', 'jdbc:'],
521506
['redis:cache', 'redis:'],
522507
['redis://h/app.wasm.db', 'redis://'],
523508
['jdbc:x/app.wasm.db', 'jdbc:'],
524-
])('the CLI host REFUSES %s (scheme %s), in dev AND in prod', (url, scheme) => {
509+
])('both hosts REFUSE %s, in dev AND in prod', (url, scheme) => {
510+
const runtime = runtimeUrlVerdict(url);
511+
expect(runtime.accepted, `the runtime host accepted ${url}`).toBe(false);
512+
expect((runtime as { message: string }).message, `the runtime host's refusal of ${url}`).toContain(`"${scheme}"`);
525513
for (const isDev of [false, true]) {
526514
const cli = cliUrlVerdict(url, isDev);
527515
expect(cli.accepted, `the CLI host (isDev=${isDev}) accepted ${url}`).toBe(false);

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -296,8 +296,9 @@ function unsupportedUrlSchemeMessage(scheme: string): string {
296296
* per URL: `pg://` is postgres, and a value with no scheme is a SQLite file
297297
* path, whatever its extension. The runtime refuses every other
298298
* `<scheme>://`, so refusing it here takes away nothing that host means to
299-
* accept. The one difference is an OPAQUE scheme (`jdbc:…`, `redis:h`), which
300-
* this host refuses and the runtime host still opens as a file (#23765).
299+
* accept. An OPAQUE scheme (`jdbc:…`, `redis:h`) gets the same answer: both
300+
* hosts refuse it, naming the scheme, and `driver-vocabulary-parity.test.ts`
301+
* holds the two equal.
301302
*/
302303
export function inferDriverTypeFromUrl(url: string | undefined): string {
303304
if (!url) return '';
@@ -354,10 +355,10 @@ export function inferDriverTypeFromUrl(url: string | undefined): string {
354355
// than letting a name that ends `.db` reach SQLite, is what keeps
355356
// `redis://h/x.db` refused, as the runtime host refuses it;
356357
// - an OPAQUE scheme, two or more characters and a `:` with no `//`
357-
// (`jdbc:postgresql://…`, `redis:h`). The runtime host's scheme-less arm
358-
// opens these as a SQLite file at a path made of the URL. That is an
359-
// implementation gap on that host (#23765), against its own rule that an
360-
// unknown scheme never falls back to SQLite, so it is not copied here.
358+
// (`jdbc:postgresql://…`, `redis:h`), rather than opening a SQLite file at
359+
// a path made of the URL. The runtime host's `detectDriverFromUrl` refuses
360+
// it by the same rule, and `driver-vocabulary-parity.test.ts` holds the
361+
// two equal.
361362
// A single letter and a `:` is a drive (`C:\data\app`, `C:/data/app`), not
362363
// a scheme, and stays a path.
363364
const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(u)?.[0] ?? /^[a-z][a-z0-9+.-]+:/i.exec(u)?.[0];
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The standalone host refuses an OPAQUE scheme, and a `*.wasm.db` file name no
4+
// longer rescues a scheme nothing recognises (#23765).
5+
//
6+
// The module header's rule is "Unknown URL schemes throw — we never silently
7+
// fall back to sqlite". Two arms of `detectDriverFromUrl` broke it:
8+
//
9+
// - the scheme-less arm read "no `<scheme>://`" as "a file path", so an
10+
// opaque scheme — two or more characters and a `:` with no `//`
11+
// (`jdbc:postgresql://…`, `redis:h`) — opened a SQLite file at a path made
12+
// of the URL: `OS_DATABASE_URL=jdbc:postgresql://127.0.0.1:5999/db` booted
13+
// "ready" on `./jdbc:postgresql:/127.0.0.1:5999/db`;
14+
// - the `.wasm.db` test ran ahead of every scheme check, so
15+
// `redis://h/x.wasm.db` opened a WASM SQLite file under `./redis:/h/`.
16+
//
17+
// The cases hold three things:
18+
//
19+
// 1. both shapes meet the refusal an unrecognised `<scheme>://` already met,
20+
// unchanged — same error class, same own properties, same words with only
21+
// the scheme differing — through every door the URL arrives by, and its
22+
// first sentence names the scheme;
23+
// 2. the refusal names the scheme only: no own property of the thrown error
24+
// carries the placeholder username, password or token in the URL;
25+
// 3. CONTROL: a single letter and a `:` is a Windows drive and stays a path,
26+
// and every file spelling resolves to the kind AND the file it did.
27+
28+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
29+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
30+
import { tmpdir } from 'node:os';
31+
import { join } from 'node:path';
32+
import { resolveStandaloneDatabase } from './standalone-stack.js';
33+
34+
// Placeholders only: nothing here is, or looks like, a real secret.
35+
const USER = 'PLACEHOLDER-USER';
36+
const SECRET = 'PLACEHOLDER-SECRET';
37+
const TOKEN = 'PLACEHOLDER-TOKEN';
38+
/** A userinfo with a password, a host, a path and a token query. */
39+
const CREDENTIALED_TAIL = `${USER}:${SECRET}@127.0.0.1:6399/0?authToken=${TOKEN}`;
40+
41+
/** Env keys these tests write; restored after every case. */
42+
const ENV_KEYS = [
43+
'OS_DATABASE_URL',
44+
'DATABASE_URL',
45+
'TURSO_DATABASE_URL',
46+
'OS_DATABASE_DRIVER',
47+
'OS_HOME',
48+
'OS_ARTIFACT_PATH',
49+
] as const;
50+
const ORIGINAL_ENV: Record<string, string | undefined> = Object.fromEntries(
51+
ENV_KEYS.map((k) => [k, process.env[k]]),
52+
);
53+
54+
let root: string;
55+
beforeEach(() => {
56+
for (const key of ENV_KEYS) delete process.env[key];
57+
root = mkdtempSync(join(tmpdir(), 'os-opaque-scheme-refusal-'));
58+
// Every rung that could answer before the one under test points at this
59+
// empty directory, so no developer state leaks into a case.
60+
process.env.OS_HOME = root;
61+
});
62+
afterEach(() => {
63+
try { rmSync(root, { recursive: true, force: true }); } catch { /* noop */ }
64+
for (const key of ENV_KEYS) {
65+
const original = ORIGINAL_ENV[key];
66+
if (original === undefined) delete process.env[key];
67+
else process.env[key] = original;
68+
}
69+
});
70+
71+
/** The error `run` throws; fails the case when it throws nothing. */
72+
function refusalOf(run: () => unknown): Error {
73+
try {
74+
run();
75+
} catch (err) {
76+
return err as Error;
77+
}
78+
throw new Error('expected a refusal, but the URL resolved');
79+
}
80+
81+
/** A compiled artifact whose declared default datasource carries `url`. */
82+
function artifactDeclaring(url: string): string {
83+
const artifactPath = join(root, 'objectstack.json');
84+
writeFileSync(artifactPath, JSON.stringify({
85+
datasources: [{ name: 'primary', driver: 'postgres', config: { url } }],
86+
datasourceMapping: [{ default: true, datasource: 'primary' }],
87+
}));
88+
return artifactPath;
89+
}
90+
91+
/** The three doors a database URL reaches this host by. */
92+
const DOORS: ReadonlyArray<readonly [door: string, run: (url: string) => unknown]> = [
93+
['the databaseUrl option (--database)', (url) => resolveStandaloneDatabase({ databaseUrl: url })],
94+
['OS_DATABASE_URL', (url) => {
95+
process.env.OS_DATABASE_URL = url;
96+
return resolveStandaloneDatabase({ projectRoot: root });
97+
}],
98+
['a declared default datasource', (url) =>
99+
resolveStandaloneDatabase({ projectRoot: root, artifactPath: artifactDeclaring(url) })],
100+
];
101+
102+
/**
103+
* The refusal an unrecognised `<scheme>://` has always met, as the reference
104+
* the widened refusal is compared against — so the comparison pins sameness,
105+
* never the prose.
106+
*/
107+
const REFERENCE_SCHEME = 'wat://';
108+
function referenceRefusal(): Error {
109+
return refusalOf(() => resolveStandaloneDatabase({ databaseUrl: `${REFERENCE_SCHEME}nope` }));
110+
}
111+
112+
/** The unsupported-scheme refusal, unchanged, naming `scheme` and nothing of the URL. */
113+
function expectUnsupportedSchemeRefusal(err: Error, scheme: string): void {
114+
const reference = referenceRefusal();
115+
// 1. The same refusal: class, own properties (a `code`, were one ever
116+
// added, included) and words, with only the scheme differing.
117+
expect(err.constructor).toBe(Error);
118+
expect(err.constructor).toBe(reference.constructor);
119+
expect(Object.getOwnPropertyNames(err).sort()).toEqual(Object.getOwnPropertyNames(reference).sort());
120+
expect((err as { code?: unknown }).code).toBe((reference as { code?: unknown }).code);
121+
expect(err.cause).toBeUndefined();
122+
expect(err.message.startsWith(`[StandaloneStack] Unsupported database URL scheme "${scheme}". `),
123+
`the first sentence does not name "${scheme}": ${err.message}`).toBe(true);
124+
expect(err.message.replace(`"${scheme}"`, '"SCHEME"'))
125+
.toBe(reference.message.replace(`"${REFERENCE_SCHEME}"`, '"SCHEME"'));
126+
// 2. The scheme only. Positive control first: the serialisation really
127+
// holds the message, so the negative reads below read something.
128+
const everything = JSON.stringify(err, Object.getOwnPropertyNames(err));
129+
expect(everything).toContain(JSON.stringify(err.message));
130+
for (const placeholder of [USER, SECRET, TOKEN]) {
131+
expect(everything, `the refusal carries ${placeholder}`).not.toContain(placeholder);
132+
}
133+
}
134+
135+
describe('an OPAQUE scheme is refused, naming the scheme, never the URL', () => {
136+
// Two or more characters and a `:` with no `//`. The scheme ends at the
137+
// FIRST `:`, so it cannot hold a userinfo, a host or a path.
138+
const OPAQUE: ReadonlyArray<readonly [url: string, scheme: string]> = [
139+
// The card's reproduction, and the same shape carrying credentials.
140+
['jdbc:postgresql://127.0.0.1:5999/db', 'jdbc:'],
141+
[`jdbc:postgresql://${CREDENTIALED_TAIL}`, 'jdbc:'],
142+
['redis:h', 'redis:'],
143+
[`redis:${CREDENTIALED_TAIL}`, 'redis:'],
144+
// A scheme this host recognises is opaque too when its `//` is missing:
145+
// it is not a file path either.
146+
['libsql:db.example.turso.io', 'libsql:'],
147+
// A file name whose FIRST segment holds a colon reads as a scheme, as on
148+
// the CLI host; `./backup:2026.db` and `file:backup:2026.db` name that
149+
// file (CONTROL below).
150+
['backup:2026.db', 'backup:'],
151+
];
152+
153+
for (const [door, run] of DOORS) {
154+
it.each(OPAQUE)(`%s via ${door} → refused as %s`, (url, scheme) => {
155+
expectUnsupportedSchemeRefusal(refusalOf(() => run(url)), scheme);
156+
});
157+
}
158+
});
159+
160+
describe('a `*.wasm.db` file name no longer rescues a scheme nothing recognises', () => {
161+
// It selects the WASM engine for a FILE only: a `file:` URL or a value with
162+
// no scheme (the CONTROL below).
163+
const RESCUED: ReadonlyArray<readonly [url: string, scheme: string]> = [
164+
['redis://h/x.wasm.db', 'redis://'],
165+
// The URL must END in `.wasm.db` for the old test to have rescued it, so
166+
// the token rides in the path here, not in a trailing query.
167+
[`redis://${USER}:${SECRET}@127.0.0.1:6399/${TOKEN}/x.wasm.db`, 'redis://'],
168+
['https://example.com/app.wasm.db', 'https://'],
169+
['jdbc:x/app.wasm.db', 'jdbc:'],
170+
];
171+
172+
for (const [door, run] of DOORS) {
173+
it.each(RESCUED)(`%s via ${door} → refused as %s`, (url, scheme) => {
174+
expectUnsupportedSchemeRefusal(refusalOf(() => run(url)), scheme);
175+
});
176+
}
177+
});
178+
179+
describe('CONTROL: a drive letter, a file: URL and a plain path open the file they did', () => {
180+
it.each([
181+
// A single letter and a `:` is a Windows drive, not a scheme.
182+
['C:\\data\\x.db', 'sqlite', 'C:\\data\\x.db'],
183+
['C:/data/x.db', 'sqlite', 'C:/data/x.db'],
184+
['c:\\data\\x.wasm.db', 'sqlite-wasm', 'c:\\data\\x.wasm.db'],
185+
['file:./x.db', 'sqlite', './x.db'],
186+
['sqlite:./x.db', 'sqlite', './x.db'],
187+
['./x.db', 'sqlite', './x.db'],
188+
['./x.wasm.db', 'sqlite-wasm', './x.wasm.db'],
189+
['file:./x.wasm.db', 'sqlite-wasm', './x.wasm.db'],
190+
['wasm-sqlite:///tmp/x.db', 'sqlite-wasm', '/tmp/x.db'],
191+
['data/app', 'sqlite', 'data/app'],
192+
// A `:` past the first path segment is not a scheme either, and `file:`
193+
// names a file whose own name holds one.
194+
['./data/app:v2.db', 'sqlite', './data/app:v2.db'],
195+
['./backup:2026.db', 'sqlite', './backup:2026.db'],
196+
['file:backup:2026.db', 'sqlite', 'backup:2026.db'],
197+
[':memory:', 'sqlite', null],
198+
])('%s → %s, file %s', (url, driver, file) => {
199+
const resolved = resolveStandaloneDatabase({ databaseUrl: url });
200+
expect(resolved.driver).toBe(driver);
201+
expect(resolved.sqliteFile).toBe(file);
202+
});
203+
204+
it.each(['postgres', 'mysql', 'mongodb'])(
205+
'a credential-bearing %s:// URL still resolves, credential intact',
206+
(scheme) => {
207+
const url = `${scheme}://${CREDENTIALED_TAIL}`;
208+
expect(resolveStandaloneDatabase({ databaseUrl: url }).url).toBe(url);
209+
},
210+
);
211+
});

‎packages/runtime/src/standalone-stack.ts‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -456,12 +456,29 @@ function detectDriverFromUrl(dbUrl: string): ResolvedDriverKind {
456456
if (/^libsql:\/\//i.test(dbUrl)) return 'turso';
457457
if (/^https?:\/\//i.test(dbUrl) && /\.turso\./i.test(dbUrl)) return 'turso';
458458
if (/^wasm-sqlite:\/\//i.test(dbUrl)) return 'sqlite-wasm';
459-
if (/\.wasm\.db$/i.test(dbUrl)) return 'sqlite-wasm';
460-
if (/^file:/i.test(dbUrl)) return 'sqlite';
459+
// A `*.wasm.db` file name selects the WASM engine only for a FILE: a
460+
// `file:` URL or a value with no scheme (both returns below). Tested ahead
461+
// of the scheme checks, it rescued a scheme nothing recognises
462+
// (`redis://h/x.wasm.db`) into a SQLite file at a path made of the URL.
463+
const wasmFile = /\.wasm\.db$/i.test(dbUrl);
464+
if (/^file:/i.test(dbUrl)) return wasmFile ? 'sqlite-wasm' : 'sqlite';
465+
// What is left with a scheme is refused, by the header's rule:
466+
// - an unrecognised `<scheme>://` (`redis://…`);
467+
// - an OPAQUE scheme, two or more characters and a `:` with no `//`
468+
// (`jdbc:postgresql://…`, `redis:h`). These used to read as a bare path
469+
// and open a SQLite file at a path made of the URL
470+
// (`./jdbc:postgresql:/127.0.0.1:5999/db`), the bogus directory the
471+
// rule exists to prevent.
472+
// A single letter and a `:` is a Windows drive (`C:\data\app.db`,
473+
// `C:/data/app.db`), not a scheme, and stays a path. The CLI host draws the
474+
// same line (`inferDriverTypeFromUrl` in `utils/storage-driver.ts`), and
475+
// `driver-vocabulary-parity.test.ts` holds the two equal.
476+
const scheme = urlSchemeOf(dbUrl) ?? /^[a-z][a-z0-9+.-]+:/i.exec(dbUrl)?.[0];
461477
// Bare path without a scheme — treat as a sqlite file path.
462-
const scheme = urlSchemeOf(dbUrl);
463-
if (!scheme) return 'sqlite';
464-
// Names the scheme, never the URL ({@link urlSchemeOf}).
478+
if (!scheme) return wasmFile ? 'sqlite-wasm' : 'sqlite';
479+
// Names the scheme, never the URL ({@link urlSchemeOf}). An opaque scheme
480+
// ends at its FIRST `:`, so it cannot hold a userinfo, a host or a path
481+
// either.
465482
throw new Error(
466483
`[StandaloneStack] Unsupported database URL scheme "${scheme}". ` +
467484
`Supported schemes: postgres://, pg://, mysql://, mysql2://, ` +

0 commit comments

Comments
 (0)