|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #5820 — the standalone stack dispatches `libsql://`, on the same terms the CLI |
| 4 | +// does (#5602 / PR #5819). |
| 5 | +// |
| 6 | +// Before this, `detectDriverFromUrl()` refused every libSQL URL with |
| 7 | +// `Unsupported database URL scheme`, while `resolveDatabaseUrl()` listed |
| 8 | +// `TURSO_DATABASE_URL` as a URL SOURCE — read it in, cannot dispatch it out. The |
| 9 | +// operator-visible split was `os start` (CLI path, boots) versus `os migrate` |
| 10 | +// (this path, `Unsupported database URL scheme`) on one and the same |
| 11 | +// `OS_DATABASE_URL=libsql://…`. |
| 12 | +// |
| 13 | +// What the pins below assert, in order: |
| 14 | +// 1. detection — libSQL URLs resolve to the `turso` kind, existing schemes are |
| 15 | +// untouched, and a genuinely unknown scheme still throws; |
| 16 | +// 2. the optional package, both ways — present ⇒ a TursoDriver is built from |
| 17 | +// the definition; absent ⇒ a loud failure carrying the install command, |
| 18 | +// with no SQLite anywhere in the failure (#3276); |
| 19 | +// 3. the whole boot — `createStandaloneStack({ databaseUrl: 'libsql://…' })` |
| 20 | +// no longer produces the "unsupported scheme" refusal. |
| 21 | +// |
| 22 | +// No test here touches a real Turso endpoint: the package is substituted through |
| 23 | +// `importDriverPackage`, which is what makes the "package missing" arm testable |
| 24 | +// even in a workspace where the package happens to be installed. |
| 25 | + |
| 26 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 27 | +import { |
| 28 | + resolveStandaloneDatabase, |
| 29 | + resolveDatabaseAuthToken, |
| 30 | + createStandaloneStack, |
| 31 | +} from './standalone-stack.js'; |
| 32 | +import { |
| 33 | + loadTursoDriverFactory, |
| 34 | + MissingDriverPackageError, |
| 35 | + isTursoDriverId, |
| 36 | + TURSO_DRIVER_INSTALL_COMMAND, |
| 37 | + TURSO_DRIVER_PACKAGE, |
| 38 | +} from './turso-driver-factory.js'; |
| 39 | + |
| 40 | +/** Env keys these tests write; restored after every case. */ |
| 41 | +const ENV_KEYS = [ |
| 42 | + 'OS_DATABASE_URL', |
| 43 | + 'DATABASE_URL', |
| 44 | + 'TURSO_DATABASE_URL', |
| 45 | + 'OS_DATABASE_AUTH_TOKEN', |
| 46 | + 'TURSO_AUTH_TOKEN', |
| 47 | + 'OS_DATABASE_DRIVER', |
| 48 | + 'OS_HOME', |
| 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 | +afterEach(() => { |
| 55 | + for (const key of ENV_KEYS) { |
| 56 | + const original = ORIGINAL_ENV[key]; |
| 57 | + if (original === undefined) delete process.env[key]; |
| 58 | + else process.env[key] = original; |
| 59 | + } |
| 60 | +}); |
| 61 | + |
| 62 | +function clearUrlEnv(): void { |
| 63 | + for (const key of ENV_KEYS) delete process.env[key]; |
| 64 | +} |
| 65 | + |
| 66 | +describe('detectDriverFromUrl — libSQL/Turso URLs resolve to the `turso` kind (#5820)', () => { |
| 67 | + it('libsql:// resolves to turso, keeps the URL, and probes no sqlite file', () => { |
| 68 | + const r = resolveStandaloneDatabase({ databaseUrl: 'libsql://my-db.turso.io' }); |
| 69 | + expect(r.driver).toBe('turso'); |
| 70 | + expect(r.url).toBe('libsql://my-db.turso.io'); |
| 71 | + // The occupancy probe (`os migrate`, #3917) must have nothing to say about a |
| 72 | + // remote endpoint — and must NOT read the URL as a file path. |
| 73 | + expect(r.sqliteFile).toBeNull(); |
| 74 | + }); |
| 75 | + |
| 76 | + it('an https Turso endpoint resolves to turso — the exact spelling the CLI classifies', () => { |
| 77 | + expect(resolveStandaloneDatabase({ databaseUrl: 'https://my-db.turso.io' }).driver).toBe('turso'); |
| 78 | + expect(resolveStandaloneDatabase({ databaseUrl: 'http://my-db.turso.io' }).driver).toBe('turso'); |
| 79 | + }); |
| 80 | + |
| 81 | + // The reason this issue exists: the env var was already a URL SOURCE here. |
| 82 | + it('TURSO_DATABASE_URL now dispatches as well as resolves (the read-in/refuse-out split is gone)', () => { |
| 83 | + clearUrlEnv(); |
| 84 | + process.env.TURSO_DATABASE_URL = 'libsql://from-env.turso.io'; |
| 85 | + const r = resolveStandaloneDatabase(); |
| 86 | + expect(r.url).toBe('libsql://from-env.turso.io'); |
| 87 | + expect(r.driver).toBe('turso'); |
| 88 | + }); |
| 89 | + |
| 90 | + it('an explicit databaseDriver: "turso" is accepted by the config schema', () => { |
| 91 | + const r = resolveStandaloneDatabase({ databaseDriver: 'turso', databaseUrl: 'libsql://explicit.turso.io' }); |
| 92 | + expect(r.driver).toBe('turso'); |
| 93 | + expect(r.sqliteFile).toBeNull(); |
| 94 | + }); |
| 95 | + |
| 96 | + it('OS_DATABASE_DRIVER=turso selects the same kind', () => { |
| 97 | + clearUrlEnv(); |
| 98 | + process.env.OS_DATABASE_DRIVER = 'turso'; |
| 99 | + process.env.OS_DATABASE_URL = 'libsql://env-driver.turso.io'; |
| 100 | + expect(resolveStandaloneDatabase().driver).toBe('turso'); |
| 101 | + }); |
| 102 | +}); |
| 103 | + |
| 104 | +describe('detectDriverFromUrl — the existing schemes are untouched (positive controls)', () => { |
| 105 | + it.each([ |
| 106 | + ['memory://anything', 'memory'], |
| 107 | + ['postgres://user:pw@localhost:5432/db', 'postgres'], |
| 108 | + ['postgresql://user:pw@localhost:5432/db', 'postgres'], |
| 109 | + ['pg://user:pw@localhost:5432/db', 'postgres'], |
| 110 | + ['mongodb://localhost:27017/objectstack', 'mongodb'], |
| 111 | + ['mongodb+srv://cluster.example.com/db', 'mongodb'], |
| 112 | + ['wasm-sqlite:///tmp/x.db', 'sqlite-wasm'], |
| 113 | + ['file:/tmp/os-5820/plain.db', 'sqlite'], |
| 114 | + ['/tmp/os-5820/bare-path.db', 'sqlite'], |
| 115 | + ])('%s → %s', (url, kind) => { |
| 116 | + expect(resolveStandaloneDatabase({ databaseUrl: url }).driver).toBe(kind); |
| 117 | + }); |
| 118 | + |
| 119 | + it('an unknown scheme still throws, and the message now lists libsql', () => { |
| 120 | + expect(() => resolveStandaloneDatabase({ databaseUrl: 'wat://nope' })) |
| 121 | + .toThrow(/Unsupported database URL scheme/); |
| 122 | + expect(() => resolveStandaloneDatabase({ databaseUrl: 'wat://nope' })) |
| 123 | + .toThrow(/libsql:\/\//); |
| 124 | + }); |
| 125 | + |
| 126 | + // The turso arm is narrow on purpose: a plain https URL is not a database. |
| 127 | + it('a non-Turso https URL is still unsupported', () => { |
| 128 | + expect(() => resolveStandaloneDatabase({ databaseUrl: 'https://example.com/db' })) |
| 129 | + .toThrow(/Unsupported database URL scheme/); |
| 130 | + }); |
| 131 | +}); |
| 132 | + |
| 133 | +describe('resolveDatabaseAuthToken — the same precedence `os serve` reads', () => { |
| 134 | + it('explicit config wins over both env vars', () => { |
| 135 | + process.env.OS_DATABASE_AUTH_TOKEN = 'from-os-env'; |
| 136 | + process.env.TURSO_AUTH_TOKEN = 'from-vendor-env'; |
| 137 | + expect(resolveDatabaseAuthToken({ databaseAuthToken: 'from-config' })).toBe('from-config'); |
| 138 | + }); |
| 139 | + |
| 140 | + it('OS_DATABASE_AUTH_TOKEN (where --database-auth-token lands) wins over TURSO_AUTH_TOKEN', () => { |
| 141 | + clearUrlEnv(); |
| 142 | + process.env.OS_DATABASE_AUTH_TOKEN = 'from-os-env'; |
| 143 | + process.env.TURSO_AUTH_TOKEN = 'from-vendor-env'; |
| 144 | + expect(resolveDatabaseAuthToken()).toBe('from-os-env'); |
| 145 | + }); |
| 146 | + |
| 147 | + it('falls back to the vendor TURSO_AUTH_TOKEN', () => { |
| 148 | + clearUrlEnv(); |
| 149 | + process.env.TURSO_AUTH_TOKEN = 'from-vendor-env'; |
| 150 | + expect(resolveDatabaseAuthToken()).toBe('from-vendor-env'); |
| 151 | + }); |
| 152 | + |
| 153 | + it('blank values are absent, not empty credentials', () => { |
| 154 | + clearUrlEnv(); |
| 155 | + process.env.OS_DATABASE_AUTH_TOKEN = ' '; |
| 156 | + expect(resolveDatabaseAuthToken()).toBeUndefined(); |
| 157 | + process.env.TURSO_AUTH_TOKEN = 'fallback'; |
| 158 | + expect(resolveDatabaseAuthToken()).toBe('fallback'); |
| 159 | + }); |
| 160 | + |
| 161 | + it('no source at all → undefined (so no authToken key reaches the driver config)', () => { |
| 162 | + clearUrlEnv(); |
| 163 | + expect(resolveDatabaseAuthToken()).toBeUndefined(); |
| 164 | + }); |
| 165 | +}); |
| 166 | + |
| 167 | +describe('loadTursoDriverFactory — the OPTIONAL driver package, both ways (#5820)', () => { |
| 168 | + /** A stand-in for the real `@objectstack/driver-turso` module. */ |
| 169 | + function stubTursoModule() { |
| 170 | + const built: Array<Record<string, unknown>> = []; |
| 171 | + class FakeTursoDriver { |
| 172 | + connected = false; |
| 173 | + disconnected = false; |
| 174 | + constructor(public readonly config: Record<string, unknown>) { |
| 175 | + built.push(config); |
| 176 | + } |
| 177 | + async connect() { this.connected = true; } |
| 178 | + async disconnect() { this.disconnected = true; } |
| 179 | + async checkHealth() { return true; } |
| 180 | + } |
| 181 | + return { built, module: { TursoDriver: FakeTursoDriver } }; |
| 182 | + } |
| 183 | + |
| 184 | + it('claims the turso/libsql driver ids and nothing else', async () => { |
| 185 | + const { module } = stubTursoModule(); |
| 186 | + const factory = await loadTursoDriverFactory({ importDriverPackage: async () => module }); |
| 187 | + expect(factory.supports('turso')).toBe(true); |
| 188 | + expect(factory.supports('libsql')).toBe(true); |
| 189 | + expect(factory.supports('LibSQL')).toBe(true); |
| 190 | + expect(factory.supports('sqlite')).toBe(false); |
| 191 | + expect(factory.supports('memory')).toBe(false); |
| 192 | + expect(isTursoDriverId('turso')).toBe(true); |
| 193 | + expect(isTursoDriverId('sqlite')).toBe(false); |
| 194 | + }); |
| 195 | + |
| 196 | + // ① Package present: the definition this stack builds reaches a TursoDriver |
| 197 | + // construction with the url and the auth token. No network — the substitute |
| 198 | + // module proves the DISPATCH, which is this package's half of the contract. |
| 199 | + it('builds a TursoDriver from the stack-shaped definition (url + authToken)', async () => { |
| 200 | + const { built, module } = stubTursoModule(); |
| 201 | + const factory = await loadTursoDriverFactory({ importDriverPackage: async () => module }); |
| 202 | + |
| 203 | + const handle = await factory.create({ |
| 204 | + name: 'default', |
| 205 | + driver: 'turso', |
| 206 | + config: { url: 'libsql://my-db.turso.io', authToken: 'jwt-token' }, |
| 207 | + }); |
| 208 | + |
| 209 | + expect(built).toEqual([{ url: 'libsql://my-db.turso.io', authToken: 'jwt-token' }]); |
| 210 | + expect(handle.driver).toBeInstanceOf(module.TursoDriver); |
| 211 | + // Ownership left at the default `'factory'`: the instance was built for THIS |
| 212 | + // connect, so kernel teardown disconnects it. |
| 213 | + expect(handle.ownership).toBeUndefined(); |
| 214 | + await handle.connect!(); |
| 215 | + expect(await handle.checkHealth!()).toBe(true); |
| 216 | + await handle.disconnect!(); |
| 217 | + const driver = handle.driver as { connected: boolean; disconnected: boolean }; |
| 218 | + expect(driver.connected).toBe(true); |
| 219 | + expect(driver.disconnected).toBe(true); |
| 220 | + }); |
| 221 | + |
| 222 | + it('omits authToken entirely when none was resolved (no empty-string credential)', async () => { |
| 223 | + const { built, module } = stubTursoModule(); |
| 224 | + const factory = await loadTursoDriverFactory({ importDriverPackage: async () => module }); |
| 225 | + await factory.create({ name: 'default', driver: 'turso', config: { url: 'file:./data/local.db' } }); |
| 226 | + expect(built).toEqual([{ url: 'file:./data/local.db' }]); |
| 227 | + }); |
| 228 | + |
| 229 | + // ② Package absent: LOUD failure carrying the exact install command, and no |
| 230 | + // fallback of any kind. |
| 231 | + it('fails loudly with the exact install command when the package is missing', async () => { |
| 232 | + const err = await loadTursoDriverFactory({ |
| 233 | + importDriverPackage: async () => { throw new Error("Cannot find module '@objectstack/driver-turso'"); }, |
| 234 | + }).then(() => null, (e: unknown) => e); |
| 235 | + |
| 236 | + expect(err).toBeInstanceOf(MissingDriverPackageError); |
| 237 | + const missing = err as MissingDriverPackageError; |
| 238 | + expect(missing.driverType).toBe('turso'); |
| 239 | + expect(missing.packageName).toBe(TURSO_DRIVER_PACKAGE); |
| 240 | + expect(missing.installCommand).toBe(TURSO_DRIVER_INSTALL_COMMAND); |
| 241 | + expect(missing.installCommand).toBe('npm install @objectstack/driver-turso'); |
| 242 | + // The message states the command, the consequence, and the deliberate refusal. |
| 243 | + expect(missing.message).toContain('npm install @objectstack/driver-turso'); |
| 244 | + expect(missing.message).toMatch(/OPTIONAL package/); |
| 245 | + expect(missing.message).toMatch(/refuses rather than falling back to SQLite/i); |
| 246 | + expect(missing.message).toMatch(/os migrate/); |
| 247 | + // The underlying resolution error is kept: an operator debugging a broken |
| 248 | + // install needs it, and swallowing it is how "not installed" hides |
| 249 | + // "installed but crashed on import". |
| 250 | + expect(missing.message).toContain("Cannot find module '@objectstack/driver-turso'"); |
| 251 | + }); |
| 252 | + |
| 253 | + it('offers NO silent SQLite fallback when the package is missing', async () => { |
| 254 | + const attempt = await loadTursoDriverFactory({ |
| 255 | + importDriverPackage: async () => { throw new Error('boom'); }, |
| 256 | + }).then((f) => ({ ok: true as const, f }), (e: unknown) => ({ ok: false as const, e })); |
| 257 | + |
| 258 | + expect(attempt.ok).toBe(false); |
| 259 | + expect((attempt as { e: Error }).e).toBeInstanceOf(MissingDriverPackageError); |
| 260 | + expect((attempt as { e: Error }).e.message).not.toMatch(/falling back to sqlite instead|using sqlite/i); |
| 261 | + // …and the kind the stack resolved is still turso: nothing rewrites it to |
| 262 | + // sqlite on the way out. |
| 263 | + expect(resolveStandaloneDatabase({ databaseUrl: 'libsql://my-db.turso.io' }).driver).toBe('turso'); |
| 264 | + }); |
| 265 | + |
| 266 | + it('rejects a resolvable module that exports no TursoDriver', async () => { |
| 267 | + const err = await loadTursoDriverFactory({ |
| 268 | + importDriverPackage: async () => ({ notTheDriver: true }), |
| 269 | + }).then(() => null, (e: unknown) => e); |
| 270 | + expect(err).toBeInstanceOf(MissingDriverPackageError); |
| 271 | + expect((err as Error).message).toMatch(/exports no TursoDriver/); |
| 272 | + expect((err as MissingDriverPackageError).installCommand).toBe(TURSO_DRIVER_INSTALL_COMMAND); |
| 273 | + }); |
| 274 | + |
| 275 | + it('accepts a CJS-shaped module whose driver hangs off `default`', async () => { |
| 276 | + const { module } = stubTursoModule(); |
| 277 | + const factory = await loadTursoDriverFactory({ importDriverPackage: async () => ({ default: module }) }); |
| 278 | + const handle = await factory.create({ name: 'default', driver: 'turso', config: { url: 'libsql://x.turso.io' } }); |
| 279 | + expect(handle.driver).toBeInstanceOf(module.TursoDriver); |
| 280 | + }); |
| 281 | + |
| 282 | + it('refuses to build a driver from a config with no url', async () => { |
| 283 | + const { module } = stubTursoModule(); |
| 284 | + const factory = await loadTursoDriverFactory({ importDriverPackage: async () => module }); |
| 285 | + expect(() => factory.create({ name: 'default', driver: 'turso', config: {} })) |
| 286 | + .toThrow(/needs a libSQL url/); |
| 287 | + }); |
| 288 | +}); |
| 289 | + |
| 290 | +// ③ The whole boot, on the URL the issue is about. `@objectstack/driver-turso` |
| 291 | +// is deliberately NOT a dependency of `@objectstack/runtime` — that is what |
| 292 | +// "optional" means here — so in this workspace the boot takes the missing-package |
| 293 | +// arm. What matters either way is the FIRST assertion: the refusal is no longer |
| 294 | +// "unsupported scheme". (Should the package ever become a dependency of this one, |
| 295 | +// this case turns red and names exactly why in this comment.) |
| 296 | +describe('createStandaloneStack — a libsql:// boot is dispatched, not refused as unknown (#5820)', () => { |
| 297 | + it('fails with the install command instead of "Unsupported database URL scheme"', async () => { |
| 298 | + clearUrlEnv(); |
| 299 | + const err = await createStandaloneStack({ databaseUrl: 'libsql://my-db.turso.io' }) |
| 300 | + .then(() => null, (e: unknown) => e); |
| 301 | + |
| 302 | + expect(err).not.toBeNull(); |
| 303 | + expect(String((err as Error).message)).not.toMatch(/Unsupported database URL scheme/); |
| 304 | + expect(err).toBeInstanceOf(MissingDriverPackageError); |
| 305 | + expect((err as MissingDriverPackageError).installCommand).toBe(TURSO_DRIVER_INSTALL_COMMAND); |
| 306 | + }, 60_000); |
| 307 | + |
| 308 | + // The control on the same path: an unknown scheme is still refused as unknown, |
| 309 | + // so the new arm did not turn the throw into a catch-all. `os migrate`'s e2e |
| 310 | + // exit-code test pins this message from the CLI end. |
| 311 | + it('still refuses a genuinely unknown scheme', async () => { |
| 312 | + clearUrlEnv(); |
| 313 | + await expect(createStandaloneStack({ databaseUrl: 'wat://nope' })) |
| 314 | + .rejects.toThrow(/Unsupported database URL scheme/); |
| 315 | + }, 60_000); |
| 316 | +}); |
0 commit comments