diff --git a/.changeset/cli-memory-driver-dispatch.md b/.changeset/cli-memory-driver-dispatch.md new file mode 100644 index 0000000000..872a06dcfa --- /dev/null +++ b/.changeset/cli-memory-driver-dispatch.md @@ -0,0 +1,18 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): honor `OS_DATABASE_DRIVER=memory` (mingo InMemoryDriver) (#3276) + +`os dev` / `os start` / `os serve` advertised a `memory` database driver +(`--database-driver memory`, `OS_DATABASE_DRIVER=memory`, and a `memory://` +URL scheme), but `serve.ts`'s driver dispatch had no `memory` branch — so it +silently fell through to the dev SQLite `:memory:` default (SQLite-in-memory, +a *different* engine) or, in production, registered no driver at all. + +The driver kind-resolution + construction is now extracted into +`utils/storage-driver.ts` (unit-testable in isolation) with the missing +`memory` branch: selecting it yields the mingo `InMemoryDriver` in dev AND +production. The `memory://` / `mingo://` URL scheme is now recognized too, +kept distinct from sqlite's `:memory:` pseudo-file. Telemetry-datasource +provisioning behavior is unchanged. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 2f4fdd29d0..73f90a8f5d 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -8,6 +8,7 @@ import chalk from 'chalk'; import { bundleRequire } from 'bundle-require'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; +import { resolveDriverType, createStorageDriver } from '../utils/storage-driver.js'; import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types'; import { PLATFORM_CAPABILITY_TOKENS, canonicalizePlatformCapability } from '@objectstack/spec/kernel'; import { resolveObjectStackHome } from '@objectstack/runtime'; @@ -872,71 +873,48 @@ export default class Serve extends Command { // postgres://, postgresql:// → postgres // mysql://, mysql2:// → mysql // libsql://, http(s):// + .turso. → turso - // file:, sqlite:, *.db, :memory: → sqlite - // 3. Default: InMemoryDriver in dev mode + // wasm-sqlite://, *.wasm.db → sqlite-wasm + // memory://, mingo:// → memory (mingo InMemoryDriver) + // file:, sqlite:, *.db, :memory: → sqlite (SQLite's own in-memory mode) + // 3. Default: dev SQLite (native → wasm → in-memory step-down); prod none + // + // Kind-resolution and construction live in utils/storage-driver.ts so the + // whole dispatch is unit-testable (storage-driver.test.ts). #3276: the + // `memory` kind now maps to the mingo InMemoryDriver instead of silently + // falling through to the dev SQLite `:memory:` default. const hasDriver = plugins.some((p: any) => p.name?.includes('driver') || p.constructor?.name?.includes('Driver')); if (!hasDriver && config.objects) { - const explicitDriver = (process.env.OS_DATABASE_DRIVER ?? '').toLowerCase().trim(); const databaseUrl = process.env.OS_DATABASE_URL; - - const inferDriverFromUrl = (url: string | undefined): string => { - if (!url) return ''; - const u = url.trim(); - if (/^mongodb(\+srv)?:\/\//i.test(u)) return 'mongodb'; - if (/^postgres(ql)?:\/\//i.test(u)) return 'postgres'; - if (/^mysql2?:\/\//i.test(u)) return 'mysql'; - if (/^libsql:\/\//i.test(u)) return 'turso'; - if (/^https?:\/\//i.test(u) && /\.turso\./i.test(u)) return 'turso'; - if (/^wasm-sqlite:\/\//i.test(u) || /\.wasm\.db$/i.test(u)) return 'sqlite-wasm'; - if (/^file:/i.test(u) || /^sqlite:/i.test(u) || u === ':memory:' || /\.(db|sqlite|sqlite3)$/i.test(u)) return 'sqlite'; - return ''; - }; - - const driverType = explicitDriver || inferDriverFromUrl(databaseUrl); + const driverType = resolveDriverType(process.env.OS_DATABASE_DRIVER, databaseUrl); try { const { DriverPlugin } = await import('@objectstack/runtime'); - - if (driverType === 'mongodb' || driverType === 'mongo') { - const { MongoDBDriver } = await import('@objectstack/driver-mongodb'); - await kernel.use(new DriverPlugin(new MongoDBDriver({ - url: databaseUrl ?? 'mongodb://localhost:27017/objectstack', - }) as any)); - trackPlugin('MongoDBDriver'); - resolvedDriverLabel = 'MongoDBDriver'; - resolvedDatabaseUrl = databaseUrl ?? 'mongodb://localhost:27017/objectstack'; - } else if (driverType === 'sqlite' || driverType === 'sql') { - const filePath = (databaseUrl ?? ':memory:').replace(/^file:/, '').replace(/^sqlite:/, '').replace(/^sql:\/\//, ''); - // Probe-by-connect with a dev-only native → wasm → in-memory - // step-down (#2229). better-sqlite3 loads its native addon lazily - // (first query), so an ABI mismatch is invisible here and would - // otherwise surface much later as a runtime crash. resolveSqliteDriver - // forces the load and degrades gracefully in dev / fails loudly in prod. - const { resolveSqliteDriver } = await import('@objectstack/service-datasource'); - const resolved = await resolveSqliteDriver({ - filename: filePath, - dev: isDev, - // #2186: in dev, self-heal a persisted DB when a metadata change - // relaxes a constraint (loosen-only; never destructive / never in prod). - autoMigrate: isDev ? 'safe' : undefined, - warn: (m) => console.warn(chalk.yellow(m)), - }); - await kernel.use(new DriverPlugin(resolved.driver)); - trackPlugin(resolved.engine === 'memory' ? 'MemoryDriver' : resolved.engine === 'sqlite-wasm' ? 'SqliteWasmDriver' : 'SqlDriver'); - resolvedDriverLabel = resolved.label; - resolvedDatabaseUrl = resolved.engine === 'memory' ? '(in-memory)' : (databaseUrl ?? ':memory:'); + const resolution = await createStorageDriver(driverType, { + databaseUrl, + isDev, + warn: (m) => console.warn(chalk.yellow(m)), + }); + if (resolution) { + await kernel.use(new DriverPlugin(resolution.driver as any)); + trackPlugin(resolution.trackName); + resolvedDriverLabel = resolution.label; + resolvedDatabaseUrl = resolution.displayUrl; // ADR-0057 §3.6 (#2834 ②): provision the dedicated `telemetry` // datasource — a sibling SQLite file the engine routes every // telemetry/event/audit-classed object to, so platform-generated // growth can never again bloat the business DB. Dev default-on // for file-backed primaries; `OS_TELEMETRY_DB=0` opts out, - // `OS_TELEMETRY_DB=` opts in anywhere (incl. serve). - if (resolved.engine !== 'memory') { + // `OS_TELEMETRY_DB=` opts in anywhere (incl. serve). Gated on + // an explicit SQLite primary (`sqliteFilePath`, unset for the mingo + // memory driver AND the dev-default `:memory:` store) whose resolved + // engine is real SQLite — never mingo in-memory. + if (resolution.sqliteFilePath && resolution.engine !== 'memory') { const { resolveTelemetryDbPath } = await import('../utils/telemetry-datasource.js'); - const telemetryPath = resolveTelemetryDbPath({ primaryPath: filePath, env: process.env, dev: isDev }); + const telemetryPath = resolveTelemetryDbPath({ primaryPath: resolution.sqliteFilePath, env: process.env, dev: isDev }); if (telemetryPath) { try { + const { resolveSqliteDriver } = await import('@objectstack/service-datasource'); const telemetry = await resolveSqliteDriver({ filename: telemetryPath, dev: isDev, @@ -957,59 +935,6 @@ export default class Serve extends Command { } } } - } else if (driverType === 'sqlite-wasm' || driverType === 'wasm-sqlite' || driverType === 'wasm') { - const { SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm'); - const filePath = (databaseUrl ?? ':memory:').replace(/^file:/, '').replace(/^wasm-sqlite:\/\//, '').replace(/^sqlite:/, ''); - await kernel.use(new DriverPlugin(new SqliteWasmDriver({ - filename: filePath, - persist: 'on-disconnect', - }) as any)); - trackPlugin('SqliteWasmDriver'); - resolvedDriverLabel = 'SqliteWasmDriver'; - resolvedDatabaseUrl = databaseUrl ?? ':memory:'; - } else if (driverType === 'postgres' || driverType === 'postgresql' || driverType === 'pg') { - const { SqlDriver } = await import('@objectstack/driver-sql'); - await kernel.use(new DriverPlugin(new SqlDriver({ - client: 'pg', - connection: databaseUrl, - pool: { min: 0, max: 5 }, - autoMigrate: isDev ? 'safe' : undefined, // #2186 dev loosen-only self-heal - }) as any)); - trackPlugin('PostgresDriver'); - resolvedDriverLabel = 'SqlDriver(pg)'; - resolvedDatabaseUrl = databaseUrl; - } else if (driverType === 'mysql' || driverType === 'mysql2') { - const { SqlDriver } = await import('@objectstack/driver-sql'); - await kernel.use(new DriverPlugin(new SqlDriver({ - client: 'mysql2', - connection: databaseUrl, - pool: { min: 0, max: 5 }, - autoMigrate: isDev ? 'safe' : undefined, // #2186 dev loosen-only self-heal - }) as any)); - trackPlugin('MySQLDriver'); - resolvedDriverLabel = 'SqlDriver(mysql2)'; - resolvedDatabaseUrl = databaseUrl; - } else if (isDev) { - // Default in dev (no DB configured): prefer native SQLite for - // production-like SQL at native speed, with a graceful step-down to - // wasm SQLite (real SQL + on-disk persistence) then in-memory when the - // native better-sqlite3 binary is unavailable — not built, ABI mismatch - // after a Node upgrade (e.g. NODE_MODULE_VERSION change), or a blocked - // prebuild download. Shared with the explicit-file branch and the - // datasource factory via resolveSqliteDriver (#2229), which probes by - // actually opening a connection + running SELECT 1 (better-sqlite3 loads - // its native addon lazily at first query, not at construction). - const { resolveSqliteDriver } = await import('@objectstack/service-datasource'); - const resolved = await resolveSqliteDriver({ - filename: ':memory:', - dev: true, - autoMigrate: 'safe', // #2186 dev loosen-only self-heal - warn: (m) => console.warn(chalk.yellow(m)), - }); - await kernel.use(new DriverPlugin(resolved.driver)); - trackPlugin(resolved.engine === 'memory' ? 'MemoryDriver' : resolved.engine === 'sqlite-wasm' ? 'SqliteWasmDriver' : 'SqlDriver'); - resolvedDriverLabel = resolved.label; - resolvedDatabaseUrl = resolved.engine === 'memory' ? '(in-memory)' : ':memory:'; } } catch (e: any) { // silent diff --git a/packages/cli/src/utils/storage-driver.test.ts b/packages/cli/src/utils/storage-driver.test.ts new file mode 100644 index 0000000000..0d3e20c098 --- /dev/null +++ b/packages/cli/src/utils/storage-driver.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { + inferDriverTypeFromUrl, + resolveDriverType, + createStorageDriver, +} from './storage-driver.js'; + +describe('inferDriverTypeFromUrl', () => { + it('maps each recognized URL scheme to its canonical driver kind', () => { + expect(inferDriverTypeFromUrl('mongodb://localhost:27017/db')).toBe('mongodb'); + expect(inferDriverTypeFromUrl('mongodb+srv://cluster/db')).toBe('mongodb'); + expect(inferDriverTypeFromUrl('postgres://u:p@h/db')).toBe('postgres'); + expect(inferDriverTypeFromUrl('postgresql://u:p@h/db')).toBe('postgres'); + expect(inferDriverTypeFromUrl('mysql://u:p@h/db')).toBe('mysql'); + expect(inferDriverTypeFromUrl('mysql2://u:p@h/db')).toBe('mysql'); + expect(inferDriverTypeFromUrl('libsql://x.turso.io')).toBe('turso'); + expect(inferDriverTypeFromUrl('https://x.turso.io')).toBe('turso'); + expect(inferDriverTypeFromUrl('wasm-sqlite://data.db')).toBe('sqlite-wasm'); + expect(inferDriverTypeFromUrl('./local.wasm.db')).toBe('sqlite-wasm'); + expect(inferDriverTypeFromUrl('file:./app.db')).toBe('sqlite'); + expect(inferDriverTypeFromUrl('sqlite:./app.db')).toBe('sqlite'); + expect(inferDriverTypeFromUrl('./app.sqlite')).toBe('sqlite'); + }); + + // #3276: the mingo in-memory engine has its own `memory://` URL scheme. + it('maps the memory:// (and mingo://) scheme to the mingo `memory` kind', () => { + expect(inferDriverTypeFromUrl('memory://')).toBe('memory'); + expect(inferDriverTypeFromUrl('memory://ignored-host')).toBe('memory'); + expect(inferDriverTypeFromUrl('mingo://')).toBe('memory'); + }); + + // The sqlite `:memory:` PSEUDO-FILE is SQLite's own in-memory mode — NOT the + // mingo engine. It must stay `sqlite`, distinct from the `memory://` scheme. + it('keeps sqlite `:memory:` mapped to sqlite (distinct from memory://)', () => { + expect(inferDriverTypeFromUrl(':memory:')).toBe('sqlite'); + }); + + it('returns "" for an absent or unrecognized URL', () => { + expect(inferDriverTypeFromUrl(undefined)).toBe(''); + expect(inferDriverTypeFromUrl('')).toBe(''); + expect(inferDriverTypeFromUrl('redis://localhost')).toBe(''); + }); +}); + +describe('resolveDriverType', () => { + it('lets an explicit driver win over URL inference (and normalizes case/space)', () => { + expect(resolveDriverType('memory', 'postgres://h/db')).toBe('memory'); + expect(resolveDriverType(' MEMORY ', undefined)).toBe('memory'); + expect(resolveDriverType('Postgres', 'mongodb://h/db')).toBe('postgres'); + }); + + it('falls back to URL inference when no explicit driver is set', () => { + expect(resolveDriverType(undefined, 'mongodb://h/db')).toBe('mongodb'); + expect(resolveDriverType('', 'memory://')).toBe('memory'); + expect(resolveDriverType(' ', undefined)).toBe(''); + }); +}); + +describe('createStorageDriver', () => { + // ── #3276: the regression this whole change exists to fix ────────────────── + // `memory` must construct the mingo InMemoryDriver — NOT fall through to the + // dev SQLite `:memory:` default (SQLite-in-memory, a different engine). Remove + // the `memory` branch in storage-driver.ts and this assertion goes red: in dev + // it resolves to a SqlDriver/SQLite engine, in prod it resolves to null. + it('constructs the mingo InMemoryDriver for `memory` in DEV', async () => { + const r = await createStorageDriver('memory', { isDev: true }); + expect(r).not.toBeNull(); + expect(r!.driver).toBeInstanceOf(InMemoryDriver); + expect(r!.engine).toBe('memory'); + expect(r!.label).toBe('InMemoryDriver'); + expect(r!.trackName).toBe('MemoryDriver'); + expect(r!.displayUrl).toBe('(in-memory)'); + // Never provisions a telemetry sibling. + expect(r!.sqliteFilePath).toBeUndefined(); + }); + + // The explicit choice is honored in PRODUCTION too — declared === enforced. + it('constructs the mingo InMemoryDriver for `memory` in PROD', async () => { + const r = await createStorageDriver('memory', { isDev: false }); + expect(r!.driver).toBeInstanceOf(InMemoryDriver); + expect(r!.engine).toBe('memory'); + }); + + it('accepts the `mingo` and `in-memory` aliases', async () => { + expect((await createStorageDriver('mingo', { isDev: false }))!.driver).toBeInstanceOf(InMemoryDriver); + expect((await createStorageDriver('in-memory', { isDev: false }))!.driver).toBeInstanceOf(InMemoryDriver); + }); + + // ── Regression guards for the other branches (no connection is opened) ───── + it('constructs mongodb with the default URL when none is supplied', async () => { + const r = await createStorageDriver('mongodb', { isDev: false }); + expect(r!.label).toBe('MongoDBDriver'); + expect(r!.trackName).toBe('MongoDBDriver'); + expect(r!.displayUrl).toBe('mongodb://localhost:27017/objectstack'); + }); + + it('constructs postgres / mysql with their SqlDriver labels', async () => { + const pg = await createStorageDriver('postgres', { databaseUrl: 'postgres://u:p@h/db', isDev: false }); + expect(pg!.label).toBe('SqlDriver(pg)'); + expect(pg!.trackName).toBe('PostgresDriver'); + const my = await createStorageDriver('mysql', { databaseUrl: 'mysql://u:p@h/db', isDev: false }); + expect(my!.label).toBe('SqlDriver(mysql2)'); + expect(my!.trackName).toBe('MySQLDriver'); + }); + + it('constructs sqlite-wasm without connecting', async () => { + const r = await createStorageDriver('sqlite-wasm', { databaseUrl: 'file:./x.db', isDev: false }); + expect(r!.label).toBe('SqliteWasmDriver'); + expect(r!.trackName).toBe('SqliteWasmDriver'); + }); + + // In PROD, `resolveSqliteDriver` returns the native driver UNPROBED (no + // connect), so this is fast and native-addon-free. It also documents that an + // explicit sqlite primary DOES surface `sqliteFilePath` for the telemetry + // sibling — the field the `memory` driver deliberately leaves unset. + it('constructs explicit sqlite and surfaces sqliteFilePath for telemetry', async () => { + const r = await createStorageDriver('sqlite', { databaseUrl: ':memory:', isDev: false }); + expect(r!.engine).toBe('better-sqlite3'); + expect(r!.label).toBe('SqlDriver(sqlite)'); + expect(r!.trackName).toBe('SqlDriver'); + expect(r!.sqliteFilePath).toBe(':memory:'); + }); + + // Production with no driver configured registers nothing (loud downstream + // failure), rather than silently inventing an engine. + it('returns null for an unknown/absent driver in PROD', async () => { + expect(await createStorageDriver('', { isDev: false })).toBeNull(); + expect(await createStorageDriver('nonsense', { isDev: false })).toBeNull(); + }); +}); diff --git a/packages/cli/src/utils/storage-driver.ts b/packages/cli/src/utils/storage-driver.ts new file mode 100644 index 0000000000..48bbc2d02e --- /dev/null +++ b/packages/cli/src/utils/storage-driver.ts @@ -0,0 +1,256 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Storage-driver resolution for `objectstack serve`. + * + * Extracted from serve.ts so the driver dispatch is unit-testable in isolation + * (mirrors utils/telemetry-datasource.ts and utils/plugin-detection.ts). Two + * concerns: + * + * 1. {@link resolveDriverType} — pick a canonical driver KIND from the + * explicit `OS_DATABASE_DRIVER` override plus the `OS_DATABASE_URL` scheme. + * 2. {@link createStorageDriver} — construct the concrete driver instance for + * that kind. Driver packages are dynamically imported so the CLI carries no + * static dependency on any one of them. + * + * ## #3276 — the `memory` driver was advertised but had no dispatch branch + * + * `os dev` / `os start` / `os serve` all advertise a `memory` driver + * (`--database-driver memory`, `OS_DATABASE_DRIVER=memory`, and a `memory://` + * URL scheme). But the old inline dispatch had no `memory` case, so selecting it + * silently fell through to the dev SQLite `:memory:` default — SQLite-in-memory, + * a *different* engine — or, in production, registered no driver at all. The + * URL-inference and construction branches below close that "declared ≠ enforced" + * gap: `memory` now yields the mingo `InMemoryDriver`, in dev AND production, + * exactly as requested. + * + * Note the deliberate distinction from SQLite's own `:memory:` pseudo-file: + * `OS_DATABASE_URL=:memory:` stays `sqlite` (SQLite's in-memory mode), whereas + * the `memory://` scheme and the `memory` driver select the mingo engine. + */ + +/** Engines the shared sqlite step-down (`resolveSqliteDriver`) can produce. */ +export type SqliteFamilyEngine = 'better-sqlite3' | 'sqlite-wasm' | 'memory'; + +/** + * Infer a canonical driver kind from an `OS_DATABASE_URL` scheme. + * Returns `''` when the URL is absent or its scheme is unrecognized (the caller + * then falls back to the dev default / registers nothing in production). + */ +export function inferDriverTypeFromUrl(url: string | undefined): string { + if (!url) return ''; + const u = url.trim(); + if (/^mongodb(\+srv)?:\/\//i.test(u)) return 'mongodb'; + if (/^postgres(ql)?:\/\//i.test(u)) return 'postgres'; + if (/^mysql2?:\/\//i.test(u)) return 'mysql'; + if (/^libsql:\/\//i.test(u)) return 'turso'; + if (/^https?:\/\//i.test(u) && /\.turso\./i.test(u)) return 'turso'; + if (/^wasm-sqlite:\/\//i.test(u) || /\.wasm\.db$/i.test(u)) return 'sqlite-wasm'; + // #3276: the mingo in-memory engine has its own URL scheme (`memory://`, + // advertised in `os dev` / `os start` help). Kept ABOVE the sqlite test so it + // is not shadowed, and deliberately distinct from sqlite's `:memory:` + // pseudo-file below (which stays SQLite's own in-memory mode). + if (/^(memory|mingo):\/\//i.test(u)) return 'memory'; + if (/^file:/i.test(u) || /^sqlite:/i.test(u) || u === ':memory:' || /\.(db|sqlite|sqlite3)$/i.test(u)) return 'sqlite'; + return ''; +} + +/** + * Combine the explicit `OS_DATABASE_DRIVER` override with URL-scheme inference + * into the canonical driver kind. An explicit driver always wins over the URL. + */ +export function resolveDriverType( + explicitDriver: string | undefined, + databaseUrl: string | undefined, +): string { + const explicit = (explicitDriver ?? '').toLowerCase().trim(); + return explicit || inferDriverTypeFromUrl(databaseUrl); +} + +export interface CreateStorageDriverOptions { + /** Raw `OS_DATABASE_URL` (scheme stripped per-branch as before). */ + databaseUrl?: string; + /** Dev mode — enables the sqlite native→wasm→in-memory step-down + loosen-only auto-migrate. */ + isDev: boolean; + /** Warning sink for the sqlite step-down banners (serve.ts wraps in `chalk.yellow`). */ + warn?: (message: string) => void; +} + +export interface StorageDriverResolution { + /** The concrete driver instance to wrap in `DriverPlugin` and register. */ + driver: unknown; + /** Short name for the boot banner's plugin list (serve.ts `trackPlugin`). */ + trackName: string; + /** Human label for the startup banner's "driver" row. */ + label: string; + /** Display-shaped database URL for the startup banner (e.g. `(in-memory)`). */ + displayUrl: string | undefined; + /** sqlite-family resolved engine, else undefined. Keys the telemetry-sibling guard. */ + engine?: SqliteFamilyEngine; + /** + * On-disk sqlite path the telemetry datasource is provisioned next to. Set + * ONLY for the explicit `sqlite`/`sql` driver — never the dev-default + * `:memory:` path — so serve.ts provisions the telemetry sibling exactly where + * it did before this extraction. + */ + sqliteFilePath?: string; +} + +/** + * Construct the storage driver for a canonical driver kind. Returns `null` when + * nothing matches and we are NOT in dev (production with an unknown/absent + * driver registers no driver, matching the prior inline behavior). + * + * @see {@link resolveDriverType} + */ +export async function createStorageDriver( + driverType: string, + opts: CreateStorageDriverOptions, +): Promise { + const { databaseUrl, isDev } = opts; + const warn = + opts.warn ?? + ((message: string) => { + try { + console.warn(message); + } catch { + /* ignore */ + } + }); + + if (driverType === 'mongodb' || driverType === 'mongo') { + const { MongoDBDriver } = await import('@objectstack/driver-mongodb'); + const url = databaseUrl ?? 'mongodb://localhost:27017/objectstack'; + return { + driver: new MongoDBDriver({ url }) as any, + trackName: 'MongoDBDriver', + label: 'MongoDBDriver', + displayUrl: url, + }; + } + + if (driverType === 'sqlite' || driverType === 'sql') { + const filePath = (databaseUrl ?? ':memory:') + .replace(/^file:/, '') + .replace(/^sqlite:/, '') + .replace(/^sql:\/\//, ''); + // Probe-by-connect with a dev-only native → wasm → in-memory step-down + // (#2229). better-sqlite3 loads its native addon lazily (first query), so an + // ABI mismatch is invisible here and would otherwise surface much later as a + // runtime crash. resolveSqliteDriver forces the load and degrades gracefully + // in dev / fails loudly in prod. + const { resolveSqliteDriver } = await import('@objectstack/service-datasource'); + const resolved = await resolveSqliteDriver({ + filename: filePath, + dev: isDev, + // #2186: in dev, self-heal a persisted DB when a metadata change relaxes a + // constraint (loosen-only; never destructive / never in prod). + autoMigrate: isDev ? 'safe' : undefined, + warn, + }); + return { + driver: resolved.driver, + trackName: + resolved.engine === 'memory' + ? 'MemoryDriver' + : resolved.engine === 'sqlite-wasm' + ? 'SqliteWasmDriver' + : 'SqlDriver', + label: resolved.label, + displayUrl: resolved.engine === 'memory' ? '(in-memory)' : (databaseUrl ?? ':memory:'), + engine: resolved.engine, + sqliteFilePath: filePath, + }; + } + + if (driverType === 'sqlite-wasm' || driverType === 'wasm-sqlite' || driverType === 'wasm') { + const { SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm'); + const filePath = (databaseUrl ?? ':memory:') + .replace(/^file:/, '') + .replace(/^wasm-sqlite:\/\//, '') + .replace(/^sqlite:/, ''); + return { + driver: new SqliteWasmDriver({ filename: filePath, persist: 'on-disconnect' }) as any, + trackName: 'SqliteWasmDriver', + label: 'SqliteWasmDriver', + displayUrl: databaseUrl ?? ':memory:', + }; + } + + if (driverType === 'postgres' || driverType === 'postgresql' || driverType === 'pg') { + const { SqlDriver } = await import('@objectstack/driver-sql'); + return { + driver: new SqlDriver({ + client: 'pg', + connection: databaseUrl, + pool: { min: 0, max: 5 }, + autoMigrate: isDev ? 'safe' : undefined, // #2186 dev loosen-only self-heal + }) as any, + trackName: 'PostgresDriver', + label: 'SqlDriver(pg)', + displayUrl: databaseUrl, + }; + } + + if (driverType === 'mysql' || driverType === 'mysql2') { + const { SqlDriver } = await import('@objectstack/driver-sql'); + return { + driver: new SqlDriver({ + client: 'mysql2', + connection: databaseUrl, + pool: { min: 0, max: 5 }, + autoMigrate: isDev ? 'safe' : undefined, // #2186 dev loosen-only self-heal + }) as any, + trackName: 'MySQLDriver', + label: 'SqlDriver(mysql2)', + displayUrl: databaseUrl, + }; + } + + // #3276: explicit in-memory (mingo) driver. Honored in dev AND production — an + // operator asking for `memory` gets the mingo InMemoryDriver (ephemeral, not + // real SQL), never the SQLite `:memory:` default. This is the branch whose + // absence caused the reported "declared ≠ enforced" fall-through to SQLite. + if (driverType === 'memory' || driverType === 'mingo' || driverType === 'in-memory') { + const { InMemoryDriver } = await import('@objectstack/driver-memory'); + return { + driver: new InMemoryDriver(), + trackName: 'MemoryDriver', + label: 'InMemoryDriver', + displayUrl: '(in-memory)', + engine: 'memory', + }; + } + + // Default (no driver configured): dev prefers native SQLite for production-like + // SQL at native speed, with a graceful step-down to wasm SQLite then in-memory + // when the native better-sqlite3 binary is unavailable (#2229). Production + // registers nothing here so a missing driver surfaces loudly downstream. + if (isDev) { + const { resolveSqliteDriver } = await import('@objectstack/service-datasource'); + const resolved = await resolveSqliteDriver({ + filename: ':memory:', + dev: true, + autoMigrate: 'safe', // #2186 dev loosen-only self-heal + warn, + }); + return { + driver: resolved.driver, + trackName: + resolved.engine === 'memory' + ? 'MemoryDriver' + : resolved.engine === 'sqlite-wasm' + ? 'SqliteWasmDriver' + : 'SqlDriver', + label: resolved.label, + displayUrl: resolved.engine === 'memory' ? '(in-memory)' : ':memory:', + engine: resolved.engine, + // No sqliteFilePath: the dev-default `:memory:` store never gets a + // telemetry sibling (resolveTelemetryDbPath returns undefined for it), so + // leaving this unset keeps serve.ts from provisioning one — matching the + // pre-extraction behavior where this branch never touched telemetry. + }; + } + + return null; +}