feat(daemon): Sense Runtime — Worker, IPC, Migrations, Peer Isolation #9

Merged
xiaomo merged 2 commits from feat/sense-runtime into main 2026-04-22 08:48:31 +00:00
13 changed files with 1467 additions and 19 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"files": {
"ignore": ["**/dist/**"]
"ignore": ["**/dist/**", "false/**", "**/node_modules/**"]
},
"organizeImports": {
"enabled": true
+23
View File
@@ -0,0 +1,23 @@
import { loadavg } from "node:os";
import type { DrizzleDB, PeerMap } from "@uncaged/nerve-daemon";
import { samples } from "./schema.js";
/**
* Read the 1-minute CPU load average, persist it, and emit a Signal.
*
* Returns `null` only if `loadavg` is unavailable (non-POSIX platforms).
* On every successful read a row is inserted and the value is returned,
* which causes the engine to emit a Signal.
*/
export async function compute(db: DrizzleDB, _peers: PeerMap): Promise<number | null> {
const [oneMin] = loadavg();
if (typeof oneMin !== "number" || Number.isNaN(oneMin)) {
return null;
}
await db.insert(samples).values({ ts: Date.now(), value: oneMin });
return oneMin;
}
@@ -0,0 +1,7 @@
-- Migration: 0001_init
-- Creates the samples table for the cpu-usage sense.
CREATE TABLE IF NOT EXISTS samples (
ts INTEGER PRIMARY KEY,
value REAL NOT NULL
);
+11
View File
@@ -0,0 +1,11 @@
import { integer, real, sqliteTable } from "drizzle-orm/sqlite-core";
/**
* Each row records one CPU load sample.
* `ts` is the Unix timestamp in milliseconds (primary key, append-only).
* `value` is the 1-minute load average from os.loadavg()[0].
*/
export const samples = sqliteTable("samples", {
ts: integer("ts").primaryKey(),
value: real("value").notNull(),
});
+2 -8
View File
@@ -290,17 +290,11 @@ export function parseNerveConfig(raw: string): Result<NerveConfig> {
if (!workflowsResult.ok) return workflowsResult;
// Cross-validate: workflow reflexes must reference defined workflows
const workflowNames = new Set(
workflowsResult.value ? Object.keys(workflowsResult.value) : [],
);
const workflowNames = new Set(workflowsResult.value ? Object.keys(workflowsResult.value) : []);
for (let i = 0; i < reflexesResult.value.length; i++) {
const reflex = reflexesResult.value[i];
if (reflex.kind === "workflow" && !workflowNames.has(reflex.workflow)) {
return err(
new Error(
`reflexes[${i}].workflow: "${reflex.workflow}" not found in workflows`,
),
);
return err(new Error(`reflexes[${i}].workflow: "${reflex.workflow}" not found in workflows`));
}
}
+12 -1
View File
@@ -6,6 +6,17 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsup"
"build": "tsup",
"test": "vitest run"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.43.1",
"yaml": "^2.8.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"vitest": "^4.1.5"
}
}
@@ -0,0 +1,432 @@
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 { integer, real, sqliteTable } from "drizzle-orm/sqlite-core";
import { describe, expect, it } from "vitest";
import { parseParentMessage } from "../ipc.js";
import { executeCompute, openPeerDb, openSenseDb, runMigrations } from "../sense-runtime.js";
import type { DrizzleDB, PeerMap, SenseRuntime } from "../sense-runtime.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const INIT_SQL = `
CREATE TABLE IF NOT EXISTS samples (
ts INTEGER PRIMARY KEY,
value REAL NOT NULL
);
`;
function makeTempMigrationsDir(sql: string): string {
const dir = mkdtempSync(join(tmpdir(), "nerve-test-"));
writeFileSync(join(dir, "0001_init.sql"), sql);
return dir;
}
function makeTempMigrationsDirEmpty(): string {
return mkdtempSync(join(tmpdir(), "nerve-test-empty-"));
}
function makeTempDbPath(): string {
const dir = mkdtempSync(join(tmpdir(), "nerve-db-"));
return join(dir, "test.db");
}
const samples = sqliteTable("samples", {
ts: integer("ts").primaryKey(),
value: real("value").notNull(),
});
// ---------------------------------------------------------------------------
// runMigrations
// ---------------------------------------------------------------------------
describe("runMigrations", () => {
it("creates table via SQL migration file", () => {
const sqlite = new Database(":memory:");
const migrationsDir = makeTempMigrationsDir(INIT_SQL);
const result = runMigrations(sqlite, migrationsDir);
expect(result.ok).toBe(true);
const row = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='samples'")
.get();
expect(row).toBeDefined();
sqlite.close();
});
it("runs multiple migrations in lexicographic order", () => {
const sqlite = new Database(":memory:");
const dir = mkdtempSync(join(tmpdir(), "nerve-multi-"));
writeFileSync(join(dir, "0001_init.sql"), INIT_SQL);
writeFileSync(join(dir, "0002_add_col.sql"), "ALTER TABLE samples ADD COLUMN label TEXT;");
const result = runMigrations(sqlite, dir);
expect(result.ok).toBe(true);
const info = sqlite.prepare("PRAGMA table_info(samples)").all() as Array<{ name: string }>;
const cols = info.map((r) => r.name);
expect(cols).toContain("label");
sqlite.close();
});
it("returns ok when migrations directory is empty", () => {
const sqlite = new Database(":memory:");
const dir = makeTempMigrationsDirEmpty();
const result = runMigrations(sqlite, dir);
expect(result.ok).toBe(true);
sqlite.close();
});
it("returns err when migrations directory does not exist", () => {
const sqlite = new Database(":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 dir = mkdtempSync(join(tmpdir(), "nerve-bad-sql-"));
writeFileSync(join(dir, "0001_bad.sql"), "NOT VALID SQL !!!;");
const result = runMigrations(sqlite, dir);
expect(result.ok).toBe(false);
sqlite.close();
});
});
// ---------------------------------------------------------------------------
// openSenseDb
// ---------------------------------------------------------------------------
describe("openSenseDb", () => {
it("creates the db file and runs migrations", () => {
const dbPath = makeTempDbPath();
const migrationsDir = makeTempMigrationsDir(INIT_SQL);
const result = openSenseDb(dbPath, migrationsDir);
expect(result.ok).toBe(true);
if (!result.ok) return;
const { sqlite } = result.value;
const row = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='samples'")
.get();
expect(row).toBeDefined();
sqlite.close();
});
it("returns err when migrations dir is missing", () => {
const dbPath = makeTempDbPath();
const result = openSenseDb(dbPath, "/nonexistent/migrations");
expect(result.ok).toBe(false);
});
});
// ---------------------------------------------------------------------------
// openPeerDb
// ---------------------------------------------------------------------------
describe("openPeerDb", () => {
it("opens an existing db in read-only mode", () => {
// Create a writable db first
const dbPath = makeTempDbPath();
const sqlite = new Database(dbPath);
sqlite.exec(INIT_SQL);
sqlite.prepare("INSERT INTO samples (ts, value) VALUES (1, 42.0)").run();
sqlite.close();
const result = openPeerDb(dbPath);
expect(result.ok).toBe(true);
if (!result.ok) return;
// Should be able to read
const peerDb = result.value;
const rows = peerDb.select().from(samples).all();
expect(rows).toHaveLength(1);
expect(rows[0].value).toBe(42.0);
});
it("returns err when db path does not exist", () => {
const result = openPeerDb("/nonexistent/path/to/peer.db");
expect(result.ok).toBe(false);
});
});
// ---------------------------------------------------------------------------
// executeCompute
// ---------------------------------------------------------------------------
describe("executeCompute", () => {
function makeRuntime(computeFn: (db: DrizzleDB, peers: PeerMap) => Promise<unknown | null>): {
runtime: SenseRuntime;
sqlite: Database.Database;
} {
const sqlite = new Database(":memory:");
sqlite.exec(INIT_SQL);
const db = drizzle(sqlite) as DrizzleDB;
return {
runtime: { name: "test-sense", db, compute: computeFn },
sqlite,
};
}
const emptyPeers: PeerMap = {};
it("returns the payload when compute returns a non-null value", async () => {
const { runtime, sqlite } = makeRuntime(async (db) => {
await db.insert(samples).values({ ts: Date.now(), value: 0.5 });
return 0.5;
});
const result = await executeCompute(runtime, emptyPeers);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toBe(0.5);
const rows = sqlite.prepare("SELECT * FROM samples").all();
expect(rows).toHaveLength(1);
sqlite.close();
});
it("returns null (no signal) when compute returns null", async () => {
const { runtime, sqlite } = makeRuntime(async () => null);
const result = await executeCompute(runtime, emptyPeers);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toBeNull();
const rows = sqlite.prepare("SELECT * FROM samples").all();
expect(rows).toHaveLength(0);
sqlite.close();
});
it("returns err when compute throws", async () => {
const { runtime, sqlite } = makeRuntime(async () => {
throw new Error("something went wrong");
});
const result = await executeCompute(runtime, emptyPeers);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toContain("something went wrong");
sqlite.close();
});
it("compute can read from peers", async () => {
// Set up a peer db with data
const peerSqlite = new Database(":memory:");
peerSqlite.exec(INIT_SQL);
peerSqlite.prepare("INSERT INTO samples (ts, value) VALUES (100, 3.14)").run();
const peerDb = drizzle(peerSqlite) as DrizzleDB;
const peers: PeerMap = { "other-sense": peerDb };
const { runtime, sqlite } = makeRuntime(async (_db, p) => {
const rows = await p["other-sense"].select().from(samples).all();
return rows.length > 0 ? rows[0].value : null;
});
const result = await executeCompute(runtime, peers);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toBe(3.14);
peerSqlite.close();
sqlite.close();
});
it("write to own db does not affect peer db (isolation)", async () => {
const peerSqlite = new Database(":memory:");
peerSqlite.exec(INIT_SQL);
const peerDb = drizzle(peerSqlite) as DrizzleDB;
const peers: PeerMap = { "peer-sense": peerDb };
const { runtime, sqlite } = makeRuntime(async (db) => {
await db.insert(samples).values({ ts: 999, value: 9.9 });
return 9.9;
});
await executeCompute(runtime, peers);
const peerRows = peerSqlite.prepare("SELECT * FROM samples").all();
expect(peerRows).toHaveLength(0);
const ownRows = sqlite.prepare("SELECT * FROM samples").all();
expect(ownRows).toHaveLength(1);
peerSqlite.close();
sqlite.close();
});
it("inserts correctly into the sense db directory path", async () => {
const dbPath = makeTempDbPath();
const migrationsDir = makeTempMigrationsDir(INIT_SQL);
const dbResult = openSenseDb(dbPath, migrationsDir);
expect(dbResult.ok).toBe(true);
if (!dbResult.ok) return;
mkdirSync(join(dbPath, "..", "migrations"), { recursive: true });
const { sqlite: dbSqlite, db } = dbResult.value;
const runtime: SenseRuntime = {
name: "cpu-usage",
db,
compute: async (d) => {
await d.insert(samples).values({ ts: 1000, value: 1.23 });
return 1.23;
},
};
const result = await executeCompute(runtime, {});
expect(result.ok).toBe(true);
const rows = dbSqlite.prepare("SELECT * FROM samples").all() as Array<{
ts: number;
value: number;
}>;
expect(rows).toHaveLength(1);
expect(rows[0].ts).toBe(1000);
expect(rows[0].value).toBe(1.23);
dbSqlite.close();
});
it("returns err when compute exceeds timeoutMs", async () => {
const { runtime, sqlite } = makeRuntime(
(_db, _peers, options) =>
new Promise<null>((resolve, reject) => {
const t = setTimeout(() => resolve(null), 5_000);
options?.signal.addEventListener("abort", () => {
clearTimeout(t);
reject(new Error("aborted"));
});
}),
);
const result = await executeCompute(runtime, emptyPeers, 50);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/timed out/i);
sqlite.close();
});
it("completes within timeout when compute is fast", async () => {
const { runtime, sqlite } = makeRuntime(async () => 42);
const result = await executeCompute(runtime, emptyPeers, 5_000);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toBe(42);
sqlite.close();
});
it("passes AbortSignal to compute fn", async () => {
let capturedSignal: AbortSignal | undefined;
const { runtime, sqlite } = makeRuntime(async (_db, _peers, options) => {
capturedSignal = options?.signal;
return null;
});
await executeCompute(runtime, emptyPeers, 1_000);
expect(capturedSignal).toBeInstanceOf(AbortSignal);
sqlite.close();
});
});
// ---------------------------------------------------------------------------
// parseParentMessage (IPC validation)
// ---------------------------------------------------------------------------
describe("parseParentMessage", () => {
it("accepts a valid compute message", () => {
const result = parseParentMessage({ type: "compute", sense: "cpu" });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.type).toBe("compute");
});
it("accepts a valid shutdown message", () => {
const result = parseParentMessage({ type: "shutdown" });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.type).toBe("shutdown");
});
it("returns err for non-object input", () => {
expect(parseParentMessage(null).ok).toBe(false);
expect(parseParentMessage("string").ok).toBe(false);
expect(parseParentMessage(42).ok).toBe(false);
});
it("returns err when type field is missing", () => {
const result = parseParentMessage({ sense: "cpu" });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/type/);
});
it("returns err for unknown type value", () => {
const result = parseParentMessage({ type: "unknown" });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/unknown/i);
});
});
// ---------------------------------------------------------------------------
// runMigrations – journal (idempotency)
// ---------------------------------------------------------------------------
describe("runMigrations journal", () => {
it("does not re-run an already-applied migration", () => {
const sqlite = new Database(":memory:");
const dir = mkdtempSync(join(tmpdir(), "nerve-journal-"));
writeFileSync(join(dir, "0001_init.sql"), INIT_SQL);
const first = runMigrations(sqlite, dir);
expect(first.ok).toBe(true);
// Insert a row so we can verify second run doesn't fail on CREATE TABLE
sqlite.exec("INSERT INTO samples (ts, value) VALUES (1, 1.0)");
// Run again — migration must NOT re-run (would fail without IF NOT EXISTS but
// the journal prevents it even for non-idempotent SQL)
const nonIdempotentSql = "CREATE TABLE samples2 (id INTEGER PRIMARY KEY)";
writeFileSync(join(dir, "0002_samples2.sql"), nonIdempotentSql);
// First time: creates samples2
const second = runMigrations(sqlite, dir);
expect(second.ok).toBe(true);
// Second time: 0002 already in journal, must not re-run
const third = runMigrations(sqlite, dir);
expect(third.ok).toBe(true);
sqlite.close();
});
it("tracks migrations in _migrations table", () => {
const sqlite = new Database(":memory:");
const dir = mkdtempSync(join(tmpdir(), "nerve-journal2-"));
writeFileSync(join(dir, "0001_init.sql"), INIT_SQL);
runMigrations(sqlite, dir);
const rows = sqlite.prepare("SELECT name FROM _migrations ORDER BY name").all() as Array<{
name: string;
}>;
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe("0001_init.sql");
sqlite.close();
});
});
+19 -1
View File
@@ -1 +1,19 @@
// TODO: implement
export type {
ComputeMessage,
ShutdownMessage,
ParentToWorkerMessage,
SignalMessage,
ErrorMessage,
ReadyMessage,
WorkerToParentMessage,
} from "./ipc.js";
export type { ComputeFn, DrizzleDB, PeerMap, SenseRuntime } from "./sense-runtime.js";
export {
runMigrations,
openSenseDb,
openPeerDb,
loadComputeFn,
executeCompute,
} from "./sense-runtime.js";
+59
View File
@@ -0,0 +1,59 @@
/**
* IPC message types for parent (kernel) ↔ sense worker communication.
* Protocol per RFC §5.2: hub-and-spoke, all messages through engine.
*/
import type { Result } from "@uncaged/nerve-core";
import { err, ok } from "@uncaged/nerve-core";
/** Parent → Worker: trigger one compute cycle for a sense */
export type ComputeMessage = {
type: "compute";
sense: string;
};
/** Parent → Worker: graceful shutdown */
export type ShutdownMessage = {
type: "shutdown";
};
/** Union of all messages the parent sends to a worker */
export type ParentToWorkerMessage = ComputeMessage | ShutdownMessage;
/** Worker → Parent: compute produced a signal */
export type SignalMessage = {
type: "signal";
sense: string;
payload: unknown;
};
/** Worker → Parent: compute threw or returned an unexpected error */
export type ErrorMessage = {
type: "error";
sense: string;
error: string;
};
/** Worker → Parent: worker finished bootstrap and is ready to receive compute */
export type ReadyMessage = {
type: "ready";
};
/** Union of all messages a worker sends to the parent */
export type WorkerToParentMessage = SignalMessage | ErrorMessage | ReadyMessage;
/** Validate and parse an unknown IPC message received from the parent process. */
export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage> {
if (raw === null || typeof raw !== "object") {
return err(new Error("IPC message is not an object"));
}
const obj = raw as Record<string, unknown>;
if (typeof obj.type !== "string") {
return err(new Error("IPC message missing string 'type' field"));
}
const type = obj.type;
if (type !== "compute" && type !== "shutdown") {
return err(new Error(`Unknown IPC message type: "${type}"`));
}
return ok(raw as ParentToWorkerMessage);
}
+229
View File
@@ -0,0 +1,229 @@
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import type { Result } from "@uncaged/nerve-core";
import { err, ok } from "@uncaged/nerve-core";
/** A Drizzle DB instance (schema-generic) */
export type DrizzleDB = BetterSQLite3Database<Record<string, never>>;
/** Read-only map of peer sense name → their Drizzle DB */
export type PeerMap = Readonly<Record<string, DrizzleDB>>;
/** Options passed to a compute function */
export type ComputeOptions = {
signal: AbortSignal;
};
/**
* The shape every sense's index.ts must export.
* Engine injects `db` (read-write), `peers` (read-only), and `options`.
* Returns T when a signal should be emitted, null for silence.
*/
export type ComputeFn<T = unknown> = (
db: DrizzleDB,
peers: PeerMap,
options?: ComputeOptions,
) => Promise<T | null>;
/** All state held for one sense inside a worker */
export type SenseRuntime = {
name: string;
Outdated
Review

🔴 缺 migration journal. 每次启动全量重跑所有 SQL,非幂等 migration 会崩。建议加:

CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at INTEGER);

跑之前检查已执行的,跳过。

🔴 **缺 migration journal.** 每次启动全量重跑所有 SQL,非幂等 migration 会崩。建议加: ```sql CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at INTEGER); ``` 跑之前检查已执行的,跳过。
db: DrizzleDB;
compute: ComputeFn;
};
function ensureMigrationsTable(sqlite: Database.Database): Result<void> {
try {
sqlite.exec(
`CREATE TABLE IF NOT EXISTS _migrations (
name TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
)`,
);
return ok(undefined);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to create _migrations table: ${msg}`));
}
}
function listMigrationFiles(migrationsDir: string): Result<string[]> {
try {
const files = readdirSync(migrationsDir)
.filter((f) => f.endsWith(".sql"))
.sort();
return ok(files);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to read migrations directory "${migrationsDir}": ${msg}`));
}
}
function applyMigrationFile(
sqlite: Database.Database,
file: string,
filePath: string,
): Result<void> {
let sql: string;
try {
sql = readFileSync(filePath, "utf8");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to read migration file "${filePath}": ${msg}`));
}
const insertJournal = sqlite.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)");
try {
sqlite.transaction(() => {
sqlite.exec(sql);
insertJournal.run(file, Date.now());
})();
return ok(undefined);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Migration "${file}" failed: ${msg}`));
}
}
/**
* Run all *.sql migration files in the given directory against a
* better-sqlite3 Database, in lexicographic order.
* Tracks applied migrations in _migrations table to avoid re-running.
*/
export function runMigrations(sqlite: Database.Database, migrationsDir: string): Result<void> {
const tableResult = ensureMigrationsTable(sqlite);
if (!tableResult.ok) return tableResult;
const filesResult = listMigrationFiles(migrationsDir);
if (!filesResult.ok) return filesResult;
const applied = new Set<string>(
(sqlite.prepare("SELECT name FROM _migrations").all() as Array<{ name: string }>).map(
(r) => r.name,
),
);
for (const file of filesResult.value) {
if (applied.has(file)) continue;
const result = applyMigrationFile(sqlite, file, join(migrationsDir, file));
if (!result.ok) return result;
}
return ok(undefined);
}
/**
* Open (or create) the SQLite file at `dbPath`, run all migrations in
* `migrationsDir`, and wrap with Drizzle ORM.
*/
export function openSenseDb(
dbPath: string,
migrationsDir: string,
): Result<{ sqlite: Database.Database; db: DrizzleDB }> {
let sqlite: Database.Database;
try {
sqlite = new Database(dbPath);
// WAL mode for better concurrent read performance
sqlite.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}`));
}
const migResult = runMigrations(sqlite, migrationsDir);
if (!migResult.ok) return migResult;
const db = drizzle(sqlite) as DrizzleDB;
return ok({ sqlite, db });
}
Outdated
Review

