Compare commits

...

10 Commits

Author SHA1 Message Date
xiaoju 418d8ee0c8 refactor(cli): replace sql.js with node:sqlite
Drop the sql.js WASM dependency in favour of Node 22's built-in
node:sqlite (DatabaseSync). This eliminates the ~2 MB WASM binary,
removes the async init ceremony, and lets us open databases in
readonly mode directly on disk instead of loading them into memory.

Breaking: requires Node >= 22.5.0 (sqlite support).

- Remove sql.js from cli dependencies
- Rewrite sense-sqlite.ts to use DatabaseSync
- Update sense command (schema/query) — sync API, no more queryAsObjects
- Update tests to use node:sqlite directly
- Remove sql.js from tsup externals

小橘 🍊(NEKO Team)
2026-04-23 08:43:39 +00:00
xiaomo 719c4c1449 Merge pull request 'refactor(cli): replace better-sqlite3 with sql.js (pure WASM) — implements RFC #63' (#64) from refactor/sql-js-migration into main 2026-04-23 07:32:38 +00:00
xiaoju c8bf4bf547 refactor(cli): replace better-sqlite3 with sql.js (pure WASM)
- 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
2026-04-23 07:25:08 +00:00
xiaoju 9b93c4a4d9 chore(cli): bump version to 0.1.8 2026-04-23 07:10:28 +00:00
xiaomo ca14c5f51d Merge pull request 'feat(cli): add nerve sense schema and query commands (closes #60)' (#62) from feat/sense-query into main 2026-04-23 07:06:02 +00:00
xiaomo 1979e0e16c Merge pull request 'refactor: replace dynamic imports with static imports in CLI' (#61) from refactor/static-imports into main 2026-04-23 07:04:31 +00:00
xingyue 9102c6698a chore: remove gitea-access rule from project (belongs in agent local skills) 2026-04-23 15:03:14 +08:00
xingyue 6cc8833b2a chore: add cursor rules and annotate legitimate dynamic imports
- Add .cursor/rules/no-dynamic-import.mdc: ban dynamic import() in
  production code with documented exceptions
- Add .cursor/rules/gitea-access.mdc: tea CLI usage guide
- Add explanatory comments on the 2 legitimate dynamic imports in
  sense-runtime.ts and workflow-worker.ts
2026-04-23 15:00:07 +08:00
xiaomo fc76b862ad Merge pull request 'refactor(cli): replace dynamic imports with static imports — closes #57' (#59) from refactor/static-imports into main 2026-04-23 06:55:46 +00:00
xingyue 787e791aba refactor(cli): replace dynamic imports with static imports
Convert 6 unnecessary `await import()` calls for Node built-in modules
(node:child_process, node:util) and project modules (../workspace.js)
to static top-level imports in init.ts and start.ts.

Closes #57
2026-04-23 14:52:18 +08:00
11 changed files with 84 additions and 47 deletions
+34
View File
@@ -0,0 +1,34 @@
---
description: Ban dynamic import() in production code — use static imports instead
globs: packages/*/src/**/*.ts
alwaysApply: true
---
# No Dynamic Import in Production Code
## Rule
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
Always use static top-level `import` statements.
## Why
- Static imports enable tree-shaking and bundler optimizations
- They make dependencies explicit and discoverable at a glance
- Dynamic imports of Node built-ins or project modules add unnecessary async overhead
## Exceptions (must include a comment explaining why)
1. **`sense-runtime.ts`** — loads user-authored sense modules whose paths are only known at runtime
2. **`workflow-worker.ts`** — loads user-authored workflow modules whose paths are only known at runtime
When suppressing, add a comment directly above:
```ts
// Dynamic import required: user module path resolved at runtime
const mod = await import(senseIndexPath);
```
## Test Files
Test files (`__tests__/**`) are exempt — dynamic import after `vi.mock()` is standard vitest practice.
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@uncaged/nerve-cli", "name": "@uncaged/nerve-cli",
"version": "0.1.7", "version": "0.1.8",
"type": "module", "type": "module",
"bin": { "bin": {
"nerve": "dist/cli.js" "nerve": "dist/cli.js"
@@ -20,13 +20,12 @@
}, },
"dependencies": { "dependencies": {
"@uncaged/nerve-core": "workspace:*", "@uncaged/nerve-core": "workspace:*",
"better-sqlite3": "^11.10.0",
"citty": "^0.1.6" "citty": "^0.1.6"
}, },
"devDependencies": { "devDependencies": {
"@uncaged/nerve-daemon": "workspace:*",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@uncaged/nerve-daemon": "workspace:*",
"vitest": "^4.1.5" "vitest": "^4.1.5"
} }
} }
+14 -14
View File
@@ -5,8 +5,8 @@
import { mkdirSync, rmSync } from "node:fs"; import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import Database from "better-sqlite3";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { import {
@@ -47,7 +47,7 @@ describe("assertSenseDbExists", () => {
it("returns the path when the file exists", () => { it("returns the path when the file exists", () => {
const p = join(tmpDir, "data", "senses", "x.db"); const p = join(tmpDir, "data", "senses", "x.db");
new Database(p).close(); new DatabaseSync(p).close();
expect(assertSenseDbExists(tmpDir, "x")).toBe(p); expect(assertSenseDbExists(tmpDir, "x")).toBe(p);
}); });
}); });
@@ -55,9 +55,9 @@ describe("assertSenseDbExists", () => {
describe("listTableSqlStatements", () => { describe("listTableSqlStatements", () => {
it("returns CREATE statements ordered by tbl_name", () => { it("returns CREATE statements ordered by tbl_name", () => {
const p = join(tmpDir, "data", "senses", "t.db"); const p = join(tmpDir, "data", "senses", "t.db");
const db = new Database(p); const db = new DatabaseSync(p);
db.exec("CREATE TABLE zebra (id INTEGER);"); db.exec("CREATE TABLE zebra (id INTEGER)");
db.exec("CREATE TABLE alpha (id INTEGER);"); db.exec("CREATE TABLE alpha (id INTEGER)");
const stmts = listTableSqlStatements(db); const stmts = listTableSqlStatements(db);
db.close(); db.close();
expect(stmts).toHaveLength(2); expect(stmts).toHaveLength(2);
@@ -69,17 +69,17 @@ describe("listTableSqlStatements", () => {
describe("pickDefaultPreviewTable", () => { describe("pickDefaultPreviewTable", () => {
it("prefers non-_migrations tables when both exist", () => { it("prefers non-_migrations tables when both exist", () => {
const p = join(tmpDir, "data", "senses", "t.db"); const p = join(tmpDir, "data", "senses", "t.db");
const db = new Database(p); const db = new DatabaseSync(p);
db.exec(`CREATE TABLE _migrations (name TEXT PRIMARY KEY); db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)");
CREATE TABLE readings (id INTEGER);`); db.exec("CREATE TABLE readings (id INTEGER)");
expect(pickDefaultPreviewTable(db)).toBe("readings"); expect(pickDefaultPreviewTable(db)).toBe("readings");
db.close(); db.close();
}); });
it("uses _migrations when it is the only table", () => { it("uses _migrations when it is the only table", () => {
const p = join(tmpDir, "data", "senses", "t.db"); const p = join(tmpDir, "data", "senses", "t.db");
const db = new Database(p); const db = new DatabaseSync(p);
db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY);"); db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)");
expect(pickDefaultPreviewTable(db)).toBe("_migrations"); expect(pickDefaultPreviewTable(db)).toBe("_migrations");
db.close(); db.close();
}); });
@@ -140,12 +140,12 @@ describe("collectColumnKeys", () => {
describe("readonly query integration", () => { describe("readonly query integration", () => {
it("runs default preview SQL on a real db", () => { it("runs default preview SQL on a real db", () => {
const p = join(tmpDir, "data", "senses", "demo.db"); const p = join(tmpDir, "data", "senses", "demo.db");
const rw = new Database(p); const rw = new DatabaseSync(p);
rw.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, v TEXT);"); rw.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, v TEXT)");
rw.exec(`INSERT INTO items (v) VALUES ('a'), ('b');`); rw.exec("INSERT INTO items (v) VALUES ('a'), ('b')");
rw.close(); rw.close();
const db = new Database(p, { readonly: true, fileMustExist: true }); const db = new DatabaseSync(p, { readOnly: true });
const table = pickDefaultPreviewTable(db); const table = pickDefaultPreviewTable(db);
expect(table).toBe("items"); expect(table).toBe("items");
if (table === null) { if (table === null) {
+4 -8
View File
@@ -1,5 +1,7 @@
import { spawn, execFile } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { defineCommand } from "citty"; import { defineCommand } from "citty";
@@ -42,6 +44,8 @@ const GITIGNORE = `data/
node_modules/ node_modules/
`; `;
const execFileAsync = promisify(execFile);
const CPU_SCHEMA_TS = `import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; const CPU_SCHEMA_TS = `import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const cpuUsage = sqliteTable("cpu_usage", { export const cpuUsage = sqliteTable("cpu_usage", {
@@ -90,7 +94,6 @@ function writeFile(filePath: string, content: string): void {
} }
async function runCommand(cmd: string, args: string[], cwd: string): Promise<void> { async function runCommand(cmd: string, args: string[], cwd: string): Promise<void> {
const { spawn } = await import("node:child_process");
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const child = spawn(cmd, args, { cwd, stdio: "inherit" }); const child = spawn(cmd, args, { cwd, stdio: "inherit" });
child.on("close", (code) => { child.on("close", (code) => {
@@ -102,10 +105,6 @@ async function runCommand(cmd: string, args: string[], cwd: string): Promise<voi
} }
async function detectPackageManager(): Promise<{ cmd: string; installArgs: string[] }> { async function detectPackageManager(): Promise<{ cmd: string; installArgs: string[] }> {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
for (const pm of ["pnpm", "yarn", "npm"]) { for (const pm of ["pnpm", "yarn", "npm"]) {
try { try {
await execFileAsync(pm, ["--version"]); await execFileAsync(pm, ["--version"]);
@@ -223,9 +222,6 @@ async function tryRequireSqlite(nerveRoot: string): Promise<boolean> {
try { try {
const modulePath = join(nerveRoot, "node_modules", "better-sqlite3"); const modulePath = join(nerveRoot, "node_modules", "better-sqlite3");
// Use a child process to test if the native module loads // Use a child process to test if the native module loads
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
await execFileAsync("node", ["-e", `require(${JSON.stringify(modulePath)})`], { await execFileAsync("node", ["-e", `require(${JSON.stringify(modulePath)})`], {
cwd: nerveRoot, cwd: nerveRoot,
timeout: 10_000, timeout: 10_000,
+7 -10
View File
@@ -1,8 +1,8 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { type SenseInfo, parseNerveConfig } from "@uncaged/nerve-core"; import { type SenseInfo, parseNerveConfig } from "@uncaged/nerve-core";
import Database from "better-sqlite3";
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import { listSensesViaDaemon, triggerSenseViaDaemon } from "../daemon-client.js"; import { listSensesViaDaemon, triggerSenseViaDaemon } from "../daemon-client.js";
@@ -11,6 +11,7 @@ import {
defaultPreviewSql, defaultPreviewSql,
formatRowsAsAlignedTable, formatRowsAsAlignedTable,
listTableSqlStatements, listTableSqlStatements,
openSenseDb,
parseSenseQueryArgs, parseSenseQueryArgs,
pickDefaultPreviewTable, pickDefaultPreviewTable,
} from "../sense-sqlite.js"; } from "../sense-sqlite.js";
@@ -79,7 +80,6 @@ const senseListCommand = defineCommand({
}, },
async run() { async run() {
if (!isRunning()) { if (!isRunning()) {
// Daemon not running — show static info from nerve.yaml
process.stderr.write( process.stderr.write(
"⚠️ Daemon is not running — showing static config only (no last signal time).\n\n", "⚠️ Daemon is not running — showing static config only (no last signal time).\n\n",
); );
@@ -170,10 +170,9 @@ const senseSchemaCommand = defineCommand({
}, },
async run({ args }) { async run({ args }) {
const nerveRoot = getNerveRoot(); const nerveRoot = getNerveRoot();
let db: Database.Database | undefined; let db: DatabaseSync | undefined;
try { try {
const path = assertSenseDbExists(nerveRoot, args.name); db = openSenseDb(nerveRoot, args.name);
db = new Database(path, { readonly: true, fileMustExist: true });
const statements = listTableSqlStatements(db); const statements = listTableSqlStatements(db);
if (args.json) { if (args.json) {
process.stdout.write(`${JSON.stringify(statements, null, 2)}\n`); process.stdout.write(`${JSON.stringify(statements, null, 2)}\n`);
@@ -217,7 +216,7 @@ const senseQueryCommand = defineCommand({
}, },
async run({ args, rawArgs }) { async run({ args, rawArgs }) {
const nerveRoot = getNerveRoot(); const nerveRoot = getNerveRoot();
let db: Database.Database | undefined; let db: DatabaseSync | undefined;
try { try {
let parsed: { name: string; sql: string | undefined }; let parsed: { name: string; sql: string | undefined };
try { try {
@@ -228,8 +227,7 @@ const senseQueryCommand = defineCommand({
process.exit(1); process.exit(1);
} }
const path = assertSenseDbExists(nerveRoot, args.name); db = openSenseDb(nerveRoot, args.name);
db = new Database(path, { readonly: true, fileMustExist: true });
let sql = parsed.sql?.trim(); let sql = parsed.sql?.trim();
if (!sql) { if (!sql) {
@@ -242,8 +240,7 @@ const senseQueryCommand = defineCommand({
} }
} }
const stmt = db.prepare(sql); const rows = db.prepare(sql).all() as Record<string, unknown>[];
const rows = stmt.all() as Record<string, unknown>[];
if (args.json) { if (args.json) {
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`); process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
+2 -2
View File
@@ -1,3 +1,4 @@
import { spawn } from "node:child_process";
import { createWriteStream, existsSync } from "node:fs"; import { createWriteStream, existsSync } from "node:fs";
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
@@ -8,6 +9,7 @@ import { defineCommand } from "citty";
import { import {
getLogPath, getLogPath,
getNerveRoot, getNerveRoot,
getSocketPath,
isRunning, isRunning,
readPidFile, readPidFile,
removePidFile, removePidFile,
@@ -64,7 +66,6 @@ async function runDaemon(nerveRoot: string): Promise<void> {
const logPath = getLogPath(); const logPath = getLogPath();
await mkdir(join(nerveRoot, "logs"), { recursive: true }); await mkdir(join(nerveRoot, "logs"), { recursive: true });
const { spawn } = await import("node:child_process");
const logStream = createWriteStream(logPath, { flags: "a" }); const logStream = createWriteStream(logPath, { flags: "a" });
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
if (logStream.pending) logStream.once("open", () => resolve()); if (logStream.pending) logStream.once("open", () => resolve());
@@ -90,7 +91,6 @@ async function runDaemon(nerveRoot: string): Promise<void> {
writePidFile(pid); writePidFile(pid);
const { getSocketPath } = await import("../workspace.js");
const ready = await waitForSocket(getSocketPath(), 5000); const ready = await waitForSocket(getSocketPath(), 5000);
if (!ready || !isRunning()) { if (!ready || !isRunning()) {
+9 -4
View File
@@ -1,7 +1,6 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import type Database from "better-sqlite3";
/** SQLite path for a sense under the nerve workspace root. */ /** SQLite path for a sense under the nerve workspace root. */
export function senseDbPath(nerveRoot: string, senseName: string): string { export function senseDbPath(nerveRoot: string, senseName: string): string {
@@ -16,8 +15,14 @@ export function assertSenseDbExists(nerveRoot: string, senseName: string): strin
return path; return path;
} }
/** Open a sense SQLite database in readonly mode using node:sqlite. */
export function openSenseDb(nerveRoot: string, senseName: string): DatabaseSync {
const path = assertSenseDbExists(nerveRoot, senseName);
return new DatabaseSync(path, { readOnly: true });
}
/** `SELECT sql FROM sqlite_master WHERE type='table'` (non-null sql only). */ /** `SELECT sql FROM sqlite_master WHERE type='table'` (non-null sql only). */
export function listTableSqlStatements(db: Database.Database): string[] { export function listTableSqlStatements(db: DatabaseSync): string[] {
const rows = db const rows = db
.prepare( .prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL ORDER BY tbl_name`, `SELECT sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL ORDER BY tbl_name`,
@@ -30,7 +35,7 @@ export function listTableSqlStatements(db: Database.Database): string[] {
* Table used for `nerve sense query <name>` with no SQL. * Table used for `nerve sense query <name>` with no SQL.
* Prefers real data tables over `_migrations`, then lexicographic by name. * Prefers real data tables over `_migrations`, then lexicographic by name.
*/ */
export function pickDefaultPreviewTable(db: Database.Database): string | null { export function pickDefaultPreviewTable(db: DatabaseSync): string | null {
const row = db const row = db
.prepare( .prepare(
`SELECT name FROM sqlite_master `SELECT name FROM sqlite_master
+1 -1
View File
@@ -9,5 +9,5 @@ export default defineConfig({
js: "#!/usr/bin/env node", js: "#!/usr/bin/env node",
}, },
/** Daemon is loaded from workspace node_modules at runtime — never bundle it. */ /** Daemon is loaded from workspace node_modules at runtime — never bundle it. */
external: ["@uncaged/nerve-daemon", "better-sqlite3"], external: ["@uncaged/nerve-daemon"],
}); });
+1
View File
@@ -173,6 +173,7 @@ export async function loadComputeFn(senseIndexPath: string): Promise<Result<Comp
let mod: unknown; let mod: unknown;
try { try {
// Dynamic import required: user-authored sense module, path resolved at runtime
mod = await import(senseIndexPath); mod = await import(senseIndexPath);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
+1
View File
@@ -198,6 +198,7 @@ async function loadWorkflowDefinition(
); );
} }
// Dynamic import required: user-authored workflow module, path resolved at runtime
const mod = await import(indexPath); const mod = await import(indexPath);
const def: unknown = mod.default ?? mod; const def: unknown = mod.default ?? mod;
+9 -5
View File
@@ -23,9 +23,6 @@ importers:
'@uncaged/nerve-core': '@uncaged/nerve-core':
specifier: workspace:* specifier: workspace:*
version: link:../core version: link:../core
better-sqlite3:
specifier: ^11.10.0
version: 11.10.0
citty: citty:
specifier: ^0.1.6 specifier: ^0.1.6
version: 0.1.6 version: 0.1.6
@@ -63,7 +60,7 @@ importers:
version: 11.10.0 version: 11.10.0
drizzle-orm: drizzle-orm:
specifier: ^0.43.1 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: yaml:
specifier: ^2.8.3 specifier: ^2.8.3
version: 2.8.3 version: 2.8.3
@@ -1074,6 +1071,9 @@ packages:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
sql.js@1.14.1:
resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==}
stackback@0.0.2: stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -1695,10 +1695,11 @@ snapshots:
detect-libc@2.1.2: {} 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: optionalDependencies:
'@types/better-sqlite3': 7.6.13 '@types/better-sqlite3': 7.6.13
better-sqlite3: 11.10.0 better-sqlite3: 11.10.0
sql.js: 1.14.1
end-of-stream@1.4.5: end-of-stream@1.4.5:
dependencies: dependencies:
@@ -2000,6 +2001,9 @@ snapshots:
source-map@0.7.6: {} source-map@0.7.6: {}
sql.js@1.14.1:
optional: true
stackback@0.0.2: {} stackback@0.0.2: {}
std-env@4.1.0: {} std-env@4.1.0: {}