Skip to content

Commit cfb549d

Browse files
qq9340100claude
andauthored
feat(runtime): standalone stack dispatches mysql:// and validates OS_DATABASE_DRIVER (#6265) (#6344)
Two halves of one defect family in `standalone-stack.ts` — a driver selection this stack could not dispatch. (a) `mysql://` — the #5820 split with a different scheme. The CLI has classified `mysql[2]://` as `mysql` since forever (`inferDriverTypeFromUrl`), the shared factory has always been able to build it (`kind === 'mysql'` -> SqlDriver on `mysql2`), and the docs' URL-inference table lists it; only `detectDriverFromUrl` here had no arm, so one `OS_DATABASE_URL=mysql://…` booted under `os start` and died under `os migrate` with `Unsupported database URL scheme`. - detection arm uses character-for-character the CLI's regex; dispatch declares `{ driver: 'mysql', config: { url } }` for the shared factory, like postgres. - no dependency change: `mysql2` is already an optional peer of `@objectstack/driver-sql`, the same posture `pg` has. - `sqliteFile` stays null for a MySQL target (occupancy-gate semantics). (b) `OS_DATABASE_DRIVER` was a bare `as` cast while `cfg.databaseDriver` went through a zod enum. An unknown value matched no dispatch arm and landed in the chain's trailing `else`: SQLite, silently (#3276 class). - one declaration (`StandaloneDatabaseDriverSchema`) now feeds the config key, the env value and the `ResolvedDriverKind` union; the refusal enumerates `.options` instead of a fourth hand-written list. - unknown value -> loud throw naming the value and every legal driver; the env value is lower-cased first, matching the CLI's reader of the same variable. - the trailing `else` is a `never` guard now, so the next kind added without a dispatch arm is a compile error rather than a wrong database. Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW Co-authored-by: Claude <noreply@anthropic.com>
1 parent fb363b2 commit cfb549d

3 files changed

