Skip to content

Commit f2063f3

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(cli): extend better-sqlite3 → wasm SQLite auto-fallback to the persistent-file / --artifact dev path (#2229) (#2234)
The native-better-sqlite3 → wasm SQLite → in-memory step-down previously only guarded the zero-config `:memory:` dev branch of `serve`. A normal `objectstack dev` run never reached it: `dev` injects a persistent `file:` DB so AI-authored data survives restarts, and `--artifact` boots resolve sqlite through the datasource factory — both constructed `better-sqlite3` directly with no probe and no fallback. better-sqlite3 loads its native addon lazily (first query), so an ABI mismatch was invisible at boot and surfaced later as a runtime `Find operation failed`. Hoist the probe-by-connect + step-down into a shared `resolveSqliteDriver` helper (@objectstack/service-datasource) and apply it to both previously unguarded sqlite construction sites: the explicit `sqlite`/`file:` branch in serve.ts and the sqlite branch of the default datasource driver factory. The helper forces the native load with `connect()` + `SELECT 1` and, in dev only, steps down to wasm SQLite (real SQL + on-disk persistence — the same `file:` keeps working) then to in-memory as a last resort, emitting the existing `⚠ native better-sqlite3 unavailable …` warning. In production the native driver is returned unprobed so a load failure surfaces loudly (fail-closed) instead of silently degrading. Dev is plumbed through standalone-stack so the --artifact boot enables the fallback explicitly (NODE_ENV fallback otherwise). Adds a co-located unit test simulating a NODE_MODULE_VERSION load failure (asserts wasm fallback + warning, in-memory last resort, and prod fail-closed). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bfb6301 commit f2063f3

9 files changed

Lines changed: 500 additions & 104 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/service-datasource": patch
3+
"@objectstack/cli": patch
4+
"@objectstack/runtime": patch
5+
---
6+
7+
fix(cli): extend native better-sqlite3 → wasm SQLite auto-fallback to the persistent-file / `--artifact` dev path (#2229)
8+
9+
The native-`better-sqlite3` → wasm SQLite → in-memory step-down previously only
10+
guarded the zero-config `:memory:` dev branch of `serve`. A normal
11+
`objectstack dev` run never reaches it — `dev` injects a persistent `file:` DB
12+
(so AI-authored data survives restarts) and `--artifact` boots resolve sqlite
13+
through the datasource factory — both of which constructed
14+
`better-sqlite3` directly with no probe and no fallback. An ABI mismatch (e.g.
15+
a cached prebuilt binary built for a different Node version) was therefore not
16+
caught at boot and surfaced later as a runtime `Find operation failed` on the
17+
first query.
18+
19+
The probe-by-connect + step-down is now hoisted into a shared
20+
`resolveSqliteDriver` helper (`@objectstack/service-datasource`) and applied to
21+
both previously-unguarded sqlite construction sites: the explicit `sqlite` /
22+
`file:` branch in `serve.ts` and the sqlite branch of the default datasource
23+
driver factory. better-sqlite3 loads its native addon lazily (first query), so
24+
the helper forces the load with a `SELECT 1` and, **in dev only**, steps down to
25+
wasm SQLite (real SQL + on-disk persistence — the same `file:` keeps working)
26+
then to the in-memory driver as a last resort, emitting the existing
27+
`⚠ native better-sqlite3 unavailable …` warning. In production the native driver
28+
is returned unprobed so a load failure surfaces loudly (fail-closed) rather than
29+
silently degrading to a different engine.

packages/cli/src/commands/serve.ts

Lines changed: 39 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ export default class Serve extends Command {
425425
// "missing artifact" error and assemble a bare kernel that
426426
// can later install marketplace apps at runtime.
427427
const { createDefaultHostConfig } = await import('@objectstack/runtime');
428-
const bootResult = await createDefaultHostConfig({ requireArtifact: !useEmptyBoot });
428+
const bootResult = await createDefaultHostConfig({ requireArtifact: !useEmptyBoot, dev: isDev });
429429
config = { ...originalConfig, ...bootResult } as any;
430430
} else if (resolvedMode === 'standalone') {
431431
const { createStandaloneStack } = await import('@objectstack/runtime');
@@ -435,6 +435,9 @@ export default class Serve extends Command {
435435
const standaloneInput = {
436436
...(config.standalone ?? {}),
437437
projectRoot: (config.standalone?.projectRoot ?? path.dirname(absolutePath)),
438+
// #2229: dev enables the native-better-sqlite3 → wasm → in-memory
439+
// step-down in the shared datasource factory; prod fails loudly.
440+
dev: isDev,
438441
};
439442
const bootResult = await createStandaloneStack(standaloneInput);
440443
config = { ...originalConfig, ...bootResult } as any;
@@ -630,19 +633,25 @@ export default class Serve extends Command {
630633
resolvedDriverLabel = 'MongoDBDriver';
631634
resolvedDatabaseUrl = databaseUrl ?? 'mongodb://localhost:27017/objectstack';
632635
} else if (driverType === 'sqlite' || driverType === 'sql') {
633-
const { SqlDriver } = await import('@objectstack/driver-sql');
634636
const filePath = (databaseUrl ?? ':memory:').replace(/^file:/, '').replace(/^sqlite:/, '').replace(/^sql:\/\//, '');
635-
await kernel.use(new DriverPlugin(new SqlDriver({
636-
client: 'better-sqlite3',
637-
connection: { filename: filePath },
638-
useNullAsDefault: true,
637+
// Probe-by-connect with a dev-only native → wasm → in-memory
638+
// step-down (#2229). better-sqlite3 loads its native addon lazily
639+
// (first query), so an ABI mismatch is invisible here and would
640+
// otherwise surface much later as a runtime crash. resolveSqliteDriver
641+
// forces the load and degrades gracefully in dev / fails loudly in prod.
642+
const { resolveSqliteDriver } = await import('@objectstack/service-datasource');
643+
const resolved = await resolveSqliteDriver({
644+
filename: filePath,
645+
dev: isDev,
639646
// #2186: in dev, self-heal a persisted DB when a metadata change
640647
// relaxes a constraint (loosen-only; never destructive / never in prod).
641648
autoMigrate: isDev ? 'safe' : undefined,
642-
}) as any));
643-
trackPlugin('SqlDriver');
644-
resolvedDriverLabel = 'SqlDriver(sqlite)';
645-
resolvedDatabaseUrl = databaseUrl ?? ':memory:';
649+
warn: (m) => console.warn(chalk.yellow(m)),
650+
});
651+
await kernel.use(new DriverPlugin(resolved.driver));
652+
trackPlugin(resolved.engine === 'memory' ? 'MemoryDriver' : resolved.engine === 'sqlite-wasm' ? 'SqliteWasmDriver' : 'SqlDriver');
653+
resolvedDriverLabel = resolved.label;
654+
resolvedDatabaseUrl = resolved.engine === 'memory' ? '(in-memory)' : (databaseUrl ?? ':memory:');
646655
} else if (driverType === 'sqlite-wasm' || driverType === 'wasm-sqlite' || driverType === 'wasm') {
647656
const { SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm');
648657
const filePath = (databaseUrl ?? ':memory:').replace(/^file:/, '').replace(/^wasm-sqlite:\/\//, '').replace(/^sqlite:/, '');
@@ -676,90 +685,26 @@ export default class Serve extends Command {
676685
resolvedDriverLabel = 'SqlDriver(mysql2)';
677686
resolvedDatabaseUrl = databaseUrl;
678687
} else if (isDev) {
679-
// Default in dev: prefer native SQLite for production-like SQL
680-
// semantics at native speed. When the native `better-sqlite3`
681-
// binary is unavailable — not built, ABI mismatch after a Node
682-
// upgrade (e.g. Node 25 → NODE_MODULE_VERSION mismatch), or a
683-
// blocked prebuild download — fall back to the pure-JS wasm SQLite
684-
// driver, which keeps *real* SQL semantics (and on-disk
685-
// persistence) without any native build step. Only if wasm also
686-
// fails to load do we drop to the in-memory driver (mingo), which
687-
// is neither real SQL nor persistent.
688-
//
689-
// knex loads its client lazily (at first query, not at construction),
690-
// so the only reliable signal inside this registration window is to
691-
// actually open a connection: connect() runs `SELECT 1`, which forces
692-
// better-sqlite3 to load. If that throws we step down the chain here
693-
// instead of letting the failure surface much later — as a
694-
// missing-module crash on the first real query — or be swallowed by
695-
// the silent catch below, leaving the kernel with no driver at all.
696-
let sqliteDriver: any;
697-
let sqliteOk = false;
698-
try {
699-
const { SqlDriver } = await import('@objectstack/driver-sql');
700-
sqliteDriver = new SqlDriver({
701-
client: 'better-sqlite3',
702-
connection: { filename: ':memory:' },
703-
useNullAsDefault: true,
704-
autoMigrate: 'safe', // #2186 dev loosen-only self-heal
705-
});
706-
await sqliteDriver.connect();
707-
sqliteOk = true;
708-
} catch {
709-
sqliteOk = false;
710-
if (sqliteDriver?.disconnect) {
711-
try { await sqliteDriver.disconnect(); } catch { /* ignore */ }
712-
}
713-
}
714-
715-
if (sqliteOk) {
716-
await kernel.use(new DriverPlugin(sqliteDriver));
717-
trackPlugin('SqlDriver');
718-
resolvedDriverLabel = 'SqlDriver(sqlite)';
719-
resolvedDatabaseUrl = ':memory:';
720-
} else {
721-
// Native unavailable → try the pure-JS wasm SQLite driver before
722-
// giving up on SQL fidelity entirely. Same probe-by-connect
723-
// approach: actually open the connection so a load failure is
724-
// caught here rather than on the first real query.
725-
let wasmDriver: any;
726-
let wasmOk = false;
727-
try {
728-
const { SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm');
729-
wasmDriver = new SqliteWasmDriver({
730-
filename: ':memory:',
731-
persist: 'on-disconnect',
732-
});
733-
await wasmDriver.connect();
734-
wasmOk = true;
735-
} catch {
736-
wasmOk = false;
737-
if (wasmDriver?.disconnect) {
738-
try { await wasmDriver.disconnect(); } catch { /* ignore */ }
739-
}
740-
}
741-
742-
if (wasmOk) {
743-
await kernel.use(new DriverPlugin(wasmDriver));
744-
trackPlugin('SqliteWasmDriver');
745-
resolvedDriverLabel = 'SqliteWasmDriver';
746-
resolvedDatabaseUrl = ':memory:';
747-
console.warn(chalk.yellow(
748-
' ⚠ native better-sqlite3 unavailable (ABI mismatch or not built) — dev using wasm SQLite (real SQL, slower).\n' +
749-
' Rebuild better-sqlite3 for native speed, or set OS_DATABASE_DRIVER=sqlite-wasm to silence this.'
750-
));
751-
} else {
752-
const { InMemoryDriver } = await import('@objectstack/driver-memory');
753-
await kernel.use(new DriverPlugin(new InMemoryDriver()));
754-
trackPlugin('MemoryDriver');
755-
resolvedDriverLabel = 'InMemoryDriver';
756-
resolvedDatabaseUrl = '(in-memory)';
757-
console.warn(chalk.yellow(
758-
' ⚠ neither native nor wasm SQLite available — dev falling back to InMemoryDriver (mingo, not real SQL).\n' +
759-
' Rebuild better-sqlite3, or set OS_DATABASE_URL / OS_DATABASE_DRIVER for SQL fidelity.'
760-
));
761-
}
762-
}
688+
// Default in dev (no DB configured): prefer native SQLite for
689+
// production-like SQL at native speed, with a graceful step-down to
690+
// wasm SQLite (real SQL + on-disk persistence) then in-memory when the
691+
// native better-sqlite3 binary is unavailable — not built, ABI mismatch
692+
// after a Node upgrade (e.g. NODE_MODULE_VERSION change), or a blocked
693+
// prebuild download. Shared with the explicit-file branch and the
694+
// datasource factory via resolveSqliteDriver (#2229), which probes by
695+
// actually opening a connection + running SELECT 1 (better-sqlite3 loads
696+
// its native addon lazily at first query, not at construction).
697+
const { resolveSqliteDriver } = await import('@objectstack/service-datasource');
698+
const resolved = await resolveSqliteDriver({
699+
filename: ':memory:',
700+
dev: true,
701+
autoMigrate: 'safe', // #2186 dev loosen-only self-heal
702+
warn: (m) => console.warn(chalk.yellow(m)),
703+
});
704+
await kernel.use(new DriverPlugin(resolved.driver));
705+
trackPlugin(resolved.engine === 'memory' ? 'MemoryDriver' : resolved.engine === 'sqlite-wasm' ? 'SqliteWasmDriver' : 'SqlDriver');
706+
resolvedDriverLabel = resolved.label;
707+
resolvedDatabaseUrl = resolved.engine === 'memory' ? '(in-memory)' : ':memory:';
763708
}
764709
} catch (e: any) {
765710
// silent

packages/runtime/src/standalone-stack.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ export const StandaloneStackConfigSchema = z.object({
7171
* precedence over this default.
7272
*/
7373
projectRoot: z.string().optional(),
74+
/**
75+
* Dev gate for the sqlite driver factory's native-better-sqlite3 → wasm →
76+
* in-memory step-down (#2229). When omitted, defaults to
77+
* `process.env.NODE_ENV === 'development'`. In production a native load
78+
* failure is NOT silently swapped for wasm/mingo (fail-closed).
79+
*/
80+
dev: z.boolean().optional(),
7481
});
7582

7683
export type StandaloneStackConfig = z.input<typeof StandaloneStackConfigSchema>;
@@ -183,6 +190,10 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro
183190
);
184191
} else {
185192
const { createDefaultDatasourceDriverFactory } = await import('@objectstack/service-datasource');
193+
// #2229: in dev, a native better-sqlite3 ABI/load failure steps down to
194+
// wasm SQLite (real SQL + on-disk persistence) then in-memory; in prod it
195+
// fails loudly. Falls back to NODE_ENV when the caller did not pass `dev`.
196+
const factoryDev = cfg.dev ?? process.env.NODE_ENV === 'development';
186197
let driverId: string;
187198
let driverConfig: Record<string, unknown>;
188199
if (dbDriver === 'memory') {
@@ -211,7 +222,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro
211222

212223
let driverHandle: { driver?: unknown } | unknown;
213224
try {
214-
driverHandle = await createDefaultDatasourceDriverFactory().create({ driver: driverId, config: driverConfig });
225+
driverHandle = await createDefaultDatasourceDriverFactory({ dev: factoryDev }).create({ driver: driverId, config: driverConfig });
215226
} catch (err: any) {
216227
// Preserve the actionable hint the bespoke path gave for the optional
217228
// mongo peer dep (the factory throws a generic "not installed" message).

packages/services/service-datasource/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"devDependencies": {
3333
"@objectstack/driver-memory": "workspace:*",
3434
"@objectstack/driver-sql": "workspace:*",
35+
"@objectstack/driver-sqlite-wasm": "workspace:*",
3536
"@objectstack/plugin-hono-server": "workspace:*",
3637
"@types/node": "^26.0.0",
3738
"tsup": "^8.5.1",

packages/services/service-datasource/src/default-datasource-driver-factory.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,19 @@ function buildMongoUrl(spec: DatasourceConnectionSpec): string {
113113
* lazily so a host that never builds (e.g.) a mongo connection doesn't pay for
114114
* the mongo SDK.
115115
*/
116-
export function createDefaultDatasourceDriverFactory(): IDatasourceDriverFactory {
116+
export interface DefaultDatasourceDriverFactoryOptions {
117+
/**
118+
* Enables the dev-only native-`better-sqlite3` → wasm → in-memory step-down
119+
* for sqlite construction (#2229). When omitted, defaults per call to
120+
* `process.env.NODE_ENV === 'development'`. In production a native load
121+
* failure is NOT silently swapped for a different engine (fail-closed).
122+
*/
123+
dev?: boolean;
124+
}
125+
126+
export function createDefaultDatasourceDriverFactory(
127+
options: DefaultDatasourceDriverFactoryOptions = {},
128+
): IDatasourceDriverFactory {
117129
return {
118130
supports(driverId: string): boolean {
119131
return resolveKind(driverId) !== undefined;
@@ -140,14 +152,19 @@ export function createDefaultDatasourceDriverFactory(): IDatasourceDriverFactory
140152
}
141153

142154
if (kind === 'sqlite') {
143-
const { SqlDriver } = await import('@objectstack/driver-sql');
144-
const driver = new SqlDriver({
145-
client: 'better-sqlite3',
146-
connection: buildSqlConnection(spec, 'better-sqlite3') as any,
147-
useNullAsDefault: true,
148-
...(schemaMode ? { schemaMode: schemaMode as any } : {}),
149-
} as any);
150-
return toHandle(driver, () => sqlServerVersion(driver, 'sqlite'));
155+
// better-sqlite3 loads its native addon lazily (first query), so an ABI
156+
// mismatch is invisible at construction and crashes later. resolveSqliteDriver
157+
// probes up-front and, IN DEV ONLY, steps down to wasm SQLite (real SQL +
158+
// on-disk persistence) then in-memory; in production it returns the native
159+
// driver unprobed so a failure surfaces loudly (fail-closed). (#2229)
160+
const conn = buildSqlConnection(spec, 'better-sqlite3') as { filename?: string };
161+
const { resolveSqliteDriver } = await import('./sqlite-driver-fallback.js');
162+
const resolved = await resolveSqliteDriver({
163+
filename: conn.filename ?? ':memory:',
164+
dev: options.dev,
165+
...(schemaMode ? { schemaMode } : {}),
166+
});
167+
return toHandle(resolved.driver, () => sqlServerVersion(resolved.driver, 'sqlite'));
151168
}
152169

153170
if (kind === 'mongodb') {

packages/services/service-datasource/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,17 @@ export type {
7676

7777
// Host glue: dev driver factory + fail-closed secret binder.
7878
export { createDefaultDatasourceDriverFactory } from './default-datasource-driver-factory.js';
79+
// Shared native-better-sqlite3 → wasm → in-memory step-down (#2229).
80+
export {
81+
resolveSqliteDriver,
82+
NATIVE_SQLITE_WASM_FALLBACK_WARNING,
83+
NATIVE_SQLITE_MEMORY_FALLBACK_WARNING,
84+
} from './sqlite-driver-fallback.js';
85+
export type {
86+
ResolveSqliteDriverOptions,
87+
ResolvedSqliteDriver,
88+
SqliteFallbackEngine,
89+
} from './sqlite-driver-fallback.js';
7990
export {
8091
createDatasourceSecretBinder,
8192
toCredentialsRef,

0 commit comments

Comments
 (0)