Skip to content

Commit bfe8247

Browse files
os-zhuangclaude
andcommitted
fix(cli): dispatch OS_DATABASE_DRIVER=memory to the mingo InMemoryDriver (#3276)
`os dev` / `os start` / `os serve` all advertise 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 selecting it silently fell through to the dev SQLite `:memory:` default (SQLite-in-memory — a different engine), or registered no driver at all in production. Declared ≠ enforced. Fix + refactor: - Extract the driver kind-resolution and construction out of serve.ts's inline if/else chain into utils/storage-driver.ts, mirroring the existing utils/telemetry-datasource.ts / utils/plugin-detection.ts pattern, so the whole dispatch is unit-testable in isolation. - Add the missing `memory` (aliases `mingo`, `in-memory`) branch that builds the mingo InMemoryDriver, honored in dev AND production. - Recognize the advertised `memory://` / `mingo://` URL scheme in inferDriverTypeFromUrl (distinct from sqlite's `:memory:` pseudo-file, which stays SQLite's own in-memory mode). - serve.ts telemetry-datasource provisioning behavior is preserved exactly (keyed on an explicit-sqlite primary path; never the memory driver). Tests (storage-driver.test.ts) cover the full URL→kind mapping, explicit>URL precedence, and every construction branch; the `memory` assertions go red without the new branch (verified: dev→SqlDriver, prod→null). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2271f3e commit bfe8247

3 files changed

Lines changed: 417 additions & 103 deletions

File tree

packages/cli/src/commands/serve.ts

Lines changed: 28 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import chalk from 'chalk';
88
import { bundleRequire } from 'bundle-require';
99
import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
1010
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
11+
import { resolveDriverType, createStorageDriver } from '../utils/storage-driver.js';
1112
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled } from '@objectstack/types';
1213
import { resolveObjectStackHome } from '@objectstack/runtime';
1314
import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js';
@@ -631,71 +632,48 @@ export default class Serve extends Command {
631632
// postgres://, postgresql:// → postgres
632633
// mysql://, mysql2:// → mysql
633634
// libsql://, http(s):// + .turso. → turso
634-
// file:, sqlite:, *.db, :memory: → sqlite
635-
// 3. Default: InMemoryDriver in dev mode
635+
// wasm-sqlite://, *.wasm.db → sqlite-wasm
636+
// memory://, mingo:// → memory (mingo InMemoryDriver)
637+
// file:, sqlite:, *.db, :memory: → sqlite (SQLite's own in-memory mode)
638+
// 3. Default: dev SQLite (native → wasm → in-memory step-down); prod none
639+
//
640+
// Kind-resolution and construction live in utils/storage-driver.ts so the
641+
// whole dispatch is unit-testable (storage-driver.test.ts). #3276: the
642+
// `memory` kind now maps to the mingo InMemoryDriver instead of silently
643+
// falling through to the dev SQLite `:memory:` default.
636644
const hasDriver = plugins.some((p: any) => p.name?.includes('driver') || p.constructor?.name?.includes('Driver'));
637645
if (!hasDriver && config.objects) {
638-
const explicitDriver = (process.env.OS_DATABASE_DRIVER ?? '').toLowerCase().trim();
639646
const databaseUrl = process.env.OS_DATABASE_URL;
640-
641-
const inferDriverFromUrl = (url: string | undefined): string => {
642-
if (!url) return '';
643-
const u = url.trim();
644-
if (/^mongodb(\+srv)?:\/\//i.test(u)) return 'mongodb';
645-
if (/^postgres(ql)?:\/\//i.test(u)) return 'postgres';
646-
if (/^mysql2?:\/\//i.test(u)) return 'mysql';
647-
if (/^libsql:\/\//i.test(u)) return 'turso';
648-
if (/^https?:\/\//i.test(u) && /\.turso\./i.test(u)) return 'turso';
649-
if (/^wasm-sqlite:\/\//i.test(u) || /\.wasm\.db$/i.test(u)) return 'sqlite-wasm';
650-
if (/^file:/i.test(u) || /^sqlite:/i.test(u) || u === ':memory:' || /\.(db|sqlite|sqlite3)$/i.test(u)) return 'sqlite';
651-
return '';
652-
};
653-
654-
const driverType = explicitDriver || inferDriverFromUrl(databaseUrl);
647+
const driverType = resolveDriverType(process.env.OS_DATABASE_DRIVER, databaseUrl);
655648

656649
try {
657650
const { DriverPlugin } = await import('@objectstack/runtime');
658-
659-
if (driverType === 'mongodb' || driverType === 'mongo') {
660-
const { MongoDBDriver } = await import('@objectstack/driver-mongodb');
661-
await kernel.use(new DriverPlugin(new MongoDBDriver({
662-
url: databaseUrl ?? 'mongodb://localhost:27017/objectstack',
663-
}) as any));
664-
trackPlugin('MongoDBDriver');
665-
resolvedDriverLabel = 'MongoDBDriver';
666-
resolvedDatabaseUrl = databaseUrl ?? 'mongodb://localhost:27017/objectstack';
667-
} else if (driverType === 'sqlite' || driverType === 'sql') {
668-
const filePath = (databaseUrl ?? ':memory:').replace(/^file:/, '').replace(/^sqlite:/, '').replace(/^sql:\/\//, '');
669-
// Probe-by-connect with a dev-only native → wasm → in-memory
670-
// step-down (#2229). better-sqlite3 loads its native addon lazily
671-
// (first query), so an ABI mismatch is invisible here and would
672-
// otherwise surface much later as a runtime crash. resolveSqliteDriver
673-
// forces the load and degrades gracefully in dev / fails loudly in prod.
674-
const { resolveSqliteDriver } = await import('@objectstack/service-datasource');
675-
const resolved = await resolveSqliteDriver({
676-
filename: filePath,
677-
dev: isDev,
678-
// #2186: in dev, self-heal a persisted DB when a metadata change
679-
// relaxes a constraint (loosen-only; never destructive / never in prod).
680-
autoMigrate: isDev ? 'safe' : undefined,
681-
warn: (m) => console.warn(chalk.yellow(m)),
682-
});
683-
await kernel.use(new DriverPlugin(resolved.driver));
684-
trackPlugin(resolved.engine === 'memory' ? 'MemoryDriver' : resolved.engine === 'sqlite-wasm' ? 'SqliteWasmDriver' : 'SqlDriver');
685-
resolvedDriverLabel = resolved.label;
686-
resolvedDatabaseUrl = resolved.engine === 'memory' ? '(in-memory)' : (databaseUrl ?? ':memory:');
651+
const resolution = await createStorageDriver(driverType, {
652+
databaseUrl,
653+
isDev,
654+
warn: (m) => console.warn(chalk.yellow(m)),
655+
});
656+
if (resolution) {
657+
await kernel.use(new DriverPlugin(resolution.driver as any));
658+
trackPlugin(resolution.trackName);
659+
resolvedDriverLabel = resolution.label;
660+
resolvedDatabaseUrl = resolution.displayUrl;
687661

688662
// ADR-0057 §3.6 (#2834 ②): provision the dedicated `telemetry`
689663
// datasource — a sibling SQLite file the engine routes every
690664
// telemetry/event/audit-classed object to, so platform-generated
691665
// growth can never again bloat the business DB. Dev default-on
692666
// for file-backed primaries; `OS_TELEMETRY_DB=0` opts out,
693-
// `OS_TELEMETRY_DB=<path>` opts in anywhere (incl. serve).
694-
if (resolved.engine !== 'memory') {
667+
// `OS_TELEMETRY_DB=<path>` opts in anywhere (incl. serve). Gated on
668+
// an explicit SQLite primary (`sqliteFilePath`, unset for the mingo
669+
// memory driver AND the dev-default `:memory:` store) whose resolved
670+
// engine is real SQLite — never mingo in-memory.
671+
if (resolution.sqliteFilePath && resolution.engine !== 'memory') {
695672
const { resolveTelemetryDbPath } = await import('../utils/telemetry-datasource.js');
696-
const telemetryPath = resolveTelemetryDbPath({ primaryPath: filePath, env: process.env, dev: isDev });
673+
const telemetryPath = resolveTelemetryDbPath({ primaryPath: resolution.sqliteFilePath, env: process.env, dev: isDev });
697674
if (telemetryPath) {
698675
try {
676+
const { resolveSqliteDriver } = await import('@objectstack/service-datasource');
699677
const telemetry = await resolveSqliteDriver({
700678
filename: telemetryPath,
701679
dev: isDev,
@@ -716,59 +694,6 @@ export default class Serve extends Command {
716694
}
717695
}
718696
}
719-
} else if (driverType === 'sqlite-wasm' || driverType === 'wasm-sqlite' || driverType === 'wasm') {
720-
const { SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm');
721-
const filePath = (databaseUrl ?? ':memory:').replace(/^file:/, '').replace(/^wasm-sqlite:\/\//, '').replace(/^sqlite:/, '');
722-
await kernel.use(new DriverPlugin(new SqliteWasmDriver({
723-
filename: filePath,
724-
persist: 'on-disconnect',
725-
}) as any));
726-
trackPlugin('SqliteWasmDriver');
727-
resolvedDriverLabel = 'SqliteWasmDriver';
728-
resolvedDatabaseUrl = databaseUrl ?? ':memory:';
729-
} else if (driverType === 'postgres' || driverType === 'postgresql' || driverType === 'pg') {
730-
const { SqlDriver } = await import('@objectstack/driver-sql');
731-
await kernel.use(new DriverPlugin(new SqlDriver({
732-
client: 'pg',
733-
connection: databaseUrl,
734-
pool: { min: 0, max: 5 },
735-
autoMigrate: isDev ? 'safe' : undefined, // #2186 dev loosen-only self-heal
736-
}) as any));
737-
trackPlugin('PostgresDriver');
738-
resolvedDriverLabel = 'SqlDriver(pg)';
739-
resolvedDatabaseUrl = databaseUrl;
740-
} else if (driverType === 'mysql' || driverType === 'mysql2') {
741-
const { SqlDriver } = await import('@objectstack/driver-sql');
742-
await kernel.use(new DriverPlugin(new SqlDriver({
743-
client: 'mysql2',
744-
connection: databaseUrl,
745-
pool: { min: 0, max: 5 },
746-
autoMigrate: isDev ? 'safe' : undefined, // #2186 dev loosen-only self-heal
747-
}) as any));
748-
trackPlugin('MySQLDriver');
749-
resolvedDriverLabel = 'SqlDriver(mysql2)';
750-
resolvedDatabaseUrl = databaseUrl;
751-
} else if (isDev) {
752-
// Default in dev (no DB configured): prefer native SQLite for
753-
// production-like SQL at native speed, with a graceful step-down to
754-
// wasm SQLite (real SQL + on-disk persistence) then in-memory when the
755-
// native better-sqlite3 binary is unavailable — not built, ABI mismatch
756-
// after a Node upgrade (e.g. NODE_MODULE_VERSION change), or a blocked
757-
// prebuild download. Shared with the explicit-file branch and the
758-
// datasource factory via resolveSqliteDriver (#2229), which probes by
759-
// actually opening a connection + running SELECT 1 (better-sqlite3 loads
760-
// its native addon lazily at first query, not at construction).
761-
const { resolveSqliteDriver } = await import('@objectstack/service-datasource');
762-
const resolved = await resolveSqliteDriver({
763-
filename: ':memory:',
764-
dev: true,
765-
autoMigrate: 'safe', // #2186 dev loosen-only self-heal
766-
warn: (m) => console.warn(chalk.yellow(m)),
767-
});
768-
await kernel.use(new DriverPlugin(resolved.driver));
769-
trackPlugin(resolved.engine === 'memory' ? 'MemoryDriver' : resolved.engine === 'sqlite-wasm' ? 'SqliteWasmDriver' : 'SqlDriver');
770-
resolvedDriverLabel = resolved.label;
771-
resolvedDatabaseUrl = resolved.engine === 'memory' ? '(in-memory)' : ':memory:';
772697
}
773698
} catch (e: any) {
774699
// silent
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { InMemoryDriver } from '@objectstack/driver-memory';
5+
import {
6+
inferDriverTypeFromUrl,
7+
resolveDriverType,
8+
createStorageDriver,
9+
} from './storage-driver.js';
10+
11+
describe('inferDriverTypeFromUrl', () => {
12+
it('maps each recognized URL scheme to its canonical driver kind', () => {
13+
expect(inferDriverTypeFromUrl('mongodb://localhost:27017/db')).toBe('mongodb');
14+
expect(inferDriverTypeFromUrl('mongodb+srv://cluster/db')).toBe('mongodb');
15+
expect(inferDriverTypeFromUrl('postgres://u:p@h/db')).toBe('postgres');
16+
expect(inferDriverTypeFromUrl('postgresql://u:p@h/db')).toBe('postgres');
17+
expect(inferDriverTypeFromUrl('mysql://u:p@h/db')).toBe('mysql');
18+
expect(inferDriverTypeFromUrl('mysql2://u:p@h/db')).toBe('mysql');
19+
expect(inferDriverTypeFromUrl('libsql://x.turso.io')).toBe('turso');
20+
expect(inferDriverTypeFromUrl('https://x.turso.io')).toBe('turso');
21+
expect(inferDriverTypeFromUrl('wasm-sqlite://data.db')).toBe('sqlite-wasm');
22+
expect(inferDriverTypeFromUrl('./local.wasm.db')).toBe('sqlite-wasm');
23+
expect(inferDriverTypeFromUrl('file:./app.db')).toBe('sqlite');
24+
expect(inferDriverTypeFromUrl('sqlite:./app.db')).toBe('sqlite');
25+
expect(inferDriverTypeFromUrl('./app.sqlite')).toBe('sqlite');
26+
});
27+
28+
// #3276: the mingo in-memory engine has its own `memory://` URL scheme.
29+
it('maps the memory:// (and mingo://) scheme to the mingo `memory` kind', () => {
30+
expect(inferDriverTypeFromUrl('memory://')).toBe('memory');
31+
expect(inferDriverTypeFromUrl('memory://ignored-host')).toBe('memory');
32+
expect(inferDriverTypeFromUrl('mingo://')).toBe('memory');
33+
});
34+
35+
// The sqlite `:memory:` PSEUDO-FILE is SQLite's own in-memory mode — NOT the
36+
// mingo engine. It must stay `sqlite`, distinct from the `memory://` scheme.
37+
it('keeps sqlite `:memory:` mapped to sqlite (distinct from memory://)', () => {
38+
expect(inferDriverTypeFromUrl(':memory:')).toBe('sqlite');
39+
});
40+
41+
it('returns "" for an absent or unrecognized URL', () => {
42+
expect(inferDriverTypeFromUrl(undefined)).toBe('');
43+
expect(inferDriverTypeFromUrl('')).toBe('');
44+
expect(inferDriverTypeFromUrl('redis://localhost')).toBe('');
45+
});
46+
});
47+
48+
describe('resolveDriverType', () => {
49+
it('lets an explicit driver win over URL inference (and normalizes case/space)', () => {
50+
expect(resolveDriverType('memory', 'postgres://h/db')).toBe('memory');
51+
expect(resolveDriverType(' MEMORY ', undefined)).toBe('memory');
52+
expect(resolveDriverType('Postgres', 'mongodb://h/db')).toBe('postgres');
53+
});
54+
55+
it('falls back to URL inference when no explicit driver is set', () => {
56+
expect(resolveDriverType(undefined, 'mongodb://h/db')).toBe('mongodb');
57+
expect(resolveDriverType('', 'memory://')).toBe('memory');
58+
expect(resolveDriverType(' ', undefined)).toBe('');
59+
});
60+
});
61+
62+
describe('createStorageDriver', () => {
63+
// ── #3276: the regression this whole change exists to fix ──────────────────
64+
// `memory` must construct the mingo InMemoryDriver — NOT fall through to the
65+
// dev SQLite `:memory:` default (SQLite-in-memory, a different engine). Remove
66+
// the `memory` branch in storage-driver.ts and this assertion goes red: in dev
67+
// it resolves to a SqlDriver/SQLite engine, in prod it resolves to null.
68+
it('constructs the mingo InMemoryDriver for `memory` in DEV', async () => {
69+
const r = await createStorageDriver('memory', { isDev: true });
70+
expect(r).not.toBeNull();
71+
expect(r!.driver).toBeInstanceOf(InMemoryDriver);
72+
expect(r!.engine).toBe('memory');
73+
expect(r!.label).toBe('InMemoryDriver');
74+
expect(r!.trackName).toBe('MemoryDriver');
75+
expect(r!.displayUrl).toBe('(in-memory)');
76+
// Never provisions a telemetry sibling.
77+
expect(r!.sqliteFilePath).toBeUndefined();
78+
});
79+
80+
// The explicit choice is honored in PRODUCTION too — declared === enforced.
81+
it('constructs the mingo InMemoryDriver for `memory` in PROD', async () => {
82+
const r = await createStorageDriver('memory', { isDev: false });
83+
expect(r!.driver).toBeInstanceOf(InMemoryDriver);
84+
expect(r!.engine).toBe('memory');
85+
});
86+
87+
it('accepts the `mingo` and `in-memory` aliases', async () => {
88+
expect((await createStorageDriver('mingo', { isDev: false }))!.driver).toBeInstanceOf(InMemoryDriver);
89+
expect((await createStorageDriver('in-memory', { isDev: false }))!.driver).toBeInstanceOf(InMemoryDriver);
90+
});
91+
92+
// ── Regression guards for the other branches (no connection is opened) ─────
93+
it('constructs mongodb with the default URL when none is supplied', async () => {
94+
const r = await createStorageDriver('mongodb', { isDev: false });
95+
expect(r!.label).toBe('MongoDBDriver');
96+
expect(r!.trackName).toBe('MongoDBDriver');
97+
expect(r!.displayUrl).toBe('mongodb://localhost:27017/objectstack');
98+
});
99+
100+
it('constructs postgres / mysql with their SqlDriver labels', async () => {
101+
const pg = await createStorageDriver('postgres', { databaseUrl: 'postgres://u:p@h/db', isDev: false });
102+
expect(pg!.label).toBe('SqlDriver(pg)');
103+
expect(pg!.trackName).toBe('PostgresDriver');
104+
const my = await createStorageDriver('mysql', { databaseUrl: 'mysql://u:p@h/db', isDev: false });
105+
expect(my!.label).toBe('SqlDriver(mysql2)');
106+
expect(my!.trackName).toBe('MySQLDriver');
107+
});
108+
109+
it('constructs sqlite-wasm without connecting', async () => {
110+
const r = await createStorageDriver('sqlite-wasm', { databaseUrl: 'file:./x.db', isDev: false });
111+
expect(r!.label).toBe('SqliteWasmDriver');
112+
expect(r!.trackName).toBe('SqliteWasmDriver');
113+
});
114+
115+
// In PROD, `resolveSqliteDriver` returns the native driver UNPROBED (no
116+
// connect), so this is fast and native-addon-free. It also documents that an
117+
// explicit sqlite primary DOES surface `sqliteFilePath` for the telemetry
118+
// sibling — the field the `memory` driver deliberately leaves unset.
119+
it('constructs explicit sqlite and surfaces sqliteFilePath for telemetry', async () => {
120+
const r = await createStorageDriver('sqlite', { databaseUrl: ':memory:', isDev: false });
121+
expect(r!.engine).toBe('better-sqlite3');
122+
expect(r!.label).toBe('SqlDriver(sqlite)');
123+
expect(r!.trackName).toBe('SqlDriver');
124+
expect(r!.sqliteFilePath).toBe(':memory:');
125+
});
126+
127+
// Production with no driver configured registers nothing (loud downstream
128+
// failure), rather than silently inventing an engine.
129+
it('returns null for an unknown/absent driver in PROD', async () => {
130+
expect(await createStorageDriver('', { isDev: false })).toBeNull();
131+
expect(await createStorageDriver('nonsense', { isDev: false })).toBeNull();
132+
});
133+
});

0 commit comments

Comments
 (0)