Skip to content

Commit f30a4f3

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 9dba636 commit f30a4f3

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, isModuleNotFoundError } from '@objectstack/types';
1213
import { PLATFORM_CAPABILITY_TOKENS, canonicalizePlatformCapability } from '@objectstack/spec/kernel';
1314
import { resolveObjectStackHome } from '@objectstack/runtime';
@@ -872,71 +873,48 @@ export default class Serve extends Command {
872873
// postgres://, postgresql:// → postgres
873874
// mysql://, mysql2:// → mysql
874875
// libsql://, http(s):// + .turso. → turso
875-
// file:, sqlite:, *.db, :memory: → sqlite
876-
// 3. Default: InMemoryDriver in dev mode
876+
// wasm-sqlite://, *.wasm.db → sqlite-wasm
877+
// memory://, mingo:// → memory (mingo InMemoryDriver)
878+
// file:, sqlite:, *.db, :memory: → sqlite (SQLite's own in-memory mode)
879+
// 3. Default: dev SQLite (native → wasm → in-memory step-down); prod none
880+
//
881+
// Kind-resolution and construction live in utils/storage-driver.ts so the
882+
// whole dispatch is unit-testable (storage-driver.test.ts). #3276: the
883+
// `memory` kind now maps to the mingo InMemoryDriver instead of silently
884+
// falling through to the dev SQLite `:memory:` default.
877885
const hasDriver = plugins.some((p: any) => p.name?.includes('driver') || p.constructor?.name?.includes('Driver'));
878886
if (!hasDriver && config.objects) {
879-
const explicitDriver = (process.env.OS_DATABASE_DRIVER ?? '').toLowerCase().trim();
880887
const databaseUrl = process.env.OS_DATABASE_URL;
881-
882-
const inferDriverFromUrl = (url: string | undefined): string => {
883-
if (!url) return '';
884-
const u = url.trim();
885-
if (/^mongodb(\+srv)?:\/\//i.test(u)) return 'mongodb';
886-
if (/^postgres(ql)?:\/\//i.test(u)) return 'postgres';
887-
if (/^mysql2?:\/\//i.test(u)) return 'mysql';
888-
if (/^libsql:\/\//i.test(u)) return 'turso';
889-
if (/^https?:\/\//i.test(u) && /\.turso\./i.test(u)) return 'turso';
890-
if (/^wasm-sqlite:\/\//i.test(u) || /\.wasm\.db$/i.test(u)) return 'sqlite-wasm';
891-
if (/^file:/i.test(u) || /^sqlite:/i.test(u) || u === ':memory:' || /\.(db|sqlite|sqlite3)$/i.test(u)) return 'sqlite';
892-
return '';
893-
};
894-
895-
const driverType = explicitDriver || inferDriverFromUrl(databaseUrl);
888+
const driverType = resolveDriverType(process.env.OS_DATABASE_DRIVER, databaseUrl);
896889

897890
try {
898891
const { DriverPlugin } = await import('@objectstack/runtime');
899-
900-
if (driverType === 'mongodb' || driverType === 'mongo') {
901-
const { MongoDBDriver } = await import('@objectstack/driver-mongodb');
902-
await kernel.use(new DriverPlugin(new MongoDBDriver({
903-
url: databaseUrl ?? 'mongodb://localhost:27017/objectstack',
904-
}) as any));
905-
trackPlugin('MongoDBDriver');
906-
resolvedDriverLabel = 'MongoDBDriver';
907-
resolvedDatabaseUrl = databaseUrl ?? 'mongodb://localhost:27017/objectstack';
908-
} else if (driverType === 'sqlite' || driverType === 'sql') {
909-
const filePath = (databaseUrl ?? ':memory:').replace(/^file:/, '').replace(/^sqlite:/, '').replace(/^sql:\/\//, '');
910-
// Probe-by-connect with a dev-only native → wasm → in-memory
911-
// step-down (#2229). better-sqlite3 loads its native addon lazily
912-
// (first query), so an ABI mismatch is invisible here and would
913-
// otherwise surface much later as a runtime crash. resolveSqliteDriver
914-
// forces the load and degrades gracefully in dev / fails loudly in prod.
915-
const { resolveSqliteDriver } = await import('@objectstack/service-datasource');
916-
const resolved = await resolveSqliteDriver({
917-
filename: filePath,
918-
dev: isDev,
919-
// #2186: in dev, self-heal a persisted DB when a metadata change
920-
// relaxes a constraint (loosen-only; never destructive / never in prod).
921-
autoMigrate: isDev ? 'safe' : undefined,
922-
warn: (m) => console.warn(chalk.yellow(m)),
923-
});
924-
await kernel.use(new DriverPlugin(resolved.driver));
925-
trackPlugin(resolved.engine === 'memory' ? 'MemoryDriver' : resolved.engine === 'sqlite-wasm' ? 'SqliteWasmDriver' : 'SqlDriver');
926-
resolvedDriverLabel = resolved.label;
927-
resolvedDatabaseUrl = resolved.engine === 'memory' ? '(in-memory)' : (databaseUrl ?? ':memory:');
892+
const resolution = await createStorageDriver(driverType, {
893+
databaseUrl,
894+
isDev,
895+
warn: (m) => console.warn(chalk.yellow(m)),
896+
});
897+
if (resolution) {
898+
await kernel.use(new DriverPlugin(resolution.driver as any));
899+
trackPlugin(resolution.trackName);
900+
resolvedDriverLabel = resolution.label;
901+
resolvedDatabaseUrl = resolution.displayUrl;
928902

929903
// ADR-0057 §3.6 (#2834 ②): provision the dedicated `telemetry`
930904
// datasource — a sibling SQLite file the engine routes every
931905
// telemetry/event/audit-classed object to, so platform-generated
932906
// growth can never again bloat the business DB. Dev default-on
933907
// for file-backed primaries; `OS_TELEMETRY_DB=0` opts out,
934-
// `OS_TELEMETRY_DB=<path>` opts in anywhere (incl. serve).
935-
if (resolved.engine !== 'memory') {
908+
// `OS_TELEMETRY_DB=<path>` opts in anywhere (incl. serve). Gated on
909+
// an explicit SQLite primary (`sqliteFilePath`, unset for the mingo
910+
// memory driver AND the dev-default `:memory:` store) whose resolved
911+
// engine is real SQLite — never mingo in-memory.
912+
if (resolution.sqliteFilePath && resolution.engine !== 'memory') {
936913
const { resolveTelemetryDbPath } = await import('../utils/telemetry-datasource.js');
937-
const telemetryPath = resolveTelemetryDbPath({ primaryPath: filePath, env: process.env, dev: isDev });
914+
const telemetryPath = resolveTelemetryDbPath({ primaryPath: resolution.sqliteFilePath, env: process.env, dev: isDev });
938915
if (telemetryPath) {
939916
try {
917+
const { resolveSqliteDriver } = await import('@objectstack/service-datasource');
940918
const telemetry = await resolveSqliteDriver({
941919
filename: telemetryPath,
942920
dev: isDev,
@@ -957,59 +935,6 @@ export default class Serve extends Command {
957935
}
958936
}
959937
}
960-
} else if (driverType === 'sqlite-wasm' || driverType === 'wasm-sqlite' || driverType === 'wasm') {
961-
const { SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm');
962-
const filePath = (databaseUrl ?? ':memory:').replace(/^file:/, '').replace(/^wasm-sqlite:\/\//, '').replace(/^sqlite:/, '');
963-
await kernel.use(new DriverPlugin(new SqliteWasmDriver({
964-
filename: filePath,
965-
persist: 'on-disconnect',
966-
}) as any));
967-
trackPlugin('SqliteWasmDriver');
968-
resolvedDriverLabel = 'SqliteWasmDriver';
969-
resolvedDatabaseUrl = databaseUrl ?? ':memory:';
970-
} else if (driverType === 'postgres' || driverType === 'postgresql' || driverType === 'pg') {
971-
const { SqlDriver } = await import('@objectstack/driver-sql');
972-
await kernel.use(new DriverPlugin(new SqlDriver({
973-
client: 'pg',
974-
connection: databaseUrl,
975-
pool: { min: 0, max: 5 },
976-
autoMigrate: isDev ? 'safe' : undefined, // #2186 dev loosen-only self-heal
977-
}) as any));
978-
trackPlugin('PostgresDriver');
979-
resolvedDriverLabel = 'SqlDriver(pg)';
980-
resolvedDatabaseUrl = databaseUrl;
981-
} else if (driverType === 'mysql' || driverType === 'mysql2') {
982-
const { SqlDriver } = await import('@objectstack/driver-sql');
983-
await kernel.use(new DriverPlugin(new SqlDriver({
984-
client: 'mysql2',
985-
connection: databaseUrl,
986-
pool: { min: 0, max: 5 },
987-
autoMigrate: isDev ? 'safe' : undefined, // #2186 dev loosen-only self-heal
988-
}) as any));
989-
trackPlugin('MySQLDriver');
990-
resolvedDriverLabel = 'SqlDriver(mysql2)';
991-
resolvedDatabaseUrl = databaseUrl;
992-
} else if (isDev) {
993-
// Default in dev (no DB configured): prefer native SQLite for
994-
// production-like SQL at native speed, with a graceful step-down to
995-
// wasm SQLite (real SQL + on-disk persistence) then in-memory when the
996-
// native better-sqlite3 binary is unavailable — not built, ABI mismatch
997-
// after a Node upgrade (e.g. NODE_MODULE_VERSION change), or a blocked
998-
// prebuild download. Shared with the explicit-file branch and the
999-
// datasource factory via resolveSqliteDriver (#2229), which probes by
1000-
// actually opening a connection + running SELECT 1 (better-sqlite3 loads
1001-
// its native addon lazily at first query, not at construction).
1002-
const { resolveSqliteDriver } = await import('@objectstack/service-datasource');
1003-
const resolved = await resolveSqliteDriver({
1004-
filename: ':memory:',
1005-
dev: true,
1006-
autoMigrate: 'safe', // #2186 dev loosen-only self-heal
1007-
warn: (m) => console.warn(chalk.yellow(m)),
1008-
});
1009-
await kernel.use(new DriverPlugin(resolved.driver));
1010-
trackPlugin(resolved.engine === 'memory' ? 'MemoryDriver' : resolved.engine === 'sqlite-wasm' ? 'SqliteWasmDriver' : 'SqlDriver');
1011-
resolvedDriverLabel = resolved.label;
1012-
resolvedDatabaseUrl = resolved.engine === 'memory' ? '(in-memory)' : ':memory:';
1013938
}
1014939
} catch (e: any) {
1015940
// 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)