🔴 没有 timeout 机制。 RFC §5.3 要求 soft timeout abort + grace_period hard kill。建议:

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
  const result = await computeFn(db, peers, { signal: controller.signal });
  // ...
} finally {
  clearTimeout(timer);
}
🔴 **没有 timeout 机制。** RFC §5.3 要求 soft timeout abort + grace_period hard kill。建议: ```typescript const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const result = await computeFn(db, peers, { signal: controller.signal }); // ... } finally { clearTimeout(timer); } ```
/**
* Open a peer sense DB in read-only mode (no migrations).
*/
export function openPeerDb(dbPath: string): Result<DrizzleDB> {
let sqlite: Database.Database;
try {
sqlite = new Database(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);
}
/**
* Dynamically import the compute function from a sense's index.ts/js.
* The module must export a named `compute` function.
*/
export async function loadComputeFn(senseIndexPath: string): Promise<Result<ComputeFn>> {
let mod: unknown;
try {
mod = await import(senseIndexPath);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to import sense module "${senseIndexPath}": ${msg}`));
}
if (
mod === null ||
typeof mod !== "object" ||
!("compute" in mod) ||
typeof (mod as Record<string, unknown>).compute !== "function"
) {
return err(
new Error(`Sense module "${senseIndexPath}" must export a named "compute" function`),
);
}
return ok((mod as { compute: ComputeFn }).compute);
}
/**
* Execute a sense's compute function with an optional soft timeout.
* If timeoutMs is provided and compute takes longer, the AbortSignal is
* triggered and an error Result is returned.
*/
export async function executeCompute(
runtime: SenseRuntime,
peers: PeerMap,
timeoutMs?: number,
): Promise<Result<unknown | null>> {
const controller = new AbortController();
const options: ComputeOptions = { signal: controller.signal };
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise =
timeoutMs !== undefined
? new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new Error(`compute("${runtime.name}") timed out after ${timeoutMs}ms`));
}, timeoutMs);
})
: null;
try {
const computePromise = runtime.compute(runtime.db, peers, options);
const result = timeoutPromise
? await Promise.race([computePromise, timeoutPromise])
: await computePromise;
return ok(result);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (controller.signal.aborted) {
return err(new Error(`compute("${runtime.name}") timed out after ${timeoutMs as number}ms`));
}
return err(new Error(`compute("${runtime.name}") threw: ${msg}`));
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
+260
View File
@@ -0,0 +1,260 @@
/**
* Sense Worker runtime bootstrap.
*
* Entry point for `nerve worker sense --group <name>`.
* Receives the group name via CLI args, reads nerve.yaml, initialises one
* SenseRuntime per sense in the group, builds peer read-only connections,
* then signals ready and enters the IPC event loop.
*
* Layout assumptions (nerve user config at `~/.uncaged-nerve/`):
* senses/<name>/index.js ← compiled compute
* senses/<name>/migrations/ ← SQL migration files
* data/senses/<name>.db ← SQLite data file
* nerve.yaml ← config
*/
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { parseNerveConfig } from "@uncaged/nerve-core";
import type { NerveConfig } from "@uncaged/nerve-core";
import type { WorkerToParentMessage } from "./ipc.js";
import { parseParentMessage } from "./ipc.js";
import { executeCompute, loadComputeFn, openPeerDb, openSenseDb } from "./sense-runtime.js";
import type { DrizzleDB, PeerMap, SenseRuntime } from "./sense-runtime.js";
// ---------------------------------------------------------------------------
// IPC helpers
// ---------------------------------------------------------------------------
function send(msg: WorkerToParentMessage): void {
if (process.send) {
process.send(msg);
}
}
function sendReady(): void {
send({ type: "ready" });
}
function sendSignal(sense: string, payload: unknown): void {
send({ type: "signal", sense, payload });
}
function sendError(sense: string, error: string): void {
send({ type: "error", sense, error });
}
// ---------------------------------------------------------------------------
// Initialisation helpers (each extracted to keep bootstrap complexity low)
// ---------------------------------------------------------------------------
function readConfig(nerveRoot: string): NerveConfig {
const configPath = join(nerveRoot, "nerve.yaml");
let configRaw: string;
try {
configRaw = readFileSync(configPath, "utf8");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[sense-worker] Cannot read ${configPath}: ${msg}\n`);
process.exit(1);
}
const configResult = parseNerveConfig(configRaw);
if (!configResult.ok) {
process.stderr.write(`[sense-worker] Config parse error: ${configResult.error.message}\n`);
process.exit(1);
}
return configResult.value;
}
async function initSense(
nerveRoot: string,
senseName: string,
): Promise<{ db: DrizzleDB; runtime: SenseRuntime }> {
const dbPath = join(nerveRoot, "data", "senses", `${senseName}.db`);
const migrationsDir = join(nerveRoot, "senses", senseName, "migrations");
const senseIndexPath = resolve(join(nerveRoot, "senses", senseName, "index.js"));
const dbResult = openSenseDb(dbPath, migrationsDir);
if (!dbResult.ok) {
process.stderr.write(
`[sense-worker] Failed to init DB for "${senseName}": ${dbResult.error.message}\n`,
);
process.exit(1);
}
const computeResult = await loadComputeFn(senseIndexPath);
if (!computeResult.ok) {
process.stderr.write(
`[sense-worker] Failed to load compute for "${senseName}": ${computeResult.error.message}\n`,
);
process.exit(1);
}
const { db } = dbResult.value;
return { db, runtime: { name: senseName, db, compute: computeResult.value } };
}
function buildPeers(
nerveRoot: string,
allSenseNames: string[],
ownDbs: Map<string, DrizzleDB>,
groupSenseNames: Set<string>,
): PeerMap {
const entries: [string, DrizzleDB][] = [];
Outdated
Review

⚠️ 自身 sense 的 DB(read-write)不应出现在 peers map 里。要么排除,要么开独立只读连接。

⚠️ 自身 sense 的 DB(read-write)不应出现在 peers map 里。要么排除,要么开独立只读连接。
for (const peerName of allSenseNames) {
// Exclude senses that belong to this worker's own group — they are not peers
if (groupSenseNames.has(peerName)) continue;
const own = ownDbs.get(peerName);
if (own !== undefined) {
entries.push([peerName, own]);
continue;
}
const peerDbPath = join(nerveRoot, "data", "senses", `${peerName}.db`);
const peerResult = openPeerDb(peerDbPath);
if (!peerResult.ok) {
process.stderr.write(
`[sense-worker] Warning: could not open peer DB for "${peerName}": ${peerResult.error.message}\n`,
);
continue;
}
entries.push([peerName, peerResult.value]);
}
return Object.fromEntries(entries);
}
function handleMessage(
raw: unknown,
Outdated
Review

🔴 Unsafe cast. raw as ParentToWorkerMessage 不做校验。建议加一个 parseParentMessage(raw): Result<ParentToWorkerMessage> 校验 type 字段。

🔴 **Unsafe cast.** `raw as ParentToWorkerMessage` 不做校验。建议加一个 `parseParentMessage(raw): Result<ParentToWorkerMessage>` 校验 `type` 字段。
runtimes: Map<string, SenseRuntime>,
peers: PeerMap,
group: string,
timeoutMs: number,
inFlight: Map<string, Promise<void>>,
): void {
const parseResult = parseParentMessage(raw);
if (!parseResult.ok) {
process.stderr.write(`[sense-worker] Invalid IPC message: ${parseResult.error.message}\n`);
return;
}
const msg = parseResult.value;
Outdated
Review

⚠️ 两个问题:(1) 并发 compute 没串行化,同一 sense 连续触发会 race;(2) .then() 没有 .catch(),unhandled rejection 风险。

⚠️ 两个问题:(1) 并发 compute 没串行化,同一 sense 连续触发会 race;(2) `.then()` 没有 `.catch()`,unhandled rejection 风险。
if (msg.type === "shutdown") {
process.exit(0);
}
if (msg.type === "compute") {
const runtime = runtimes.get(msg.sense);
if (!runtime) {
sendError(msg.sense, `Unknown sense "${msg.sense}" in group "${group}"`);
return;
}
// Serialize computes for the same sense
const previous = inFlight.get(msg.sense) ?? Promise.resolve();
const next = previous.then(async () => {
const result = await executeCompute(runtime, peers, timeoutMs);
if (!result.ok) {
sendError(msg.sense, result.error.message);
return;
}
if (result.value !== null) {
sendSignal(msg.sense, result.value);
}
});
const tracked = next.catch((e: unknown) => {
const errMsg = e instanceof Error ? e.message : String(e);
sendError(msg.sense, errMsg);
});
inFlight.set(msg.sense, tracked);
}
}
// ---------------------------------------------------------------------------
// Bootstrap
// ---------------------------------------------------------------------------
const DEFAULT_TIMEOUT_MS = 30_000;
async function bootstrap(nerveRoot: string, group: string): Promise<void> {
const config = readConfig(nerveRoot);
const groupSenses = Object.keys(config.senses).filter(
(name) => config.senses[name].group === group,
);
if (groupSenses.length === 0) {
process.stderr.write(`[sense-worker] No senses found for group "${group}"\n`);
process.exit(1);
}
const runtimes = new Map<string, SenseRuntime>();
const ownDbs = new Map<string, DrizzleDB>();
for (const senseName of groupSenses) {
const { db, runtime } = await initSense(nerveRoot, senseName);
ownDbs.set(senseName, db);
runtimes.set(senseName, runtime);
}
const groupSenseNames = new Set(groupSenses);
const peers = buildPeers(nerveRoot, Object.keys(config.senses), ownDbs, groupSenseNames);
// Read timeout from config (uses first group sense's config, or default)
const firstSenseConfig = config.senses[groupSenses[0]];
const timeoutMs =
typeof (firstSenseConfig as Record<string, unknown>).timeoutMs === "number"
? ((firstSenseConfig as Record<string, unknown>).timeoutMs as number)
: DEFAULT_TIMEOUT_MS;
const inFlight = new Map<string, Promise<void>>();
sendReady();
process.on("message", (raw: unknown) => {
handleMessage(raw, runtimes, peers, group, timeoutMs, inFlight);
});
}
// ---------------------------------------------------------------------------
// CLI entrypoint
// ---------------------------------------------------------------------------
function parseArgs(): { nerveRoot: string; group: string } | null {
const args = process.argv.slice(2);
let group: string | null = null;
let nerveRoot: string | null = null;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--group" && i + 1 < args.length) {
group = args[i + 1];
i++;
} else if (args[i] === "--root" && i + 1 < args.length) {
nerveRoot = args[i + 1];
i++;
}
}
if (!group || !nerveRoot) return null;
return { nerveRoot, group };
}
const parsed = parseArgs();
if (!parsed) {
process.stderr.write("Usage: sense-worker --group <name> --root <nerve-root-dir>\n");
process.exit(1);
}
bootstrap(parsed.nerveRoot, parsed.group).catch((e) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[sense-worker] Unhandled bootstrap error: ${msg}\n`);
process.exit(1);
});
+407 -8
View File
@@ -28,9 +28,29 @@ importers:
devDependencies:
vitest:
specifier: ^4.1.5
version: 4.1.5(vite@8.0.9(esbuild@0.27.7)(yaml@2.8.3))
version: 4.1.5(@types/node@25.6.0)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(yaml@2.8.3))
packages/daemon: {}
packages/daemon:
dependencies:
'@uncaged/nerve-core':
specifier: workspace:*
version: link:../core
better-sqlite3:
specifier: ^11.10.0
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)
yaml:
specifier: ^2.8.3
version: 2.8.3
devDependencies:
'@types/better-sqlite3':
specifier: ^7.6.13
version: 7.6.13
vitest:
specifier: ^4.1.5
version: 4.1.5(@types/node@25.6.0)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(yaml@2.8.3))
packages:
@@ -520,6 +540,9 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -529,6 +552,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@vitest/expect@4.1.5':
resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==}
@@ -570,6 +596,21 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
better-sqlite3@11.10.0:
resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
bundle-require@5.1.0:
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -588,6 +629,9 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
@@ -611,10 +655,110 @@ packages:
supports-color:
optional: true
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
drizzle-orm@0.43.1:
resolution: {integrity: sha512-dUcDaZtE/zN4RV/xqGrVSMpnEczxd5cIaoDeor7Zst9wOe/HzC/7eAaulywWGYXdDEc9oBPMjayVEDg0ziTLJA==}
peerDependencies:
'@aws-sdk/client-rds-data': '>=3'
'@cloudflare/workers-types': '>=4'
'@electric-sql/pglite': '>=0.2.0'
'@libsql/client': '>=0.10.0'
'@libsql/client-wasm': '>=0.10.0'
'@neondatabase/serverless': '>=0.10.0'
'@op-engineering/op-sqlite': '>=2'
'@opentelemetry/api': ^1.4.1
'@planetscale/database': '>=1.13'
'@prisma/client': '*'
'@tidbcloud/serverless': '*'
'@types/better-sqlite3': '*'
'@types/pg': '*'
'@types/sql.js': '*'
'@vercel/postgres': '>=0.8.0'
'@xata.io/client': '*'
better-sqlite3: '>=7'
bun-types: '*'
expo-sqlite: '>=14.0.0'
gel: '>=2'
knex: '*'
kysely: '*'
mysql2: '>=2'
pg: '>=8'
postgres: '>=3'
prisma: '*'
sql.js: '>=1'
sqlite3: '>=5'
peerDependenciesMeta:
'@aws-sdk/client-rds-data':
optional: true
'@cloudflare/workers-types':
optional: true
'@electric-sql/pglite':
optional: true
'@libsql/client':
optional: true
'@libsql/client-wasm':
optional: true
'@neondatabase/serverless':
optional: true
'@op-engineering/op-sqlite':
optional: true
'@opentelemetry/api':
optional: true
'@planetscale/database':
optional: true
'@prisma/client':
optional: true
'@tidbcloud/serverless':
optional: true
'@types/better-sqlite3':
optional: true
'@types/pg':
optional: true
'@types/sql.js':
optional: true
'@vercel/postgres':
optional: true
'@xata.io/client':
optional: true
better-sqlite3:
optional: true
bun-types:
optional: true
expo-sqlite:
optional: true
gel:
optional: true
knex:
optional: true
kysely:
optional: true
mysql2:
optional: true
pg:
optional: true
postgres:
optional: true
prisma:
optional: true
sql.js:
optional: true
sqlite3:
optional: true
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
es-module-lexer@2.0.0:
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
@@ -626,6 +770,10 @@ packages:
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -639,14 +787,32 @@ packages:
picomatch:
optional: true
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
fix-dts-default-cjs-exports@1.0.1:
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
@@ -739,6 +905,16 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
@@ -753,6 +929,13 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
node-abi@3.89.0:
resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==}
engines: {node: '>=10'}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -760,6 +943,9 @@ packages:
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -799,6 +985,23 @@ packages:
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@@ -817,9 +1020,23 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -834,11 +1051,25 @@ packages:
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
sucrase@3.35.1:
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
engines: {node: '>=16 || 14 >=14.17'}
hasBin: true
tar-fs@2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
@@ -893,6 +1124,9 @@ packages:
typescript:
optional: true
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -901,6 +1135,12 @@ packages:
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite@8.0.9:
resolution: {integrity: sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -990,6 +1230,9 @@ packages:
engines: {node: '>=8'}
hasBin: true
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
yaml@2.8.3:
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
engines: {node: '>= 14.6'}
@@ -1282,6 +1525,10 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 25.6.0
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -1291,6 +1538,10 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/node@25.6.0':
dependencies:
undici-types: 7.19.2
'@vitest/expect@4.1.5':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -1300,13 +1551,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.5(vite@8.0.9(esbuild@0.27.7)(yaml@2.8.3))':
'@vitest/mocker@4.1.5(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 4.1.5
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.9(esbuild@0.27.7)(yaml@2.8.3)
vite: 8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(yaml@2.8.3)
'@vitest/pretty-format@4.1.5':
dependencies:
@@ -1338,6 +1589,28 @@ snapshots:
assertion-error@2.0.1: {}
base64-js@1.5.1: {}
better-sqlite3@11.10.0:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
bundle-require@5.1.0(esbuild@0.27.7):
dependencies:
esbuild: 0.27.7
@@ -1351,6 +1624,8 @@ snapshots:
dependencies:
readdirp: 4.1.2
chownr@1.1.4: {}
commander@4.1.1: {}
confbox@0.1.8: {}
@@ -1363,8 +1638,23 @@ snapshots:
dependencies:
ms: 2.1.3
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
deep-extend@0.6.0: {}
detect-libc@2.1.2: {}
drizzle-orm@0.43.1(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0):
optionalDependencies:
'@types/better-sqlite3': 7.6.13
better-sqlite3: 11.10.0
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
es-module-lexer@2.0.0: {}
esbuild@0.27.7:
@@ -1400,21 +1690,35 @@ snapshots:
dependencies:
'@types/estree': 1.0.8
expand-template@2.0.3: {}
expect-type@1.3.0: {}
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
file-uri-to-path@1.0.0: {}
fix-dts-default-cjs-exports@1.0.1:
dependencies:
magic-string: 0.30.21
mlly: 1.8.2
rollup: 4.60.2
fs-constants@1.0.0: {}
fsevents@2.3.3:
optional: true
github-from-package@0.0.0: {}
ieee754@1.2.1: {}
inherits@2.0.4: {}
ini@1.3.8: {}
joycon@3.1.1: {}
lightningcss-android-arm64@1.32.0:
@@ -1476,6 +1780,12 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
mimic-response@3.1.0: {}
minimist@1.2.8: {}
mkdirp-classic@0.5.3: {}
mlly@1.8.2:
dependencies:
acorn: 8.16.0
@@ -1493,10 +1803,20 @@ snapshots:
nanoid@3.3.11: {}
napi-build-utils@2.0.0: {}
node-abi@3.89.0:
dependencies:
semver: 7.7.4
object-assign@4.1.1: {}
obug@2.1.1: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
pathe@2.0.3: {}
picocolors@1.1.1: {}
@@ -1524,6 +1844,39 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.89.0
pump: 3.0.4
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
pump@3.0.4:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
readdirp@4.1.2: {}
resolve-from@5.0.0: {}
@@ -1580,8 +1933,20 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.2
fsevents: 2.3.3
safe-buffer@5.2.1: {}
semver@7.7.4: {}
siginfo@2.0.0: {}
simple-concat@1.0.1: {}
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
source-map-js@1.2.1: {}
source-map@0.7.6: {}
@@ -1590,6 +1955,12 @@ snapshots:
std-env@4.1.0: {}
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-json-comments@2.0.1: {}
sucrase@3.35.1:
dependencies:
'@jridgewell/gen-mapping': 0.3.13
@@ -1600,6 +1971,21 @@ snapshots:
tinyglobby: 0.2.16
ts-interface-checker: 0.1.13
tar-fs@2.1.4:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.4
tar-stream: 2.2.0
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
@@ -1656,11 +2042,19 @@ snapshots:
- tsx
- yaml
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
typescript@5.9.3: {}
ufo@1.6.3: {}
vite@8.0.9(esbuild@0.27.7)(yaml@2.8.3):
undici-types@7.19.2: {}
util-deprecate@1.0.2: {}
vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -1668,14 +2062,15 @@ snapshots:
rolldown: 1.0.0-rc.16
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 25.6.0
esbuild: 0.27.7
fsevents: 2.3.3
yaml: 2.8.3
vitest@4.1.5(vite@8.0.9(esbuild@0.27.7)(yaml@2.8.3)):
vitest@4.1.5(@types/node@25.6.0)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.9(esbuild@0.27.7)(yaml@2.8.3))
'@vitest/mocker': 4.1.5(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(yaml@2.8.3))
'@vitest/pretty-format': 4.1.5
'@vitest/runner': 4.1.5
'@vitest/snapshot': 4.1.5
@@ -1692,8 +2087,10 @@ snapshots:
tinyexec: 1.1.1
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.9(esbuild@0.27.7)(yaml@2.8.3)
vite: 8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.6.0
transitivePeerDependencies:
- msw
@@ -1702,4 +2099,6 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
wrappy@1.0.2: {}
yaml@2.8.3: {}
+5
View File
@@ -1,2 +1,7 @@
packages:
- "packages/*"
onlyBuiltDependencies:
- "@biomejs/biome"
- better-sqlite3
- esbuild