From 87df5fc7e70f9e5d69048e60b15303da263cdfc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 14:17:10 +0000 Subject: [PATCH] feat(runtime): standalone stack dispatches mysql:// and validates OS_DATABASE_DRIVER (#6265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW --- ...alone-stack-mysql-and-driver-validation.md | 21 ++ .../src/standalone-stack.mysql.test.ts | 266 ++++++++++++++++++ packages/runtime/src/standalone-stack.ts | 122 +++++++- 3 files changed, 402 insertions(+), 7 deletions(-) create mode 100644 .changeset/standalone-stack-mysql-and-driver-validation.md create mode 100644 packages/runtime/src/standalone-stack.mysql.test.ts diff --git a/.changeset/standalone-stack-mysql-and-driver-validation.md b/.changeset/standalone-stack-mysql-and-driver-validation.md new file mode 100644 index 0000000000..bf46a101d2 --- /dev/null +++ b/.changeset/standalone-stack-mysql-and-driver-validation.md @@ -0,0 +1,21 @@ +--- +'@objectstack/runtime': minor +--- + +**`createStandaloneStack` now dispatches `mysql://`, and an unknown `OS_DATABASE_DRIVER` value is refused instead of silently becoming SQLite** (#6265). + +Two halves of one defect family: a driver selection this stack could not dispatch. + +**`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`. + +- `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. +- 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. +- `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. + +**`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. + +- 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. +- 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. +- 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. + +Unknown URL schemes still throw (the message now lists `mysql://`), and the "unknown driver" and "unknown URL scheme" refusals stay distinguishable. diff --git a/packages/runtime/src/standalone-stack.mysql.test.ts b/packages/runtime/src/standalone-stack.mysql.test.ts new file mode 100644 index 0000000000..67955988d7 --- /dev/null +++ b/packages/runtime/src/standalone-stack.mysql.test.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #6265 — two halves of one defect family in `standalone-stack.ts`, pinned +// together because they close the same hole from opposite sides: 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 +// (`utils/storage-driver.ts` `inferDriverTypeFromUrl`), the SHARED factory +// has always been able to build it (`kind === 'mysql'` → SqlDriver on +// `mysql2`), and 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`. +// +// (b) `OS_DATABASE_DRIVER` — `cfg.databaseDriver` was parsed by a zod enum +// (loud rejection) while the env var was a bare `as` cast (no runtime check +// at all). An unknown 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 connected to MySQL — the #3276 class, and the value is +// one `content/docs/deployment/environment-variables.mdx` advertises. +// +// Nothing here talks to a real MySQL server: `createStandaloneStack` builds a +// DEFINITION and hands it to `DefaultDatasourcePlugin`, which connects later at +// kernel init (ADR-0062 D1 / #3826). The definition plus the shared factory's +// own `supports()` is therefore the whole of this package's contract. + +import { describe, it, expect, afterEach } from 'vitest'; +import { + resolveStandaloneDatabase, + createStandaloneStack, + StandaloneDatabaseDriverSchema, +} from './standalone-stack.js'; + +/** Env keys these tests write; restored after every case. */ +const ENV_KEYS = [ + 'OS_DATABASE_URL', + 'DATABASE_URL', + 'TURSO_DATABASE_URL', + 'OS_DATABASE_DRIVER', + 'OS_HOME', +] as const; +const ORIGINAL_ENV: Record = Object.fromEntries( + ENV_KEYS.map((k) => [k, process.env[k]]), +); + +afterEach(() => { + for (const key of ENV_KEYS) { + const original = ORIGINAL_ENV[key]; + if (original === undefined) delete process.env[key]; + else process.env[key] = original; + } +}); + +function clearUrlEnv(): void { + for (const key of ENV_KEYS) delete process.env[key]; +} + +/** The `default` datasource DEFINITION a built stack carries. */ +function defaultDefOf(stack: Awaited>): { + driver: string; + config?: Record; +} { + const plugin = stack.plugins.find( + (p: any) => p?.name === 'com.objectstack.runtime.default-datasource', + ) as any; + expect(plugin, 'stack must carry the DefaultDatasourcePlugin').toBeDefined(); + return plugin.def; +} + +const BOOT_TIMEOUT = 60_000; + +describe('detectDriverFromUrl — mysql:// resolves to the `mysql` kind (#6265)', () => { + it('mysql:// resolves to mysql, keeps the URL, and probes no sqlite file', () => { + const url = 'mysql://user:pw@localhost:3306/objectstack'; + const r = resolveStandaloneDatabase({ databaseUrl: url }); + expect(r.driver).toBe('mysql'); + expect(r.url).toBe(url); + // The occupancy probe (`os migrate`, #3917) has nothing to say about a + // remote server — and must NOT read the DSN as a file path. + expect(r.sqliteFile).toBeNull(); + }); + + it('mysql2:// — the second spelling the CLI regex accepts — resolves the same', () => { + const r = resolveStandaloneDatabase({ databaseUrl: 'mysql2://user:pw@db.internal:3306/app' }); + expect(r.driver).toBe('mysql'); + expect(r.sqliteFile).toBeNull(); + }); + + it('the scheme match is case-insensitive, like every other arm', () => { + expect(resolveStandaloneDatabase({ databaseUrl: 'MYSQL://user@host/db' }).driver).toBe('mysql'); + }); + + it('an explicit databaseDriver: "mysql" is accepted by the config schema', () => { + const r = resolveStandaloneDatabase({ + databaseDriver: 'mysql', + databaseUrl: 'mysql://user:pw@localhost:3306/db', + }); + expect(r.driver).toBe('mysql'); + expect(r.sqliteFile).toBeNull(); + }); + + it('OS_DATABASE_DRIVER=mysql selects the same kind', () => { + clearUrlEnv(); + process.env.OS_DATABASE_DRIVER = 'mysql'; + process.env.OS_DATABASE_URL = 'mysql://user:pw@env-host:3306/db'; + expect(resolveStandaloneDatabase().driver).toBe('mysql'); + }); + + // The URL source that used to be dispatchable only from the CLI side. + it('OS_DATABASE_URL=mysql://… dispatches with no explicit driver at all', () => { + clearUrlEnv(); + process.env.OS_DATABASE_URL = 'mysql://user:pw@env-host:3306/db'; + const r = resolveStandaloneDatabase(); + expect(r.driver).toBe('mysql'); + expect(r.url).toBe('mysql://user:pw@env-host:3306/db'); + }); +}); + +describe('createStandaloneStack — a mysql:// boot is dispatched, not refused as unknown (#6265)', () => { + it('declares { driver: "mysql", config: { url } } instead of throwing "Unsupported database URL scheme"', async () => { + clearUrlEnv(); + const url = 'mysql://user:pw@localhost:3306/objectstack'; + const stack = await createStandaloneStack({ databaseUrl: url }); + const def = defaultDefOf(stack); + expect(def.driver).toBe('mysql'); + expect(def.config).toEqual({ url }); + }, BOOT_TIMEOUT); + + // The other end of the handshake: the id this stack declares is one the + // SHARED factory can build (`kind === 'mysql'` → SqlDriver, client `mysql2`). + // Not a connect — `mysql2` is an optional peer of `@objectstack/driver-sql` + // and a live server is not this package's contract; what matters is that the + // declared id is not an id nobody builds. + it('the declared driver id is one the shared factory supports', async () => { + const { createDefaultDatasourceDriverFactory } = await import('@objectstack/service-datasource'); + const factory = createDefaultDatasourceDriverFactory({ dev: false }); + expect(factory.supports('mysql')).toBe(true); + }); + + it('an explicit databaseDriver:"mysql" declares mysql — never the sqlite fallback', async () => { + clearUrlEnv(); + const stack = await createStandaloneStack({ + databaseDriver: 'mysql', + databaseUrl: 'mysql://user:pw@localhost:3306/objectstack', + }); + expect(defaultDefOf(stack).driver).toBe('mysql'); + }, BOOT_TIMEOUT); +}); + +describe('OS_DATABASE_DRIVER — an unknown value is refused loudly, never SQLite (#6265)', () => { + it('a typo with a URL set throws, naming the value and every legal driver', () => { + clearUrlEnv(); + process.env.OS_DATABASE_DRIVER = 'mysq1'; + process.env.OS_DATABASE_URL = 'mysql://user:pw@localhost:3306/db'; + expect(() => resolveStandaloneDatabase()).toThrow(/Unsupported OS_DATABASE_DRIVER value/); + expect(() => resolveStandaloneDatabase()).toThrow(/mysq1/); + // The legal-values list is DERIVED from the enum, so this loop is the pin: + // a kind added to the schema without touching the message still passes. + for (const option of StandaloneDatabaseDriverSchema.options) { + expect(() => resolveStandaloneDatabase(), `legal value "${option}" must be named`).toThrow(option); + } + }); + + // The insidious one: no URL at all, so the old code resolved the default + // `file:…/standalone.db`, cast the env value to a kind nothing matched, and + // created a SQLite database for an operator who asked for something else. + it('a typo with NO URL set throws too — it does not quietly become the sqlite default', () => { + clearUrlEnv(); + process.env.OS_DATABASE_DRIVER = 'postgress'; + expect(() => resolveStandaloneDatabase()).toThrow(/Unsupported OS_DATABASE_DRIVER value/); + expect(() => resolveStandaloneDatabase()).toThrow(/postgress/); + }); + + it('the whole boot refuses as well, and produces no sqlite definition (both URL states)', async () => { + clearUrlEnv(); + process.env.OS_DATABASE_DRIVER = 'mysq1'; + process.env.OS_DATABASE_URL = 'mysql://user:pw@localhost:3306/db'; + await expect(createStandaloneStack()).rejects.toThrow(/Unsupported OS_DATABASE_DRIVER value/); + + delete process.env.OS_DATABASE_URL; + const err = await createStandaloneStack().then(() => null, (e: unknown) => e); + expect(err).not.toBeNull(); + expect(String((err as Error).message)).toMatch(/Unsupported OS_DATABASE_DRIVER value/); + // …and nothing anywhere in the refusal offers sqlite as a consolation. + expect(String((err as Error).message)).not.toMatch(/falling back to sqlite|using sqlite/i); + }, BOOT_TIMEOUT); + + // Two different failures, two different messages: "I don't know that driver" + // must not read as "I don't know that URL scheme", or an operator debugging + // one goes looking at the other. + it('the driver refusal and the URL-scheme refusal stay distinguishable', () => { + clearUrlEnv(); + process.env.OS_DATABASE_DRIVER = 'mysq1'; + expect(() => resolveStandaloneDatabase()).not.toThrow(/Unsupported database URL scheme/); + + delete process.env.OS_DATABASE_DRIVER; + expect(() => resolveStandaloneDatabase({ databaseUrl: 'wat://nope' })) + .not.toThrow(/Unsupported OS_DATABASE_DRIVER value/); + }); + + it('an empty / whitespace-only value is "unset", not an unknown driver', () => { + clearUrlEnv(); + process.env.OS_DATABASE_DRIVER = ' '; + process.env.OS_DATABASE_URL = 'memory://blank-driver'; + expect(resolveStandaloneDatabase().driver).toBe('memory'); + }); + + // Normalization parity with the CLI's reader of this same variable + // (`resolveDriverType`: `.toLowerCase().trim()`). The accepted VOCABULARY is + // still exactly the enum — only the casing of the operator's typing is + // normalized, in both readers, so one env value cannot mean two things. + it('accepts the CLI-normalized spellings of a legal value (case + surrounding space)', () => { + clearUrlEnv(); + process.env.OS_DATABASE_URL = 'mysql://user:pw@localhost:3306/db'; + process.env.OS_DATABASE_DRIVER = ' MySQL '; + expect(resolveStandaloneDatabase().driver).toBe('mysql'); + }); + + it('every legal value round-trips through the env path', () => { + clearUrlEnv(); + // A URL that never decides on its own — the env value is what is under test. + process.env.OS_DATABASE_URL = 'file:/tmp/os-6265/env-driver.db'; + for (const option of StandaloneDatabaseDriverSchema.options) { + process.env.OS_DATABASE_DRIVER = option; + expect(resolveStandaloneDatabase().driver).toBe(option); + } + }); +}); + +describe('the existing schemes are untouched (positive controls, #6265)', () => { + it.each([ + ['memory://anything', 'memory'], + ['postgres://user:pw@localhost:5432/db', 'postgres'], + ['postgresql://user:pw@localhost:5432/db', 'postgres'], + ['pg://user:pw@localhost:5432/db', 'postgres'], + ['mysql://user:pw@localhost:3306/db', 'mysql'], + ['mysql2://user:pw@localhost:3306/db', 'mysql'], + ['mongodb://localhost:27017/objectstack', 'mongodb'], + ['mongodb+srv://cluster.example.com/db', 'mongodb'], + ['libsql://my-db.turso.io', 'turso'], + ['https://my-db.turso.io', 'turso'], + ['wasm-sqlite:///tmp/x.db', 'sqlite-wasm'], + ['file:/tmp/os-6265/plain.db', 'sqlite'], + ['/tmp/os-6265/bare-path.db', 'sqlite'], + ])('%s → %s', (url, kind) => { + expect(resolveStandaloneDatabase({ databaseUrl: url }).driver).toBe(kind); + }); + + // #6220's e2e pins this exit path from the CLI end — the new mysql arm must + // not have turned the trailing throw into a catch-all. + it('an unknown scheme still throws, and the message now lists mysql://', () => { + expect(() => resolveStandaloneDatabase({ databaseUrl: 'wat://nope' })) + .toThrow(/Unsupported database URL scheme/); + expect(() => resolveStandaloneDatabase({ databaseUrl: 'wat://nope' })) + .toThrow(/mysql:\/\//); + // …and it still lists what #5820 added, so neither half erased the other. + expect(() => resolveStandaloneDatabase({ databaseUrl: 'wat://nope' })) + .toThrow(/libsql:\/\//); + }); + + it('a non-Turso https URL is still unsupported', () => { + expect(() => resolveStandaloneDatabase({ databaseUrl: 'https://example.com/db' })) + .toThrow(/Unsupported database URL scheme/); + }); +}); diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index b658e0462e..97296f14f3 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -16,13 +16,17 @@ * Auto-detects the appropriate driver from the database URL scheme: * - `memory://*` → InMemoryDriver * - `postgres[ql]://`, `pg://` → SqlDriver (pg) + * - `mysql[2]://` → SqlDriver (mysql2) * - `mongodb[+srv]://` → MongoDBDriver (optional `@objectstack/driver-mongodb`) * - `libsql://`, `http(s)://*.turso.*` → TursoDriver (optional `@objectstack/driver-turso`) * - `file:` / no scheme → SqlDriver (better-sqlite3) * * Unknown URL schemes throw — we never silently fall back to sqlite, since * that historically created bogus directories on disk (e.g. `mongodb:/`) - * when an unsupported URL was treated as a file path. + * when an unsupported URL was treated as a file path. The SAME refusal now + * covers an unknown `OS_DATABASE_DRIVER` value (#6265): that env var used to be + * a bare `as` cast, so a typo — or `mysql` before this stack could dispatch it + * — fell through the driver chain's trailing `else` into SQLite without a word. * * NOTE: `libsql://` / Turso support comes from `@objectstack/driver-turso`, * which lives in THIS repository (`packages/drivers/driver-turso`) since #4645 @@ -36,6 +40,16 @@ * under (#5602 / PR #5819); before #5820 the two disagreed, and one * `OS_DATABASE_URL=libsql://…` booted under `os start` while `os migrate` — which * comes through here — refused it as an unsupported scheme. + * + * NOTE: `mysql://` is the same family with none of the optional-package weight + * (#6265). The CLI has classified it as `mysql` since forever + * (`inferDriverTypeFromUrl`), the SHARED factory has always been able to build + * it (`kind === 'mysql'` → SqlDriver on `mysql2`), and only this file was + * missing the arm — so one `OS_DATABASE_URL=mysql://…` booted under `os start` + * and died under `os migrate` with `Unsupported database URL scheme`, exactly + * the #5820 split with a different scheme. Nothing lazy is needed here: the + * definition goes straight to `DefaultDatasourcePlugin` and the shared factory + * builds it like postgres. */ import { resolve as resolvePath } from 'node:path'; @@ -69,6 +83,24 @@ export function resolveObjectStackHome(): string { return resolvePath(homedir(), '.objectstack'); } +/** + * The driver kinds a standalone boot can dispatch — the ONE list, and the only + * one (#6265). + * + * Three consumers read it and every one of them used to carry its own answer: + * the `databaseDriver` config key (a zod enum that rejected loudly), the + * `OS_DATABASE_DRIVER` env var (a bare `as` cast that validated nothing, so an + * unknown value fell through the dispatch chain's trailing `else` into SQLite), + * and the `ResolvedDriverKind` union (a hand-written third copy). They are now + * one declaration: the union is `z.infer`red from it, the env value is parsed + * through it, and the refusal message enumerates `.options` rather than + * repeating them — a kind added here cannot leave a stale legal-values list + * behind. + */ +export const StandaloneDatabaseDriverSchema = z.enum([ + 'sqlite', 'sqlite-wasm', 'memory', 'postgres', 'mysql', 'mongodb', 'turso', +]); + export const StandaloneStackConfigSchema = z.object({ databaseUrl: z.string().optional(), /** @@ -78,7 +110,7 @@ export const StandaloneStackConfigSchema = z.object({ * reads, and the same pair `--database-auth-token` forwards into). */ databaseAuthToken: z.string().optional(), - databaseDriver: z.enum(['sqlite', 'sqlite-wasm', 'memory', 'postgres', 'mongodb', 'turso']).optional(), + databaseDriver: StandaloneDatabaseDriverSchema.optional(), environmentId: z.string().optional(), artifactPath: z.string().optional(), /** @@ -159,11 +191,19 @@ export interface StandaloneStackResult { positions?: any[]; } -type ResolvedDriverKind = 'memory' | 'postgres' | 'mongodb' | 'turso' | 'sqlite' | 'sqlite-wasm'; +type ResolvedDriverKind = z.infer; function detectDriverFromUrl(dbUrl: string): ResolvedDriverKind { if (/^memory:\/\//i.test(dbUrl)) return 'memory'; if (/^(postgres(ql)?|pg):\/\//i.test(dbUrl)) return 'postgres'; + // MySQL / MariaDB (#6265). Character-for-character the regex the CLI uses + // (`utils/storage-driver.ts` `inferDriverTypeFromUrl`), for the same reason + // the turso arm below copies its spellings: the two functions answer the + // same question about the same `OS_DATABASE_URL`, so any divergence IS the + // bug — `os start` booting a URL `os migrate` refuses. Unlike turso this + // needs no optional package: the shared factory's `mysql` arm builds a + // SqlDriver on `mysql2`, which `@objectstack/driver-sql` already ships. + if (/^mysql2?:\/\//i.test(dbUrl)) return 'mysql'; if (/^mongodb(\+srv)?:\/\//i.test(dbUrl)) return 'mongodb'; // libSQL / Turso (#5820). The same two spellings the CLI classifies as // `turso` (`utils/storage-driver.ts` `inferDriverTypeFromUrl`, #5602) — kept @@ -182,11 +222,52 @@ function detectDriverFromUrl(dbUrl: string): ResolvedDriverKind { if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(dbUrl)) return 'sqlite'; throw new Error( `[StandaloneStack] Unsupported database URL scheme: ${dbUrl}. ` + - `Supported schemes: memory://, postgres://, pg://, mongodb://, mongodb+srv://, ` + + `Supported schemes: memory://, postgres://, pg://, mysql://, mysql2://, ` + + `mongodb://, mongodb+srv://, ` + `libsql:// (optional @objectstack/driver-turso), file:` ); } +/** + * The explicit driver selection for this boot, or `undefined` when none was made. + * + * Two sources, ONE vocabulary (#6265). `cfg.databaseDriver` has always been + * parsed by {@link StandaloneDatabaseDriverSchema}; `OS_DATABASE_DRIVER` was + * `process.env.OS_DATABASE_DRIVER?.trim() as ResolvedDriverKind` — an assertion, + * which checks nothing at runtime. An unknown value therefore reached the + * dispatch chain in `createStandaloneStack`, matched no arm, and landed in the + * trailing `else`: SQLite, silently. `OS_DATABASE_DRIVER=mysql` (a value the + * CLI advertises and `content/docs/deployment/environment-variables.mdx` lists) + * with no URL set therefore created a local `standalone.db` while the operator + * believed they were talking to MySQL — the #3276 class exactly, and 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 an operator who never selected sqlite. + * + * So the env value is parsed by the same schema and an unrecognised one is + * refused LOUDLY, naming the legal values. The value is lower-cased first, for + * the same reason the regexes above are copied verbatim from the CLI: the CLI's + * reader of this very variable does `(explicitDriver ?? '').toLowerCase().trim()`, + * and a case-sensitive reader here would re-open the divergence this issue is + * about, one notch narrower. The accepted VOCABULARY is unchanged either way — + * it is the enum, and nothing else. + */ +function resolveExplicitDriver( + cfg: z.output, +): ResolvedDriverKind | undefined { + if (cfg.databaseDriver) return cfg.databaseDriver; + const raw = process.env.OS_DATABASE_DRIVER?.trim(); + if (!raw) return undefined; + const parsed = StandaloneDatabaseDriverSchema.safeParse(raw.toLowerCase()); + if (parsed.success) return parsed.data; + throw new Error( + `[StandaloneStack] Unsupported OS_DATABASE_DRIVER value: "${raw}". ` + + `Supported drivers: ${StandaloneDatabaseDriverSchema.options.join(', ')}. ` + + `Booting on the SQLite default instead would silently ignore the driver you asked for ` + + `and write into a local database (#3276). Fix the value, or unset OS_DATABASE_DRIVER ` + + `to let the OS_DATABASE_URL scheme select the driver.` + ); +} + /** URL→filename for the two sqlite kinds. Throws on a URL that isn't a path. */ function sqliteFilenameFromUrl(dbUrl: string, kind: 'sqlite' | 'sqlite-wasm'): string { if (kind === 'sqlite-wasm') { @@ -229,12 +310,16 @@ export interface ResolvedStandaloneDatabase { * URL was read here and then rejected by `detectDriverFromUrl` as an unsupported * scheme, so a host that set it got a hard failure rather than a libSQL * connection. Reading a source you cannot dispatch is worse than not reading it. + * + * Throws on a selection this stack cannot dispatch — an unknown URL scheme + * (`detectDriverFromUrl`) or, since #6265, an unknown `OS_DATABASE_DRIVER` value + * ({@link resolveExplicitDriver}). Both refusals happen HERE rather than at boot + * so `os migrate`'s pre-boot probe reads the same verdict the boot would. */ export function resolveStandaloneDatabase(config?: StandaloneStackConfig): ResolvedStandaloneDatabase { const cfg = StandaloneStackConfigSchema.parse(config ?? {}); const url = resolveDatabaseUrl(cfg); - const explicitDriver = cfg.databaseDriver - ?? (process.env.OS_DATABASE_DRIVER?.trim() as ResolvedDriverKind | undefined); + const explicitDriver = resolveExplicitDriver(cfg); const driver: ResolvedDriverKind = explicitDriver || detectDriverFromUrl(url); const isSqlite = driver === 'sqlite' || driver === 'sqlite-wasm'; const filename = isSqlite ? sqliteFilenameFromUrl(url, driver) : null; @@ -335,6 +420,15 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro // Factory applies the pg pool default ({ min: 0, max: 5 }) internally. driverId = 'postgres'; driverConfig = { url: dbUrl }; + } else if (dbDriver === 'mysql') { + // MySQL / MariaDB (#6265). Nothing special: the shared factory's `mysql` + // arm builds a SqlDriver on the `mysql2` client from exactly this + // config (`MysqlConfigSchema.url` — "passed to mysql2 as-is"), and the + // CLI's own `mysql` branch produces the same `{ driverId: 'mysql', + // config: { url } }`. The only thing that was missing was this arm, and + // the detection arm above it. + driverId = 'mysql'; + driverConfig = { url: dbUrl }; } else if (dbDriver === 'mongodb') { // A missing @objectstack/driver-mongodb peer dep surfaces at boot via // the connection service's fail-fast (the factory's "not installed" @@ -364,12 +458,26 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro mkdirSync(resolvePath(filename, '..'), { recursive: true }); } driverConfig = { filename }; - } else { + } else if (dbDriver === 'sqlite') { // sqlite (better-sqlite3) driverId = 'sqlite'; const filename = sqliteFilenameFromUrl(dbUrl, 'sqlite'); mkdirSync(resolvePath(filename, '..'), { recursive: true }); driverConfig = { filename }; + } else { + // Unreachable by construction — and making it unreachable is half the + // fix (#6265). This used to be a bare `else` meaning "sqlite", so every + // kind without an arm above became SQLite in silence: an unvalidated + // `OS_DATABASE_DRIVER` value landed here, and so would the NEXT kind + // added to the enum without a dispatch arm. `dbDriver` is now narrowed + // to `never` here, so that omission is a compile error instead of a + // wrong database. + const unreachable: never = dbDriver; + throw new Error( + `[StandaloneStack] No dispatch arm for database driver kind: ${String(unreachable)}. ` + + `Every kind in StandaloneDatabaseDriverSchema needs one — falling through to SQLite ` + + `is the #3276 defect.` + ); } const defaultDatasourcePlugin = new DefaultDatasourcePlugin( { driver: driverId, config: driverConfig },