Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b15fc993f2 | |||
| 96188c8cda | |||
| 781f571474 |
@@ -20,6 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"citty": "^0.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Tests for sense SQLite helpers used by `nerve sense schema` / `nerve sense query`.
|
||||
*/
|
||||
|
||||
import { mkdirSync, rmSync } 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 {
|
||||
assertSenseDbExists,
|
||||
collectColumnKeys,
|
||||
defaultPreviewSql,
|
||||
formatRowsAsAlignedTable,
|
||||
listTableSqlStatements,
|
||||
parseSenseQueryArgs,
|
||||
pickDefaultPreviewTable,
|
||||
senseDbPath,
|
||||
} from "../sense-sqlite.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = join(
|
||||
tmpdir(),
|
||||
`nerve-sense-sqlite-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
mkdirSync(join(tmpDir, "data", "senses"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("senseDbPath", () => {
|
||||
it("points at data/senses/<name>.db under the given root", () => {
|
||||
expect(senseDbPath("/root", "cpu-usage")).toBe(join("/root", "data", "senses", "cpu-usage.db"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertSenseDbExists", () => {
|
||||
it("throws when the file is missing", () => {
|
||||
expect(() => assertSenseDbExists(tmpDir, "nope")).toThrow(/No database at/);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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 stmts = listTableSqlStatements(db);
|
||||
db.close();
|
||||
expect(stmts).toHaveLength(2);
|
||||
expect(stmts[0]).toMatch(/^CREATE TABLE alpha/i);
|
||||
expect(stmts[1]).toMatch(/^CREATE TABLE zebra/i);
|
||||
});
|
||||
});
|
||||
|
||||
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);`);
|
||||
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);");
|
||||
expect(pickDefaultPreviewTable(db)).toBe("_migrations");
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultPreviewSql", () => {
|
||||
it("quotes identifiers for SQL safety", () => {
|
||||
expect(defaultPreviewSql(`weird"name`)).toContain(`weird""name`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSenseQueryArgs", () => {
|
||||
it("parses sense name only", () => {
|
||||
expect(parseSenseQueryArgs(["cpu"])).toEqual({ name: "cpu", sql: undefined });
|
||||
});
|
||||
|
||||
it("strips --json", () => {
|
||||
expect(parseSenseQueryArgs(["cpu", "--json"])).toEqual({ name: "cpu", sql: undefined });
|
||||
expect(parseSenseQueryArgs(["--json", "cpu"])).toEqual({ name: "cpu", sql: undefined });
|
||||
});
|
||||
|
||||
it("joins remaining tokens into SQL", () => {
|
||||
expect(parseSenseQueryArgs(["cpu", "SELECT", "1"])).toEqual({ name: "cpu", sql: "SELECT 1" });
|
||||
});
|
||||
|
||||
it("throws when name is missing", () => {
|
||||
expect(() => parseSenseQueryArgs(["--json"])).toThrow(/Missing sense name/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRowsAsAlignedTable", () => {
|
||||
it("shows empty marker for no rows", () => {
|
||||
expect(formatRowsAsAlignedTable([])).toContain("(0 rows)");
|
||||
});
|
||||
|
||||
it("aligns columns from row data", () => {
|
||||
const out = formatRowsAsAlignedTable([
|
||||
{ a: 1, b: "x" },
|
||||
{ a: 22, b: "yy" },
|
||||
]);
|
||||
expect(out).toContain("a");
|
||||
expect(out).toContain("b");
|
||||
expect(out).toContain("22");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectColumnKeys", () => {
|
||||
it("preserves key order from first row then appends new keys", () => {
|
||||
expect(
|
||||
collectColumnKeys([
|
||||
{ z: 1, a: 2 },
|
||||
{ a: 3, b: 4 },
|
||||
]),
|
||||
).toEqual(["z", "a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
const db = new Database(p, { readonly: true, fileMustExist: true });
|
||||
const table = pickDefaultPreviewTable(db);
|
||||
expect(table).toBe("items");
|
||||
if (table === null) {
|
||||
throw new Error("expected items table");
|
||||
}
|
||||
const sql = defaultPreviewSql(table);
|
||||
const rows = db.prepare(sql).all() as Record<string, unknown>[];
|
||||
db.close();
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,18 @@ 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";
|
||||
import {
|
||||
assertSenseDbExists,
|
||||
defaultPreviewSql,
|
||||
formatRowsAsAlignedTable,
|
||||
listTableSqlStatements,
|
||||
parseSenseQueryArgs,
|
||||
pickDefaultPreviewTable,
|
||||
} from "../sense-sqlite.js";
|
||||
import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -139,6 +148,118 @@ const senseTriggerCommand = defineCommand({
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve sense schema <name>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const senseSchemaCommand = defineCommand({
|
||||
meta: {
|
||||
name: "schema",
|
||||
description: "Print CREATE TABLE statements from a sense SQLite database",
|
||||
},
|
||||
args: {
|
||||
name: {
|
||||
type: "positional",
|
||||
description: "Sense name (data/senses/<name>.db under the nerve workspace)",
|
||||
},
|
||||
json: {
|
||||
type: "boolean",
|
||||
description: "Print JSON array of CREATE TABLE SQL strings",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const nerveRoot = getNerveRoot();
|
||||
let db: Database.Database | undefined;
|
||||
try {
|
||||
const path = assertSenseDbExists(nerveRoot, args.name);
|
||||
db = new Database(path, { readonly: true, fileMustExist: true });
|
||||
const statements = listTableSqlStatements(db);
|
||||
if (args.json) {
|
||||
process.stdout.write(`${JSON.stringify(statements, null, 2)}\n`);
|
||||
} else if (statements.length === 0) {
|
||||
process.stdout.write("(no tables)\n");
|
||||
} else {
|
||||
for (const sql of statements) {
|
||||
process.stdout.write(`${sql};\n\n`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`❌ ${msg}\n`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve sense query <name> [sql...]
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const senseQueryCommand = defineCommand({
|
||||
meta: {
|
||||
name: "query",
|
||||
description:
|
||||
"Run a read-only SQL query against a sense database (default: last 10 rows of the first data table). Pass optional SQL after the sense name; multiple words are joined.",
|
||||
},
|
||||
args: {
|
||||
name: {
|
||||
type: "positional",
|
||||
description: "Sense name (data/senses/<name>.db under the nerve workspace)",
|
||||
},
|
||||
json: {
|
||||
type: "boolean",
|
||||
description: "Print result rows as JSON",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args, rawArgs }) {
|
||||
const nerveRoot = getNerveRoot();
|
||||
let db: Database.Database | undefined;
|
||||
try {
|
||||
let parsed: { name: string; sql: string | undefined };
|
||||
try {
|
||||
parsed = parseSenseQueryArgs(rawArgs);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`❌ ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const path = assertSenseDbExists(nerveRoot, args.name);
|
||||
db = new Database(path, { readonly: true, fileMustExist: true });
|
||||
|
||||
let sql = parsed.sql?.trim();
|
||||
if (!sql) {
|
||||
const table = pickDefaultPreviewTable(db);
|
||||
if (table === null) {
|
||||
process.stderr.write("❌ No tables found in database.\n");
|
||||
process.exit(1);
|
||||
} else {
|
||||
sql = defaultPreviewSql(table);
|
||||
}
|
||||
}
|
||||
|
||||
const stmt = db.prepare(sql);
|
||||
const rows = stmt.all() as Record<string, unknown>[];
|
||||
|
||||
if (args.json) {
|
||||
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
|
||||
} else {
|
||||
process.stdout.write(formatRowsAsAlignedTable(rows));
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`❌ ${msg}\n`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve sense (parent command)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -151,5 +272,7 @@ export const senseCommand = defineCommand({
|
||||
subCommands: {
|
||||
list: senseListCommand,
|
||||
trigger: senseTriggerCommand,
|
||||
schema: senseSchemaCommand,
|
||||
query: senseQueryCommand,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
/** SQLite path for a sense under the nerve workspace root. */
|
||||
export function senseDbPath(nerveRoot: string, senseName: string): string {
|
||||
return join(nerveRoot, "data", "senses", `${senseName}.db`);
|
||||
}
|
||||
|
||||
export function assertSenseDbExists(nerveRoot: string, senseName: string): string {
|
||||
const path = senseDbPath(nerveRoot, senseName);
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`No database at ${path}`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/** `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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Table used for `nerve sense query <name>` 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 defaultPreviewSql(table: string): string {
|
||||
return `SELECT * FROM "${table.replace(/"/g, '""')}" ORDER BY rowid DESC LIMIT 10`;
|
||||
}
|
||||
|
||||
/** Parse sense name and optional SQL from subcommand raw argv (flags stripped). */
|
||||
export function parseSenseQueryArgs(rawArgs: string[]): { name: string; sql: string | undefined } {
|
||||
const pos: string[] = [];
|
||||
for (let i = 0; i < rawArgs.length; i++) {
|
||||
const a = rawArgs[i];
|
||||
if (a === "--json" || a === "--no-json") continue;
|
||||
if (a.startsWith("-")) {
|
||||
const eq = a.indexOf("=");
|
||||
if (eq === -1 && i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith("-")) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pos.push(a);
|
||||
}
|
||||
if (pos.length < 1) {
|
||||
throw new Error("Missing sense name");
|
||||
}
|
||||
const name = pos[0];
|
||||
const sql = pos.length > 1 ? pos.slice(1).join(" ") : undefined;
|
||||
return { name, sql };
|
||||
}
|
||||
|
||||
function stringifyCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
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");
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect column keys in stable order (first row keys, then any extras). */
|
||||
export function collectColumnKeys(rows: Record<string, unknown>[]): string[] {
|
||||
const keys: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
for (const k of Object.keys(row)) {
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
keys.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
const MAX_CELL = 64;
|
||||
|
||||
function truncate(s: string): string {
|
||||
if (s.length <= MAX_CELL) return s;
|
||||
return `${s.slice(0, MAX_CELL - 1)}…`;
|
||||
}
|
||||
|
||||
/** Plain aligned table for terminal output. */
|
||||
export function formatRowsAsAlignedTable(rows: Record<string, unknown>[]): string {
|
||||
if (rows.length === 0) {
|
||||
return "(0 rows)\n";
|
||||
}
|
||||
const cols = collectColumnKeys(rows);
|
||||
const cells = rows.map((row) => cols.map((c) => truncate(stringifyCell(row[c]))));
|
||||
const widths = cols.map((c, j) => Math.max(c.length, ...cells.map((r) => r[j].length)));
|
||||
const sep = widths.map((w) => "-".repeat(w)).join("-+-");
|
||||
const header = cols.map((c, j) => c.padEnd(widths[j])).join(" | ");
|
||||
const body = cells.map((r) => r.map((cell, j) => cell.padEnd(widths[j])).join(" | ")).join("\n");
|
||||
return `${header}\n${sep}\n${body}\n`;
|
||||
}
|
||||
@@ -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"],
|
||||
external: ["@uncaged/nerve-daemon", "better-sqlite3"],
|
||||
});
|
||||
|
||||
Generated
+3
@@ -23,6 +23,9 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user