Lines changed: 402 additions & 7 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
**`createStandaloneStack` now dispatches `mysql://`, and an unknown `OS_DATABASE_DRIVER` value is refused instead of silently becoming SQLite** (#6265).
6+
7+
Two halves of one defect family: a driver selection this stack could not dispatch.
8+
9+
**`mysql://` — the #5820 split with a different scheme.** The CLI has classified `mysql://` / `mysql2://` as the `mysql` kind since forever (`inferDriverTypeFromUrl`), the shared datasource factory has always been able to build it (`SqlDriver` on the `mysql2` client), and `content/docs/data-modeling/drivers.mdx` lists it in the URL-inference table — only `detectDriverFromUrl()` in this package had no arm. So one `OS_DATABASE_URL=mysql://…` booted under `os start` and hard-failed under `os migrate` (which boots through this stack) with `Unsupported database URL scheme`.
10+
11+
- `mysql://…` and `mysql2://…` resolve to the `mysql` kind, matched by character-for-character the same regex the CLI uses — the two functions answer the same question about the same URL, so a divergence between them *is* the bug.
12+
- The stack declares `{ driver: 'mysql', config: { url } }` and the shared factory builds it, exactly like `postgres`. No optional package and no new dependency: `mysql2` is already an optional peer of `@objectstack/driver-sql`, the same posture `pg` has, so a missing client surfaces at connect like it always did.
13+
- `databaseDriver: 'mysql'` and `OS_DATABASE_DRIVER=mysql` are accepted; `sqliteFile` stays `null` for a MySQL target, so `os migrate`'s occupancy probe does not read a DSN as a file path.
14+
15+
**`OS_DATABASE_DRIVER` is validated now.** `databaseDriver` in config was parsed by a zod enum (loud rejection) while the env var was a bare `as` cast — an assertion that checks nothing at runtime. An unrecognised value matched no dispatch arm and landed in the chain's trailing `else`: SQLite, in silence. `OS_DATABASE_DRIVER=mysql` with no URL therefore created a local `standalone.db` while the operator believed they were talking to MySQL, and a typo (`mysq1`, `postgress`) did the same; with a URL set it surfaced as the doubly-misleading "sqlite driver was selected but the URL does not look like a file path" for someone who never selected sqlite. This is the #3276 class.
16+
17+
- Both paths now read **one** declaration (`StandaloneDatabaseDriverSchema`): the config key parses it, the env value parses it, the `ResolvedDriverKind` union is inferred from it, and the refusal enumerates its options rather than repeating them in a hand-written list.
18+
- An unknown value throws, naming the value and every legal driver: `sqlite, sqlite-wasm, memory, postgres, mysql, mongodb, turso`. The env value is lower-cased first, matching the CLI's reader of the same variable; the accepted vocabulary is the enum and nothing else.
19+
- The dispatch chain's trailing `else` is no longer "sqlite" — it is a `never` guard, so the *next* kind added to the enum without a dispatch arm is a compile error rather than a wrong database.
20+
21+
Unknown URL schemes still throw (the message now lists `mysql://`), and the "unknown driver" and "unknown URL scheme" refusals stay distinguishable.
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #6265 — two halves of one defect family in `standalone-stack.ts`, pinned
4+
// together because they close the same hole from opposite sides: a driver
5+
// selection this stack could not dispatch.
6+
//
7+
// (a) `mysql://` — the #5820 split with a different scheme. The CLI has
8+
// classified `mysql[2]://` as `mysql` since forever
9+
// (`utils/storage-driver.ts` `inferDriverTypeFromUrl`), the SHARED factory
10+
// has always been able to build it (`kind === 'mysql'` → SqlDriver on
11+
// `mysql2`), and only `detectDriverFromUrl()` here had no arm — so one
12+
// `OS_DATABASE_URL=mysql://…` booted under `os start` and died under
13+
// `os migrate` with `Unsupported database URL scheme`.
14+
//
15+
// (b) `OS_DATABASE_DRIVER` — `cfg.databaseDriver` was parsed by a zod enum
16+
// (loud rejection) while the env var was a bare `as` cast (no runtime check
17+
// at all). An unknown value matched no dispatch arm and landed in the
18+
// chain's trailing `else`: SQLite, in silence. `OS_DATABASE_DRIVER=mysql`
19+
// with no URL therefore created a local `standalone.db` while the operator
20+
// believed they were connected to MySQL — the #3276 class, and the value is
21+
// one `content/docs/deployment/environment-variables.mdx` advertises.
22+
//
23+
// Nothing here talks to a real MySQL server: `createStandaloneStack` builds a
24+
// DEFINITION and hands it to `DefaultDatasourcePlugin`, which connects later at
25+
// kernel init (ADR-0062 D1 / #3826). The definition plus the shared factory's
26+
// own `supports()` is therefore the whole of this package's contract.
27+
28+
import { describe, it, expect, afterEach } from 'vitest';
29+
import {
30+
resolveStandaloneDatabase,
31+
createStandaloneStack,
32+
StandaloneDatabaseDriverSchema,
33+
} from './standalone-stack.js';
34+
35+
/** Env keys these tests write; restored after every case. */
36+
const ENV_KEYS = [
37+
'OS_DATABASE_URL',
38+
'DATABASE_URL',
39+
'TURSO_DATABASE_URL',
40+
'OS_DATABASE_DRIVER',
41+
'OS_HOME',
42+
] as const;
43+
const ORIGINAL_ENV: Record<string, string | undefined> = Object.fromEntries(
44+
ENV_KEYS.map((k) => [k, process.env[k]]),
45+
);
46+
47+
afterEach(() => {
48+
for (const key of ENV_KEYS) {
49+
const original = ORIGINAL_ENV[key];
50+
if (original === undefined) delete process.env[key];
51+
else process.env[key] = original;
52+
}
53+
});
54+
55+
function clearUrlEnv(): void {
56+
for (const key of ENV_KEYS) delete process.env[key];
57+
}
58+
59+
/** The `default` datasource DEFINITION a built stack carries. */
60+
function defaultDefOf(stack: Awaited<ReturnType<typeof createStandaloneStack>>): {
61+
driver: string;
62+
config?: Record<string, unknown>;
63+
} {
64+
const plugin = stack.plugins.find(
65+
(p: any) => p?.name === 'com.objectstack.runtime.default-datasource',
66+
) as any;
67+
expect(plugin, 'stack must carry the DefaultDatasourcePlugin').toBeDefined();
68+
return plugin.def;
69+
}
70+
71+
const BOOT_TIMEOUT = 60_000;
72+
73+
describe('detectDriverFromUrl — mysql:// resolves to the `mysql` kind (#6265)', () => {
74+
it('mysql:// resolves to mysql, keeps the URL, and probes no sqlite file', () => {
75+
const url = 'mysql://user:pw@localhost:3306/objectstack';
76+
const r = resolveStandaloneDatabase({ databaseUrl: url });
77+
expect(r.driver).toBe('mysql');
78+
expect(r.url).toBe(url);
79+
// The occupancy probe (`os migrate`, #3917) has nothing to say about a
80+
// remote server — and must NOT read the DSN as a file path.
81+
expect(r.sqliteFile).toBeNull();
82+
});
83+
84+
it('mysql2:// — the second spelling the CLI regex accepts — resolves the same', () => {
85+
const r = resolveStandaloneDatabase({ databaseUrl: 'mysql2://user:pw@db.internal:3306/app' });
86+
expect(r.driver).toBe('mysql');
87+
expect(r.sqliteFile).toBeNull();
88+
});
89+
90+
it('the scheme match is case-insensitive, like every other arm', () => {
91+
expect(resolveStandaloneDatabase({ databaseUrl: 'MYSQL://user@host/db' }).driver).toBe('mysql');
92+
});
93+
94+
it('an explicit databaseDriver: "mysql" is accepted by the config schema', () => {
95+
const r = resolveStandaloneDatabase({
96+
databaseDriver: 'mysql',
97+
databaseUrl: 'mysql://user:pw@localhost:3306/db',
98+
});
99+
expect(r.driver).toBe('mysql');
100+
expect(r.sqliteFile).toBeNull();
101+
});
102+
103+
it('OS_DATABASE_DRIVER=mysql selects the same kind', () => {
104+
clearUrlEnv();
105+
process.env.OS_DATABASE_DRIVER = 'mysql';
106+
process.env.OS_DATABASE_URL = 'mysql://user:pw@env-host:3306/db';
107+
expect(resolveStandaloneDatabase().driver).toBe('mysql');
108+
});
109+
110+
// The URL source that used to be dispatchable only from the CLI side.
111+
it('OS_DATABASE_URL=mysql://… dispatches with no explicit driver at all', () => {
112+
clearUrlEnv();
113+
process.env.OS_DATABASE_URL = 'mysql://user:pw@env-host:3306/db';
114+
const r = resolveStandaloneDatabase();
115+
expect(r.driver).toBe('mysql');
116+
expect(r.url).toBe('mysql://user:pw@env-host:3306/db');
117+
});
118+
});
119+
120+
describe('createStandaloneStack — a mysql:// boot is dispatched, not refused as unknown (#6265)', () => {
121+
it('declares { driver: "mysql", config: { url } } instead of throwing "Unsupported database URL scheme"', async () => {
122+
clearUrlEnv();
123+
const url = 'mysql://user:pw@localhost:3306/objectstack';
124+
const stack = await createStandaloneStack({ databaseUrl: url });
125+
const def = defaultDefOf(stack);
126+
expect(def.driver).toBe('mysql');
127+
expect(def.config).toEqual({ url });
128+
}, BOOT_TIMEOUT);
129+
130+
// The other end of the handshake: the id this stack declares is one the
131+
// SHARED factory can build (`kind === 'mysql'` → SqlDriver, client `mysql2`).
132+
// Not a connect — `mysql2` is an optional peer of `@objectstack/driver-sql`
133+
// and a live server is not this package's contract; what matters is that the
134+
// declared id is not an id nobody builds.
135+
it('the declared driver id is one the shared factory supports', async () => {
136+
const { createDefaultDatasourceDriverFactory } = await import('@objectstack/service-datasource');
137+
const factory = createDefaultDatasourceDriverFactory({ dev: false });
138+
expect(factory.supports('mysql')).toBe(true);
139+
});
140+
141+
it('an explicit databaseDriver:"mysql" declares mysql — never the sqlite fallback', async () => {
142+
clearUrlEnv();
143+
const stack = await createStandaloneStack({
144+
databaseDriver: 'mysql',
145+
databaseUrl: 'mysql://user:pw@localhost:3306/objectstack',
146+
});
147+
expect(defaultDefOf(stack).driver).toBe('mysql');
148+
}, BOOT_TIMEOUT);
149+
});
150+
151+
describe('OS_DATABASE_DRIVER — an unknown value is refused loudly, never SQLite (#6265)', () => {
152+
it('a typo with a URL set throws, naming the value and every legal driver', () => {
153+
clearUrlEnv();
154+
process.env.OS_DATABASE_DRIVER = 'mysq1';
155+
process.env.OS_DATABASE_URL = 'mysql://user:pw@localhost:3306/db';
156+
expect(() => resolveStandaloneDatabase()).toThrow(/Unsupported OS_DATABASE_DRIVER value/);
157+
expect(() => resolveStandaloneDatabase()).toThrow(/mysq1/);
158+
// The legal-values list is DERIVED from the enum, so this loop is the pin:
159+
// a kind added to the schema without touching the message still passes.
160+
for (const option of StandaloneDatabaseDriverSchema.options) {
161+
expect(() => resolveStandaloneDatabase(), `legal value "${option}" must be named`).toThrow(option);
162+
}
163+
});
164+
165+
// The insidious one: no URL at all, so the old code resolved the default
166+
// `file:…/standalone.db`, cast the env value to a kind nothing matched, and
167+
// created a SQLite database for an operator who asked for something else.
168+
it('a typo with NO URL set throws too — it does not quietly become the sqlite default', () => {
169+
clearUrlEnv();
170+
process.env.OS_DATABASE_DRIVER = 'postgress';
171+
expect(() => resolveStandaloneDatabase()).toThrow(/Unsupported OS_DATABASE_DRIVER value/);
172+
expect(() => resolveStandaloneDatabase()).toThrow(/postgress/);
173+
});
174+
175+
it('the whole boot refuses as well, and produces no sqlite definition (both URL states)', async () => {
176+
clearUrlEnv();
177+
process.env.OS_DATABASE_DRIVER = 'mysq1';
178+
process.env.OS_DATABASE_URL = 'mysql://user:pw@localhost:3306/db';
179+
await expect(createStandaloneStack()).rejects.toThrow(/Unsupported OS_DATABASE_DRIVER value/);
180+
181+
delete process.env.OS_DATABASE_URL;
182+
const err = await createStandaloneStack().then(() => null, (e: unknown) => e);
183+
expect(err).not.toBeNull();
184+
expect(String((err as Error).message)).toMatch(/Unsupported OS_DATABASE_DRIVER value/);
185+
// …and nothing anywhere in the refusal offers sqlite as a consolation.
186+
expect(String((err as Error).message)).not.toMatch(/falling back to sqlite|using sqlite/i);
187+
}, BOOT_TIMEOUT);
188+
189+
// Two different failures, two different messages: "I don't know that driver"
190+
// must not read as "I don't know that URL scheme", or an operator debugging
191+
// one goes looking at the other.
192+
it('the driver refusal and the URL-scheme refusal stay distinguishable', () => {
193+
clearUrlEnv();
194+
process.env.OS_DATABASE_DRIVER = 'mysq1';
195+
expect(() => resolveStandaloneDatabase()).not.toThrow(/Unsupported database URL scheme/);
196+
197+
delete process.env.OS_DATABASE_DRIVER;
198+
expect(() => resolveStandaloneDatabase({ databaseUrl: 'wat://nope' }))
199+
.not.toThrow(/Unsupported OS_DATABASE_DRIVER value/);
200+
});
201+
202+
it('an empty / whitespace-only value is "unset", not an unknown driver', () => {
203+
clearUrlEnv();
204+
process.env.OS_DATABASE_DRIVER = ' ';
205+
process.env.OS_DATABASE_URL = 'memory://blank-driver';
206+
expect(resolveStandaloneDatabase().driver).toBe('memory');
207+
});
208+
209+
// Normalization parity with the CLI's reader of this same variable
210+
// (`resolveDriverType`: `.toLowerCase().trim()`). The accepted VOCABULARY is
211+
// still exactly the enum — only the casing of the operator's typing is
212+
// normalized, in both readers, so one env value cannot mean two things.
213+
it('accepts the CLI-normalized spellings of a legal value (case + surrounding space)', () => {
214+
clearUrlEnv();
215+
process.env.OS_DATABASE_URL = 'mysql://user:pw@localhost:3306/db';
216+
process.env.OS_DATABASE_DRIVER = ' MySQL ';
217+
expect(resolveStandaloneDatabase().driver).toBe('mysql');
218+
});
219+
220+
it('every legal value round-trips through the env path', () => {
221+
clearUrlEnv();
222+
// A URL that never decides on its own — the env value is what is under test.
223+
process.env.OS_DATABASE_URL = 'file:/tmp/os-6265/env-driver.db';
224+
for (const option of StandaloneDatabaseDriverSchema.options) {
225+
process.env.OS_DATABASE_DRIVER = option;
226+
expect(resolveStandaloneDatabase().driver).toBe(option);
227+
}
228+
});
229+
});
230+
231+
describe('the existing schemes are untouched (positive controls, #6265)', () => {
232+
it.each([
233+
['memory://anything', 'memory'],
234+
['postgres://user:pw@localhost:5432/db', 'postgres'],
235+
['postgresql://user:pw@localhost:5432/db', 'postgres'],
236+
['pg://user:pw@localhost:5432/db', 'postgres'],
237+
['mysql://user:pw@localhost:3306/db', 'mysql'],
238+
['mysql2://user:pw@localhost:3306/db', 'mysql'],
239+
['mongodb://localhost:27017/objectstack', 'mongodb'],
240+
['mongodb+srv://cluster.example.com/db', 'mongodb'],
241+
['libsql://my-db.turso.io', 'turso'],
242+
['https://my-db.turso.io', 'turso'],
243+
['wasm-sqlite:///tmp/x.db', 'sqlite-wasm'],
244+
['file:/tmp/os-6265/plain.db', 'sqlite'],
245+
['/tmp/os-6265/bare-path.db', 'sqlite'],
246+
])('%s → %s', (url, kind) => {
247+
expect(resolveStandaloneDatabase({ databaseUrl: url }).driver).toBe(kind);
248+
});
249+
250+
// #6220's e2e pins this exit path from the CLI end — the new mysql arm must
251+
// not have turned the trailing throw into a catch-all.
252+
it('an unknown scheme still throws, and the message now lists mysql://', () => {
253+
expect(() => resolveStandaloneDatabase({ databaseUrl: 'wat://nope' }))
254+
.toThrow(/Unsupported database URL scheme/);
255+
expect(() => resolveStandaloneDatabase({ databaseUrl: 'wat://nope' }))
256+
.toThrow(/mysql:\/\//);
257+
// …and it still lists what #5820 added, so neither half erased the other.
258+
expect(() => resolveStandaloneDatabase({ databaseUrl: 'wat://nope' }))
259+
.toThrow(/libsql:\/\//);
260+
});
261+
262+
it('a non-Turso https URL is still unsupported', () => {
263+
expect(() => resolveStandaloneDatabase({ databaseUrl: 'https://example.com/db' }))
264+
.toThrow(/Unsupported database URL scheme/);
265+
});
266+
});

0 commit comments

Comments
 (0)