Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/cli-memory-driver-dispatch.md
Original file line number Diff line number Diff line change
@@ -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.
131 changes: 28 additions & 103 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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=<path>` opts in anywhere (incl. serve).
if (resolved.engine !== 'memory') {
// `OS_TELEMETRY_DB=<path>` 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,
Expand All @@ -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
Expand Down
133 changes: 133 additions & 0 deletions packages/cli/src/utils/storage-driver.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading