From c8bf4bf54742a4a74e4ac2b98bcfcf10736f7b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Thu, 23 Apr 2026 07:25:08 +0000 Subject: [PATCH] refactor(cli): replace better-sqlite3 with sql.js (pure WASM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove native C++ addon dependency, no more pnpm approve-builds - sql.js loads SQLite as WASM, zero compilation required - WASM init is singleton (once per process) - Add queryAsObjects() adapter for sql.js columnar → row format - Tests migrated to sql.js (16 passing) Implements RFC #63 --- packages/cli/package.json | 6 +- .../cli/src/__tests__/sense-sqlite.test.ts | 81 ++++++++++++------- packages/cli/src/commands/sense.ts | 16 ++-- packages/cli/src/sense-sqlite.ts | 79 ++++++++++++------ packages/cli/tsup.config.ts | 2 +- pnpm-lock.yaml | 16 ++-- 6 files changed, 130 insertions(+), 70 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 1abdf3f..b7fd4e0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,13 +20,13 @@ }, "dependencies": { "@uncaged/nerve-core": "workspace:*", - "better-sqlite3": "^11.10.0", - "citty": "^0.1.6" + "citty": "^0.1.6", + "sql.js": "^1.14.1" }, "devDependencies": { - "@uncaged/nerve-daemon": "workspace:*", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", + "@uncaged/nerve-daemon": "workspace:*", "vitest": "^4.1.5" } } diff --git a/packages/cli/src/__tests__/sense-sqlite.test.ts b/packages/cli/src/__tests__/sense-sqlite.test.ts index a9ca127..bcf1ead 100644 --- a/packages/cli/src/__tests__/sense-sqlite.test.ts +++ b/packages/cli/src/__tests__/sense-sqlite.test.ts @@ -2,12 +2,12 @@ * Tests for sense SQLite helpers used by `nerve sense schema` / `nerve sense query`. */ -import { mkdirSync, rmSync } from "node:fs"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import Database from "better-sqlite3"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import initSqlJs, { type Database } from "sql.js"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { assertSenseDbExists, @@ -17,11 +17,17 @@ import { listTableSqlStatements, parseSenseQueryArgs, pickDefaultPreviewTable, + queryAsObjects, senseDbPath, } from "../sense-sqlite.js"; +let SQL: Awaited>; let tmpDir: string; +beforeAll(async () => { + SQL = await initSqlJs(); +}); + beforeEach(() => { tmpDir = join( tmpdir(), @@ -34,6 +40,22 @@ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); +/** Helper: create a SQLite db file with the given setup SQL. */ +function createDb(name: string, setupSql: string): void { + const db = new SQL.Database(); + db.run(setupSql); + const data = db.export(); + db.close(); + writeFileSync(join(tmpDir, "data", "senses", `${name}.db`), Buffer.from(data)); +} + +/** Helper: open an in-memory db with setup SQL for unit tests. */ +function memDb(setupSql?: string): Database { + const db = new SQL.Database(); + if (setupSql) db.run(setupSql); + return db; +} + describe("senseDbPath", () => { it("points at data/senses/.db under the given root", () => { expect(senseDbPath("/root", "cpu-usage")).toBe(join("/root", "data", "senses", "cpu-usage.db")); @@ -46,18 +68,14 @@ describe("assertSenseDbExists", () => { }); it("returns the path when the file exists", () => { - const p = join(tmpDir, "data", "senses", "x.db"); - new Database(p).close(); - expect(assertSenseDbExists(tmpDir, "x")).toBe(p); + createDb("x", "SELECT 1"); + expect(assertSenseDbExists(tmpDir, "x")).toBe(join(tmpDir, "data", "senses", "x.db")); }); }); describe("listTableSqlStatements", () => { it("returns CREATE statements ordered by tbl_name", () => { - const p = join(tmpDir, "data", "senses", "t.db"); - const db = new Database(p); - db.exec("CREATE TABLE zebra (id INTEGER);"); - db.exec("CREATE TABLE alpha (id INTEGER);"); + const db = memDb("CREATE TABLE zebra (id INTEGER); CREATE TABLE alpha (id INTEGER);"); const stmts = listTableSqlStatements(db); db.close(); expect(stmts).toHaveLength(2); @@ -68,18 +86,16 @@ describe("listTableSqlStatements", () => { describe("pickDefaultPreviewTable", () => { it("prefers non-_migrations tables when both exist", () => { - const p = join(tmpDir, "data", "senses", "t.db"); - const db = new Database(p); - db.exec(`CREATE TABLE _migrations (name TEXT PRIMARY KEY); - CREATE TABLE readings (id INTEGER);`); + const db = memDb( + `CREATE TABLE _migrations (name TEXT PRIMARY KEY); + CREATE TABLE readings (id INTEGER);`, + ); expect(pickDefaultPreviewTable(db)).toBe("readings"); db.close(); }); it("uses _migrations when it is the only table", () => { - const p = join(tmpDir, "data", "senses", "t.db"); - const db = new Database(p); - db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY);"); + const db = memDb("CREATE TABLE _migrations (name TEXT PRIMARY KEY);"); expect(pickDefaultPreviewTable(db)).toBe("_migrations"); db.close(); }); @@ -137,22 +153,29 @@ describe("collectColumnKeys", () => { }); }); -describe("readonly query integration", () => { - it("runs default preview SQL on a real db", () => { - const p = join(tmpDir, "data", "senses", "demo.db"); - const rw = new Database(p); - rw.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, v TEXT);"); - rw.exec(`INSERT INTO items (v) VALUES ('a'), ('b');`); - rw.close(); +describe("queryAsObjects", () => { + it("converts columnar sql.js results to row objects", () => { + const db = memDb("CREATE TABLE t (x INTEGER, y TEXT); INSERT INTO t VALUES (1, 'a'), (2, 'b');"); + const rows = queryAsObjects(db, "SELECT * FROM t ORDER BY x"); + db.close(); + expect(rows).toEqual([ + { x: 1, y: "a" }, + { x: 2, y: "b" }, + ]); + }); +}); - const db = new Database(p, { readonly: true, fileMustExist: true }); +describe("readonly query integration", () => { + it("runs default preview SQL on a real db file", () => { + createDb("demo", "CREATE TABLE items (id INTEGER PRIMARY KEY, v TEXT); INSERT INTO items (v) VALUES ('a'), ('b');"); + + const buffer = require("node:fs").readFileSync(join(tmpDir, "data", "senses", "demo.db")); + const db = new SQL.Database(buffer); const table = pickDefaultPreviewTable(db); expect(table).toBe("items"); - if (table === null) { - throw new Error("expected items table"); - } + if (table === null) throw new Error("expected items table"); const sql = defaultPreviewSql(table); - const rows = db.prepare(sql).all() as Record[]; + const rows = queryAsObjects(db, sql); db.close(); expect(rows.length).toBeGreaterThanOrEqual(1); }); diff --git a/packages/cli/src/commands/sense.ts b/packages/cli/src/commands/sense.ts index b988e2e..19c22a6 100644 --- a/packages/cli/src/commands/sense.ts +++ b/packages/cli/src/commands/sense.ts @@ -2,7 +2,6 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { type SenseInfo, parseNerveConfig } from "@uncaged/nerve-core"; -import Database from "better-sqlite3"; import { defineCommand } from "citty"; import { listSensesViaDaemon, triggerSenseViaDaemon } from "../daemon-client.js"; @@ -11,8 +10,10 @@ import { defaultPreviewSql, formatRowsAsAlignedTable, listTableSqlStatements, + openSenseDb, parseSenseQueryArgs, pickDefaultPreviewTable, + queryAsObjects, } from "../sense-sqlite.js"; import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js"; @@ -170,10 +171,9 @@ const senseSchemaCommand = defineCommand({ }, async run({ args }) { const nerveRoot = getNerveRoot(); - let db: Database.Database | undefined; + let db: ReturnType>["Database"]> | undefined; try { - const path = assertSenseDbExists(nerveRoot, args.name); - db = new Database(path, { readonly: true, fileMustExist: true }); + db = await openSenseDb(nerveRoot, args.name); const statements = listTableSqlStatements(db); if (args.json) { process.stdout.write(`${JSON.stringify(statements, null, 2)}\n`); @@ -217,7 +217,7 @@ const senseQueryCommand = defineCommand({ }, async run({ args, rawArgs }) { const nerveRoot = getNerveRoot(); - let db: Database.Database | undefined; + let db: ReturnType>["Database"]> | undefined; try { let parsed: { name: string; sql: string | undefined }; try { @@ -228,8 +228,7 @@ const senseQueryCommand = defineCommand({ process.exit(1); } - const path = assertSenseDbExists(nerveRoot, args.name); - db = new Database(path, { readonly: true, fileMustExist: true }); + db = await openSenseDb(nerveRoot, args.name); let sql = parsed.sql?.trim(); if (!sql) { @@ -242,8 +241,7 @@ const senseQueryCommand = defineCommand({ } } - const stmt = db.prepare(sql); - const rows = stmt.all() as Record[]; + const rows = queryAsObjects(db, sql); if (args.json) { process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`); diff --git a/packages/cli/src/sense-sqlite.ts b/packages/cli/src/sense-sqlite.ts index 65e268b..f79ed34 100644 --- a/packages/cli/src/sense-sqlite.ts +++ b/packages/cli/src/sense-sqlite.ts @@ -1,7 +1,25 @@ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import type Database from "better-sqlite3"; +import initSqlJs, { type Database } from "sql.js"; + +// ── WASM singleton ────────────────────────────────────────────────────────── +let _SQL: Awaited> | null = null; + +async function getSQL() { + if (!_SQL) { + _SQL = await initSqlJs(); + } + return _SQL; +} + +/** Open a sense SQLite database (readonly, loaded into memory via sql.js). */ +export async function openSenseDb(nerveRoot: string, senseName: string): Promise { + const path = assertSenseDbExists(nerveRoot, senseName); + const SQL = await getSQL(); + const buffer = readFileSync(path); + return new SQL.Database(buffer); +} /** SQLite path for a sense under the nerve workspace root. */ export function senseDbPath(nerveRoot: string, senseName: string): string { @@ -17,32 +35,30 @@ export function assertSenseDbExists(nerveRoot: string, senseName: string): strin } /** `SELECT sql FROM sqlite_master WHERE type='table'` (non-null sql only). */ -export function listTableSqlStatements(db: Database.Database): string[] { - const rows = db - .prepare( - `SELECT sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL ORDER BY tbl_name`, - ) - .all() as { sql: string }[]; - return rows.map((r) => r.sql); +export function listTableSqlStatements(db: Database): string[] { + const results = db.exec( + `SELECT sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL ORDER BY tbl_name`, + ); + if (results.length === 0) return []; + return results[0].values.map((row) => row[0] as string); } /** * Table used for `nerve sense query ` with no SQL. * Prefers real data tables over `_migrations`, then lexicographic by name. */ -export function pickDefaultPreviewTable(db: Database.Database): string | null { - const row = db - .prepare( - `SELECT name FROM sqlite_master - WHERE type = 'table' AND sql IS NOT NULL - AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' - ORDER BY - CASE WHEN name = '_migrations' THEN 1 ELSE 0 END, - name - LIMIT 1`, - ) - .get() as { name: string } | undefined; - return row?.name ?? null; +export function pickDefaultPreviewTable(db: Database): string | null { + const results = db.exec( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND sql IS NOT NULL + AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' + ORDER BY + CASE WHEN name = '_migrations' THEN 1 ELSE 0 END, + name + LIMIT 1`, + ); + if (results.length === 0 || results[0].values.length === 0) return null; + return results[0].values[0][0] as string; } export function defaultPreviewSql(table: string): string { @@ -77,7 +93,7 @@ function stringifyCell(value: unknown): string { if (typeof value === "bigint") return value.toString(); if (typeof value === "number" || typeof value === "boolean") return String(value); if (typeof value === "string") return value; - if (Buffer.isBuffer(value)) return value.toString("hex"); + if (value instanceof Uint8Array) return Buffer.from(value).toString("hex"); try { return JSON.stringify(value); } catch { @@ -120,3 +136,20 @@ export function formatRowsAsAlignedTable(rows: Record[]): strin const body = cells.map((r) => r.map((cell, j) => cell.padEnd(widths[j])).join(" | ")).join("\n"); return `${header}\n${sep}\n${body}\n`; } + +/** + * Run a SQL query via sql.js and return rows as key-value objects. + * sql.js returns columnar data; this converts to the familiar row format. + */ +export function queryAsObjects(db: Database, sql: string): Record[] { + const results = db.exec(sql); + if (results.length === 0) return []; + const { columns, values } = results[0]; + return values.map((row) => { + const obj: Record = {}; + for (let i = 0; i < columns.length; i++) { + obj[columns[i]] = row[i]; + } + return obj; + }); +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 112ae6f..464898f 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -9,5 +9,5 @@ export default defineConfig({ js: "#!/usr/bin/env node", }, /** Daemon is loaded from workspace node_modules at runtime — never bundle it. */ - external: ["@uncaged/nerve-daemon", "better-sqlite3"], + external: ["@uncaged/nerve-daemon", "sql.js"], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81addff..6f81efd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,12 +23,12 @@ importers: '@uncaged/nerve-core': specifier: workspace:* version: link:../core - better-sqlite3: - specifier: ^11.10.0 - version: 11.10.0 citty: specifier: ^0.1.6 version: 0.1.6 + sql.js: + specifier: ^1.14.1 + version: 1.14.1 devDependencies: '@types/better-sqlite3': specifier: ^7.6.13 @@ -63,7 +63,7 @@ importers: version: 11.10.0 drizzle-orm: specifier: ^0.43.1 - version: 0.43.1(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0) + version: 0.43.1(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(sql.js@1.14.1) yaml: specifier: ^2.8.3 version: 2.8.3 @@ -1074,6 +1074,9 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + sql.js@1.14.1: + resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1695,10 +1698,11 @@ snapshots: detect-libc@2.1.2: {} - drizzle-orm@0.43.1(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0): + drizzle-orm@0.43.1(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(sql.js@1.14.1): optionalDependencies: '@types/better-sqlite3': 7.6.13 better-sqlite3: 11.10.0 + sql.js: 1.14.1 end-of-stream@1.4.5: dependencies: @@ -2000,6 +2004,8 @@ snapshots: source-map@0.7.6: {} + sql.js@1.14.1: {} + stackback@0.0.2: {} std-env@4.1.0: {} -- 2.43.0