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:
Cursor Agent 2026-05-09 17:39:20 +00:00
parent b9fb14e68b
commit d2ce5325f8
No known key found for this signature in database
4 changed files with 280 additions and 0 deletions

View file

@ -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

View file

@ -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

View 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;
}

View 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);
});
});