|
| 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