refactor(daemon): upgrade Drizzle v1.0-beta + migrate better-sqlite3 → node:sqlite

- Upgrade drizzle-orm from 0.43.1 to 1.0.0-beta.23
- Replace better-sqlite3 with node:sqlite (DatabaseSync) in:
  - sense-runtime.ts (Drizzle driver)
  - log-store.ts (raw SQL)
  - all test files
- Replace sqlite.pragma() with sqlite.exec('PRAGMA ...')
- Replace sqlite.transaction() with manual BEGIN/COMMIT/ROLLBACK
- Update CLI init command to verify node:sqlite instead of better-sqlite3
- Remove better-sqlite3 and @types/better-sqlite3 from dependencies
- Zero native addons remaining in the monorepo 🎉

Closes #67

小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-04-23 09:18:37 +00:00
parent d80a414530
commit 85dd11c84d
8 changed files with 885 additions and 136 deletions
+18 -30
View File
@@ -1,4 +1,4 @@
import { spawn, execFile } from "node:child_process";
import { execFile, spawn } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
@@ -35,7 +35,7 @@ const PACKAGE_JSON = `{
"drizzle-kit": "latest"
},
"pnpm": {
"onlyBuiltDependencies": ["better-sqlite3", "esbuild"]
"onlyBuiltDependencies": ["esbuild"]
}
}
`;
@@ -218,14 +218,18 @@ const initWorkspaceCommand = defineCommand({
},
});
async function tryRequireSqlite(nerveRoot: string): Promise<boolean> {
/** Verify built-in `node:sqlite` (Node.js ≥22.5) loads in a child process. */
async function verifyNodeSqlite(): Promise<boolean> {
try {
const modulePath = join(nerveRoot, "node_modules", "better-sqlite3");
// Use a child process to test if the native module loads
await execFileAsync("node", ["-e", `require(${JSON.stringify(modulePath)})`], {
cwd: nerveRoot,
timeout: 10_000,
});
await execFileAsync(
"node",
[
"--input-type=module",
"-e",
"import { DatabaseSync } from 'node:sqlite'; new DatabaseSync(':memory:').exec('SELECT 1');",
],
{ timeout: 10_000 },
);
return true;
} catch {
return false;
@@ -264,27 +268,11 @@ async function runInitWorkspace(force: boolean): Promise<void> {
);
}
// Verify better-sqlite3 native module — rebuild up to 2 times if broken
const sqlitePath = join(nerveRoot, "node_modules", "better-sqlite3");
if (existsSync(sqlitePath)) {
for (let attempt = 1; attempt <= 2; attempt++) {
if (await tryRequireSqlite(nerveRoot)) break;
process.stdout.write(
`${attempt === 1 ? "Building" : "Retrying build of"} native module better-sqlite3 (attempt ${attempt}/2)…\n`,
);
try {
await runCommand(cmd, ["rebuild", "better-sqlite3"], nerveRoot);
} catch {
// will be caught by the verify below
}
}
if (!(await tryRequireSqlite(nerveRoot))) {
process.stdout.write(
`⚠️ better-sqlite3 native module is not working. The daemon will fail to start.\n` +
` Fix: cd ${nerveRoot} && ${cmd} rebuild better-sqlite3\n` +
` Or: npm install --build-from-source better-sqlite3\n`,
);
}
if (!(await verifyNodeSqlite())) {
process.stdout.write(
"⚠️ Built-in SQLite (node:sqlite) is not available in this Node.js build. " +
"The daemon requires Node.js 22.5 or newer with SQLite enabled.\n",
);
}
if (!existsSync(join(nerveRoot, ".git"))) {
+2 -3
View File
@@ -17,12 +17,11 @@
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.43.1",
"drizzle-orm": "1.0.0-beta.23-c10d10c",
"yaml": "^2.8.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.0.0",
"vitest": "^4.1.5"
}
}
@@ -2,7 +2,7 @@
* Unit tests for kernel.triggerSense() — IPC issue #36.
*
* These tests use a mock child_process and a mock LogStore so they do NOT
* require better-sqlite3 to be present in the test environment.
* require a real LogStore (node:sqlite) in integration tests.
*/
import { EventEmitter } from "node:events";
@@ -58,7 +58,7 @@ vi.mock("node:child_process", () => ({
const { createKernel } = await import("../kernel.js");
// ---------------------------------------------------------------------------
// Mock LogStore factory (avoids better-sqlite3 dependency)
// Mock LogStore factory (avoids SQLite I/O in this unit test)
// ---------------------------------------------------------------------------
function makeMockLogStore() {
@@ -2,8 +2,8 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { DatabaseSync } from "node:sqlite";
import { drizzle } from "drizzle-orm/node-sqlite";
import { integer, real, sqliteTable } from "drizzle-orm/sqlite-core";
import { describe, expect, it } from "vitest";
@@ -49,7 +49,7 @@ const samples = sqliteTable("samples", {
describe("runMigrations", () => {
it("creates table via SQL migration file", () => {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
const migrationsDir = makeTempMigrationsDir(INIT_SQL);
const result = runMigrations(sqlite, migrationsDir);
@@ -64,7 +64,7 @@ describe("runMigrations", () => {
});
it("runs multiple migrations in lexicographic order", () => {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
const dir = mkdtempSync(join(tmpdir(), "nerve-multi-"));
writeFileSync(join(dir, "0001_init.sql"), INIT_SQL);
@@ -81,7 +81,7 @@ describe("runMigrations", () => {
});
it("returns ok when migrations directory is empty", () => {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
const dir = makeTempMigrationsDirEmpty();
const result = runMigrations(sqlite, dir);
expect(result.ok).toBe(true);
@@ -89,14 +89,14 @@ describe("runMigrations", () => {
});
it("returns err when migrations directory does not exist", () => {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
const result = runMigrations(sqlite, "/nonexistent/path/migrations");
expect(result.ok).toBe(false);
sqlite.close();
});
it("returns err when a migration SQL is invalid", () => {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
const dir = mkdtempSync(join(tmpdir(), "nerve-bad-sql-"));
writeFileSync(join(dir, "0001_bad.sql"), "NOT VALID SQL !!!;");
const result = runMigrations(sqlite, dir);
@@ -141,7 +141,7 @@ describe("openPeerDb", () => {
it("opens an existing db in read-only mode", () => {
// Create a writable db first
const dbPath = makeTempDbPath();
const sqlite = new Database(dbPath);
const sqlite = new DatabaseSync(dbPath);
sqlite.exec(INIT_SQL);
sqlite.prepare("INSERT INTO samples (ts, value) VALUES (1, 42.0)").run();
sqlite.close();
@@ -170,11 +170,11 @@ describe("openPeerDb", () => {
describe("executeCompute", () => {
function makeRuntime(computeFn: ComputeFn): {
runtime: SenseRuntime;
sqlite: Database.Database;
sqlite: DatabaseSync;
} {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
sqlite.exec(INIT_SQL);
const db = drizzle(sqlite) as DrizzleDB;
const db = drizzle({ client: sqlite }) as DrizzleDB;
return {
runtime: { name: "test-sense", db, compute: computeFn },
sqlite,
@@ -226,10 +226,10 @@ describe("executeCompute", () => {
it("compute can read from peers", async () => {
// Set up a peer db with data
const peerSqlite = new Database(":memory:");
const peerSqlite = new DatabaseSync(":memory:");
peerSqlite.exec(INIT_SQL);
peerSqlite.prepare("INSERT INTO samples (ts, value) VALUES (100, 3.14)").run();
const peerDb = drizzle(peerSqlite) as DrizzleDB;
const peerDb = drizzle({ client: peerSqlite }) as DrizzleDB;
const peers: PeerMap = { "other-sense": peerDb };
@@ -248,9 +248,9 @@ describe("executeCompute", () => {
});
it("write to own db does not affect peer db (isolation)", async () => {
const peerSqlite = new Database(":memory:");
const peerSqlite = new DatabaseSync(":memory:");
peerSqlite.exec(INIT_SQL);
const peerDb = drizzle(peerSqlite) as DrizzleDB;
const peerDb = drizzle({ client: peerSqlite }) as DrizzleDB;
const peers: PeerMap = { "peer-sense": peerDb };
const { runtime, sqlite } = makeRuntime(async (db) => {
@@ -403,7 +403,7 @@ describe("parseParentMessage", () => {
describe("runMigrations journal", () => {
it("does not re-run an already-applied migration", () => {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
const dir = mkdtempSync(join(tmpdir(), "nerve-journal-"));
writeFileSync(join(dir, "0001_init.sql"), INIT_SQL);
@@ -430,7 +430,7 @@ describe("runMigrations journal", () => {
});
it("tracks migrations in _migrations table", () => {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
const dir = mkdtempSync(join(tmpdir(), "nerve-journal2-"));
writeFileSync(join(dir, "0001_init.sql"), INIT_SQL);
+33 -16
View File
@@ -9,8 +9,7 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import Database from "better-sqlite3";
import type BetterSqlite3 from "better-sqlite3";
import { DatabaseSync, type StatementSync } from "node:sqlite";
import {
DEFAULT_LOG_RETENTION_MS,
@@ -184,7 +183,23 @@ function buildJsonlBody(rows: SqlLogRow[]): string {
return `${lines.join("\n")}\n`;
}
function runOptionalVacuum(sqlite: BetterSqlite3.Database, vacuum?: boolean): boolean {
function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
db.exec("BEGIN IMMEDIATE");
try {
const out = fn();
db.exec("COMMIT");
return out;
} catch (e) {
try {
db.exec("ROLLBACK");
} catch {
// ignore rollback errors
}
throw e;
}
}
function runOptionalVacuum(sqlite: DatabaseSync, vacuum?: boolean): boolean {
if (vacuum !== true) return false;
sqlite.exec("VACUUM");
return true;
@@ -199,7 +214,7 @@ function resolveArchiveStartDay(watermark: string | null, minDay: string): strin
function runArchiveDayLoop(
dbPath: string,
options: ArchiveLogsOptions,
selectLogsForDayStmt: BetterSqlite3.Statement,
selectLogsForDayStmt: StatementSync,
archiveDayTx: (day: string, start: number, endExclusive: number) => void,
startDay: string,
lastDay: string,
@@ -235,8 +250,8 @@ function runArchiveDayLoop(
export function createLogStore(dbPath: string): LogStore {
mkdirSync(dirname(dbPath), { recursive: true });
const sqlite: BetterSqlite3.Database = new Database(dbPath);
sqlite.pragma("journal_mode = WAL");
const sqlite = new DatabaseSync(dbPath);
sqlite.exec("PRAGMA journal_mode=WAL");
sqlite.exec(SCHEMA_SQL);
const insertStmt = sqlite.prepare(
@@ -288,8 +303,8 @@ export function createLogStore(dbPath: string): LogStore {
"DELETE FROM logs WHERE ts >= @start AND ts < @endExclusive",
);
const upsertWorkflowRunTx = sqlite.transaction(
(entry: Omit<LogEntry, "id">, run: WorkflowRun) => {
function upsertWorkflowRunTx(entry: Omit<LogEntry, "id">, run: WorkflowRun): LogEntry {
return runInTransaction(sqlite, () => {
const info = insertStmt.run({
source: entry.source,
type: entry.type,
@@ -304,8 +319,8 @@ export function createLogStore(dbPath: string): LogStore {
ts: run.ts,
});
return { ...entry, id: Number(info.lastInsertRowid) };
},
);
});
}
function append(entry: Omit<LogEntry, "id">): LogEntry {
const info = insertStmt.run({
@@ -376,11 +391,11 @@ export function createLogStore(dbPath: string): LogStore {
}
function upsertWorkflowRun(entry: Omit<LogEntry, "id">, run: WorkflowRun): LogEntry {
return upsertWorkflowRunTx(entry, run) as LogEntry;
return upsertWorkflowRunTx(entry, run);
}
function appendWithWorkflowUpdate(entry: Omit<LogEntry, "id">, run: WorkflowRun): LogEntry {
return upsertWorkflowRunTx(entry, run) as LogEntry;
return upsertWorkflowRunTx(entry, run);
}
function getWorkflowRun(runId: string): WorkflowRun | null {
@@ -460,10 +475,12 @@ export function createLogStore(dbPath: string): LogStore {
return result;
}
const archiveDayTx = sqlite.transaction((day: string, start: number, endExclusive: number) => {
deleteLogsForDayStmt.run({ start, endExclusive });
setMetaStmt.run({ key: LOG_ARCHIVE_META_KEY, value: day });
});
function archiveDayTx(day: string, start: number, endExclusive: number): void {
runInTransaction(sqlite, () => {
deleteLogsForDayStmt.run({ start, endExclusive });
setMetaStmt.run({ key: LOG_ARCHIVE_META_KEY, value: day });
});
}
function readWatermark(): string | null {
const raw = getMeta(LOG_ARCHIVE_META_KEY);
+25 -25
View File
@@ -1,9 +1,9 @@
import { mkdirSync, readFileSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { drizzle } from "drizzle-orm/node-sqlite";
import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
import type { Result } from "@uncaged/nerve-core";
import { err, ok } from "@uncaged/nerve-core";
@@ -11,7 +11,7 @@ import { err, ok } from "@uncaged/nerve-core";
import type { BlobStore } from "./blob-store.js";
/** A Drizzle DB instance (schema-generic) */
export type DrizzleDB = BetterSQLite3Database<Record<string, never>>;
export type DrizzleDB = NodeSQLiteDatabase<Record<string, never>>;
/** Read-only map of peer sense name → their Drizzle DB */
export type PeerMap = Readonly<Record<string, DrizzleDB>>;
@@ -42,7 +42,7 @@ export type SenseRuntime = {
compute: ComputeFn;
};
function ensureMigrationsTable(sqlite: Database.Database): Result<void> {
function ensureMigrationsTable(sqlite: DatabaseSync): Result<void> {
try {
sqlite.exec(
`CREATE TABLE IF NOT EXISTS _migrations (
@@ -69,11 +69,7 @@ function listMigrationFiles(migrationsDir: string): Result<string[]> {
}
}
function applyMigrationFile(
sqlite: Database.Database,
file: string,
filePath: string,
): Result<void> {
function applyMigrationFile(sqlite: DatabaseSync, file: string, filePath: string): Result<void> {
let sql: string;
try {
sql = readFileSync(filePath, "utf8");
@@ -83,13 +79,18 @@ function applyMigrationFile(
}
const insertJournal = sqlite.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)");
sqlite.exec("BEGIN IMMEDIATE");
try {
sqlite.transaction(() => {
sqlite.exec(sql);
insertJournal.run(file, Date.now());
})();
sqlite.exec(sql);
insertJournal.run(file, Date.now());
sqlite.exec("COMMIT");
return ok(undefined);
} catch (e) {
try {
sqlite.exec("ROLLBACK");
} catch {
// ignore secondary errors during rollback
}
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Migration "${file}" failed: ${msg}`));
}
@@ -97,10 +98,10 @@ function applyMigrationFile(
/**
* Run all *.sql migration files in the given directory against a
* better-sqlite3 Database, in lexicographic order.
* `node:sqlite` DatabaseSync, in lexicographic order.
* Tracks applied migrations in _migrations table to avoid re-running.
*/
export function runMigrations(sqlite: Database.Database, migrationsDir: string): Result<void> {
export function runMigrations(sqlite: DatabaseSync, migrationsDir: string): Result<void> {
const tableResult = ensureMigrationsTable(sqlite);
if (!tableResult.ok) return tableResult;
@@ -129,14 +130,13 @@ export function runMigrations(sqlite: Database.Database, migrationsDir: string):
export function openSenseDb(
dbPath: string,
migrationsDir: string,
): Result<{ sqlite: Database.Database; db: DrizzleDB }> {
let sqlite: Database.Database;
): Result<{ sqlite: DatabaseSync; db: DrizzleDB }> {
let sqlite: DatabaseSync;
try {
mkdirSync(dirname(dbPath), { recursive: true });
sqlite = new Database(dbPath);
// WAL mode for better concurrent read performance
sqlite.pragma("journal_mode = WAL");
sqlite = new DatabaseSync(dbPath);
sqlite.exec("PRAGMA journal_mode=WAL");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to open database "${dbPath}": ${msg}`));
@@ -145,7 +145,7 @@ export function openSenseDb(
const migResult = runMigrations(sqlite, migrationsDir);
if (!migResult.ok) return migResult;
const db = drizzle(sqlite) as DrizzleDB;
const db = drizzle({ client: sqlite }) as DrizzleDB;
return ok({ sqlite, db });
}
@@ -153,16 +153,16 @@ export function openSenseDb(
* Open a peer sense DB in read-only mode (no migrations).
*/
export function openPeerDb(dbPath: string): Result<DrizzleDB> {
let sqlite: Database.Database;
let sqlite: DatabaseSync;
try {
sqlite = new Database(dbPath, { readonly: true });
sqlite = new DatabaseSync(dbPath, { readOnly: true });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to open peer database "${dbPath}" (readonly): ${msg}`));
}
return ok(drizzle(sqlite) as DrizzleDB);
return ok(drizzle({ client: sqlite }) as DrizzleDB);
}
/**
+788 -42
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -3,5 +3,4 @@ packages:
onlyBuiltDependencies:
- "@biomejs/biome"
- better-sqlite3
- esbuild