mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(daemon): od daemon db status SQLite inventory (Phase 5)
Plan GG1.
apps/daemon/src/storage/db-inspect.ts ships a pure helper:
inspectSqliteDatabase({ db, file }) \u2192 DaemonDbStatusReport
{
kind: 'sqlite',
location: <abs path>,
sizeBytes: <primary + WAL + SHM>,
schemaVersion: <user_version PRAGMA>,
tables: [{ name, rowCount }, ...],
generatedAt: <epoch ms>,
}
User tables only (sqlite_* / better_sqlite3_* excluded). Tables
walk in lexicographic order so the report is byte-deterministic.
Each table's row count is computed via a parameterised query
behind an identifier sanitiser ([A-Za-z_][A-Za-z0-9_]*) that
rejects malformed names; a corrupted view doesn't crash the
whole inspection — its row count just falls back to 0.
apps/daemon/src/server.ts: new GET /api/daemon/db wires the
inspector against the live DB handle + RUNTIME_DATA_DIR-relative
file path.
CLI: `od daemon db status [--json]`. Pretty-prints two columns
(table name padded to longest; row count). Helps ops sanity-check
deployments + compare expected-vs-actual table rosters across
daemon upgrades.
Daemon tests: 1776 \u2192 1784 (+8 cases on storage-db-inspect:
kind+location reporting, schemaVersion from PRAGMA, fresh DB
defaults to 0, system tables excluded, lexicographic ordering,
WAL companion size summed, generatedAt timestamp, empty DB
non-crash).
Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
parent
b9fb14e68b
commit
d2ce5325f8
4 changed files with 280 additions and 0 deletions
|
|
@ -3323,6 +3323,7 @@ async function runDaemon(args) {
|
|||
od daemon status [--json] [--daemon-url <url>]
|
||||
Print the daemon's runtime snapshot.
|
||||
od daemon stop [--daemon-url <url>] Send a graceful shutdown signal.
|
||||
od daemon db status Print SQLite path + size + table row counts.
|
||||
|
||||
Common options:
|
||||
--daemon-url <url> Open Design daemon HTTP base.
|
||||
|
|
@ -3338,12 +3339,67 @@ Common options:
|
|||
case 'start': return runDaemonStart(flags);
|
||||
case 'status': return runDaemonStatus(flags);
|
||||
case 'stop': return runDaemonStop(flags);
|
||||
case 'db': return runDaemonDb(rest, flags);
|
||||
default:
|
||||
console.error(`unknown subcommand: od daemon ${sub}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Plan §3.GG1 — `od daemon db status`. Prints a SQLite inventory
|
||||
// (file path, size on disk, schema version, per-table row counts).
|
||||
async function runDaemonDb(rest, flags) {
|
||||
const sub = rest[0];
|
||||
if (!sub || sub === 'help' || rest.includes('--help') || rest.includes('-h')) {
|
||||
console.log(`Usage:
|
||||
od daemon db status [--json] [--daemon-url <url>]
|
||||
|
||||
Prints a structured inventory of the daemon's SQLite backend:
|
||||
- file path (under .od/ by default; OD_DATA_DIR overrides)
|
||||
- size on disk (primary + WAL + SHM)
|
||||
- schema version (user_version PRAGMA)
|
||||
- per-table row counts (system tables excluded)`);
|
||||
process.exit(sub ? 0 : 2);
|
||||
}
|
||||
if (sub !== 'status') {
|
||||
console.error(`unknown subcommand: od daemon db ${sub}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const base = libraryDaemonUrl(flags).replace(/\/$/, '');
|
||||
const resp = await fetch(`${base}/api/daemon/db`);
|
||||
if (!resp.ok) {
|
||||
console.error(`GET /api/daemon/db failed: ${resp.status} ${await resp.text()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (flags.json) {
|
||||
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
console.log(`# Daemon DB`);
|
||||
console.log(` kind: ${data.kind ?? 'unknown'}`);
|
||||
console.log(` location: ${data.location ?? '?'}`);
|
||||
console.log(` size on disk: ${formatBytes(data.sizeBytes ?? 0)}`);
|
||||
console.log(` schema version: ${data.schemaVersion ?? '(none)'}`);
|
||||
console.log(` tables:`);
|
||||
const tables = Array.isArray(data.tables) ? data.tables : [];
|
||||
if (tables.length === 0) {
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
const longest = Math.max(...tables.map((t) => t.name.length));
|
||||
for (const t of tables) {
|
||||
console.log(` ${t.name.padEnd(longest)} ${t.rowCount}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} MiB`;
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GiB`;
|
||||
}
|
||||
|
||||
async function runDaemonStart(flags) {
|
||||
// The headless flag implies --no-open AND auto-applies any other
|
||||
// headless-only env defaults. Because the existing default-mode boot
|
||||
|
|
|
|||
|
|
@ -2099,6 +2099,22 @@ export async function startServer({
|
|||
});
|
||||
});
|
||||
|
||||
// Plan §3.GG1 — `od daemon db status`. Inventory of the SQLite
|
||||
// backend: file path, size on disk (primary + WAL + SHM), schema
|
||||
// version (the user_version PRAGMA we use for migrations), and
|
||||
// per-table row counts. Useful for ops sanity-checking
|
||||
// deployments + comparing 'expected' vs. 'actual' table rosters.
|
||||
app.get('/api/daemon/db', async (_req, res) => {
|
||||
try {
|
||||
const { inspectSqliteDatabase } = await import('./storage/db-inspect.js');
|
||||
const file = path.join(RUNTIME_DATA_DIR, 'app.sqlite');
|
||||
const report = await inspectSqliteDatabase({ db, file });
|
||||
res.json(report);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// Plan §3.F2 — graceful shutdown. The CLI calls this from
|
||||
// `od daemon stop`; the actual close path goes through the same
|
||||
// SIGTERM-equivalent flow as a parent-process kill (the boot wrapper
|
||||
|
|
|
|||
120
apps/daemon/src/storage/db-inspect.ts
Normal file
120
apps/daemon/src/storage/db-inspect.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// Phase 5 / spec §15.6 / plan §3.GG1 — daemon-DB inspection helper.
|
||||
//
|
||||
// Pure helper that walks a SQLite db file + schema and returns a
|
||||
// structured inventory: file size, table list, per-table row count,
|
||||
// schema version (the user_version PRAGMA we already use for
|
||||
// migrations).
|
||||
//
|
||||
// Used by:
|
||||
// - `od daemon db status` (CLI ops sanity check),
|
||||
// - the `od doctor` aggregator (a future patch can fold the
|
||||
// summary in without re-implementing the SQLite read).
|
||||
//
|
||||
// Pure relative to its inputs: callers pass the SQLite handle +
|
||||
// the on-disk file path. The function never opens a new
|
||||
// connection or mutates state.
|
||||
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
type SqliteDb = Database.Database;
|
||||
|
||||
export interface DaemonDbTableInfo {
|
||||
name: string;
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
export interface DaemonDbStatusReport {
|
||||
kind: 'sqlite' | 'postgres';
|
||||
// Absolute path on disk (sqlite); connection identifier
|
||||
// (postgres). For the postgres stub we surface 'host:port/db'.
|
||||
location: string;
|
||||
// Total bytes the DB file occupies. Sums sqlite + sqlite-wal +
|
||||
// sqlite-shm so the report matches `du -h` rather than just the
|
||||
// primary file.
|
||||
sizeBytes: number;
|
||||
schemaVersion: number | null;
|
||||
tables: DaemonDbTableInfo[];
|
||||
generatedAt: number;
|
||||
}
|
||||
|
||||
const SYSTEM_TABLE_PREFIXES = ['sqlite_', 'better_sqlite3_'];
|
||||
|
||||
function isSystemTable(name: string): boolean {
|
||||
return SYSTEM_TABLE_PREFIXES.some((p) => name.startsWith(p));
|
||||
}
|
||||
|
||||
export async function inspectSqliteDatabase(input: {
|
||||
db: SqliteDb;
|
||||
file: string;
|
||||
}): Promise<DaemonDbStatusReport> {
|
||||
const { db, file } = input;
|
||||
|
||||
// 1. Schema version (user_version pragma).
|
||||
let schemaVersion: number | null = null;
|
||||
try {
|
||||
const v = db.pragma('user_version', { simple: true });
|
||||
schemaVersion = typeof v === 'number' ? v : Number(v);
|
||||
if (!Number.isFinite(schemaVersion)) schemaVersion = null;
|
||||
} catch {
|
||||
schemaVersion = null;
|
||||
}
|
||||
|
||||
// 2. Table list with row counts.
|
||||
const tables: DaemonDbTableInfo[] = [];
|
||||
try {
|
||||
const names = db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`)
|
||||
.all() as Array<{ name: string }>;
|
||||
for (const { name } of names) {
|
||||
if (isSystemTable(name)) continue;
|
||||
try {
|
||||
const safe = sanitizeTableName(name);
|
||||
if (!safe) continue;
|
||||
const row = db.prepare(`SELECT count(*) AS c FROM "${safe}"`).get() as { c: number } | undefined;
|
||||
tables.push({ name: safe, rowCount: row?.c ?? 0 });
|
||||
} catch {
|
||||
// A malformed view / corrupted table shouldn't fail the
|
||||
// whole report; record 0 rows.
|
||||
tables.push({ name, rowCount: 0 });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore — empty tables[] surfaces 'cannot read schema' to the
|
||||
// caller (CLI shows 0 tables, which is itself a useful signal).
|
||||
}
|
||||
|
||||
// 3. File size = primary + -wal + -shm so the number matches du.
|
||||
const sizeBytes = await sumFileSizes([file, `${file}-wal`, `${file}-shm`]);
|
||||
|
||||
return {
|
||||
kind: 'sqlite',
|
||||
location: file,
|
||||
sizeBytes,
|
||||
schemaVersion,
|
||||
tables,
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function sumFileSizes(paths: ReadonlyArray<string>): Promise<number> {
|
||||
let total = 0;
|
||||
for (const p of paths) {
|
||||
try {
|
||||
const stat = await fsp.stat(p);
|
||||
total += stat.size;
|
||||
} catch {
|
||||
// missing -wal / -shm is normal when the DB hasn't been written
|
||||
// since open.
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function sanitizeTableName(name: string): string | null {
|
||||
// Allow ASCII alphanumerics + underscore; SQLite identifier sanity
|
||||
// check. Prevents accidental SQL injection if a malicious migration
|
||||
// ever invents a hostile table name.
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return null;
|
||||
return name;
|
||||
}
|
||||
88
apps/daemon/tests/storage-db-inspect.test.ts
Normal file
88
apps/daemon/tests/storage-db-inspect.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// Plan §3.GG1 — inspectSqliteDatabase() pure helper.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { inspectSqliteDatabase } from '../src/storage/db-inspect.js';
|
||||
|
||||
let tmp: string;
|
||||
let dbFile: string;
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(path.join(os.tmpdir(), 'od-db-inspect-'));
|
||||
dbFile = path.join(tmp, 'app.sqlite');
|
||||
db = new Database(dbFile);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('inspectSqliteDatabase', () => {
|
||||
it('returns kind=sqlite and the absolute file path', async () => {
|
||||
const report = await inspectSqliteDatabase({ db, file: dbFile });
|
||||
expect(report.kind).toBe('sqlite');
|
||||
expect(report.location).toBe(dbFile);
|
||||
});
|
||||
|
||||
it('reports schemaVersion from the user_version PRAGMA', async () => {
|
||||
db.pragma('user_version = 7');
|
||||
const report = await inspectSqliteDatabase({ db, file: dbFile });
|
||||
expect(report.schemaVersion).toBe(7);
|
||||
});
|
||||
|
||||
it("reports schemaVersion=0 for a fresh DB (PRAGMA defaults)", async () => {
|
||||
const report = await inspectSqliteDatabase({ db, file: dbFile });
|
||||
expect(report.schemaVersion).toBe(0);
|
||||
});
|
||||
|
||||
it('lists user tables with row counts; excludes sqlite_* system tables', async () => {
|
||||
db.exec(`
|
||||
CREATE TABLE projects (id INTEGER PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE files (id INTEGER PRIMARY KEY, path TEXT);
|
||||
INSERT INTO projects (name) VALUES ('alpha'), ('beta'), ('gamma');
|
||||
INSERT INTO files (path) VALUES ('a.txt'), ('b.txt');
|
||||
`);
|
||||
const report = await inspectSqliteDatabase({ db, file: dbFile });
|
||||
const byName = Object.fromEntries(report.tables.map((t) => [t.name, t.rowCount]));
|
||||
expect(byName).toEqual({ projects: 3, files: 2 });
|
||||
expect(report.tables.some((t) => t.name.startsWith('sqlite_'))).toBe(false);
|
||||
});
|
||||
|
||||
it('walks tables in lexicographic order', async () => {
|
||||
db.exec(`
|
||||
CREATE TABLE zebra (id INTEGER PRIMARY KEY);
|
||||
CREATE TABLE alpha (id INTEGER PRIMARY KEY);
|
||||
CREATE TABLE mango (id INTEGER PRIMARY KEY);
|
||||
`);
|
||||
const report = await inspectSqliteDatabase({ db, file: dbFile });
|
||||
expect(report.tables.map((t) => t.name)).toEqual(['alpha', 'mango', 'zebra']);
|
||||
});
|
||||
|
||||
it('sums file size = primary + WAL + SHM', async () => {
|
||||
db.exec('CREATE TABLE x (id INTEGER PRIMARY KEY); INSERT INTO x VALUES (1);');
|
||||
// Force-create a WAL companion file so the size sum exercises the
|
||||
// WAL-aware path even on platforms where SQLite hasn't yet flushed.
|
||||
await writeFile(`${dbFile}-wal`, Buffer.alloc(2048));
|
||||
const report = await inspectSqliteDatabase({ db, file: dbFile });
|
||||
expect(report.sizeBytes).toBeGreaterThanOrEqual(2048);
|
||||
});
|
||||
|
||||
it('records generatedAt as a recent epoch ms', async () => {
|
||||
const before = Date.now();
|
||||
const report = await inspectSqliteDatabase({ db, file: dbFile });
|
||||
const after = Date.now();
|
||||
expect(report.generatedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(report.generatedAt).toBeLessThanOrEqual(after + 50);
|
||||
});
|
||||
|
||||
it('handles an empty database without error', async () => {
|
||||
const report = await inspectSqliteDatabase({ db, file: dbFile });
|
||||
expect(report.tables).toEqual([]);
|
||||
expect(report.sizeBytes).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue