Files
fusion/packages/core/src/central-db.ts
Fusion 8f0f9257ed feat(FN-3114): stabilize docker config modal recreate indicator test
The merge stabilizes a test case in the NodeDetailModal test file related to the docker config modal's recreate indicator, correcting an assertion that was causing intermittent failures.

Fusion-Task-Id: FN-3114
2026-05-04 19:05:48 -07:00

489 lines
15 KiB
TypeScript

/**
* Central SQLite database module for fn's multi-project architecture.
*
* Uses Node.js built-in `node:sqlite` (DatabaseSync) for simplified
* synchronous transaction handling. The database runs in WAL mode
* for concurrent reader/writer access.
*
* This database is stored at `~/.fusion/fusion-central.db` and serves as the
* coordination hub for all projects, storing the project registry,
* unified activity feed, global concurrency limits, and project health.
*/
import { DatabaseSync } from "./sqlite-adapter.js";
import { join } from "node:path";
import { mkdirSync, existsSync } from "node:fs";
import type { Statement } from "./db.js";
import { resolveGlobalDir } from "./global-settings.js";
// ── JSON Helpers (reused from db.ts) ─────────────────────────────────────
import { toJson, toJsonNullable, fromJson } from "./db.js";
export { toJson, toJsonNullable, fromJson };
// ── Schema Definition ───────────────────────────────────────────────────
const CENTRAL_SCHEMA_VERSION = 7;
const CENTRAL_SCHEMA_SQL = `
-- Projects table (project registry)
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
isolationMode TEXT NOT NULL DEFAULT 'in-process',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
lastActivityAt TEXT,
nodeId TEXT,
settings TEXT -- JSON ProjectSettings snapshot
);
CREATE INDEX IF NOT EXISTS idxProjectsPath ON projects(path);
CREATE INDEX IF NOT EXISTS idxProjectsStatus ON projects(status);
-- Project health table (mutable state, updated frequently)
CREATE TABLE IF NOT EXISTS projectHealth (
projectId TEXT PRIMARY KEY,
status TEXT NOT NULL,
activeTaskCount INTEGER DEFAULT 0,
inFlightAgentCount INTEGER DEFAULT 0,
lastActivityAt TEXT,
lastErrorAt TEXT,
lastErrorMessage TEXT,
totalTasksCompleted INTEGER DEFAULT 0,
totalTasksFailed INTEGER DEFAULT 0,
averageTaskDurationMs INTEGER,
updatedAt TEXT NOT NULL,
FOREIGN KEY (projectId) REFERENCES projects(id) ON DELETE CASCADE
);
-- Central activity log (unified feed across all projects)
CREATE TABLE IF NOT EXISTS centralActivityLog (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
type TEXT NOT NULL,
projectId TEXT NOT NULL,
projectName TEXT NOT NULL,
taskId TEXT,
taskTitle TEXT,
details TEXT NOT NULL,
metadata TEXT, -- JSON
FOREIGN KEY (projectId) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxActivityLogTimestamp ON centralActivityLog(timestamp);
CREATE INDEX IF NOT EXISTS idxActivityLogType ON centralActivityLog(type);
CREATE INDEX IF NOT EXISTS idxActivityLogProjectId ON centralActivityLog(projectId);
-- Global concurrency state (single row)
CREATE TABLE IF NOT EXISTS globalConcurrency (
id INTEGER PRIMARY KEY CHECK (id = 1),
globalMaxConcurrent INTEGER DEFAULT 4,
currentlyActive INTEGER DEFAULT 0,
queuedCount INTEGER DEFAULT 0,
updatedAt TEXT
);
-- Seed default row
INSERT OR IGNORE INTO globalConcurrency (id, globalMaxConcurrent, currentlyActive, queuedCount)
VALUES (1, 4, 0, 0);
-- Nodes table (runtime hosts for project execution)
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL CHECK (type IN ('local', 'remote')),
url TEXT,
apiKey TEXT,
status TEXT NOT NULL DEFAULT 'offline',
capabilities TEXT,
systemMetrics TEXT,
knownPeers TEXT,
versionInfo TEXT,
pluginVersions TEXT,
dockerConfig TEXT,
maxConcurrent INTEGER NOT NULL DEFAULT 2,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idxNodesStatus ON nodes(status);
CREATE INDEX IF NOT EXISTS idxNodesType ON nodes(type);
-- Peer nodes table (mesh awareness graph per node)
CREATE TABLE IF NOT EXISTS peerNodes (
id TEXT PRIMARY KEY,
nodeId TEXT NOT NULL,
peerNodeId TEXT NOT NULL,
name TEXT NOT NULL,
url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'unknown',
lastSeen TEXT NOT NULL,
connectedAt TEXT NOT NULL,
UNIQUE(nodeId, peerNodeId),
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxPeerNodesNodeId ON peerNodes(nodeId);
-- Settings sync state tracking
CREATE TABLE IF NOT EXISTS settingsSyncState (
nodeId TEXT NOT NULL,
remoteNodeId TEXT NOT NULL,
lastSyncedAt TEXT,
localChecksum TEXT,
remoteChecksum TEXT,
syncCount INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (nodeId, remoteNodeId),
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxSettingsSyncNode ON settingsSyncState(nodeId);
-- Managed Docker nodes table (Docker-provisioned mesh nodes)
CREATE TABLE IF NOT EXISTS managedDockerNodes (
id TEXT PRIMARY KEY,
nodeId TEXT,
name TEXT NOT NULL UNIQUE,
imageName TEXT NOT NULL,
imageTag TEXT NOT NULL,
containerId TEXT,
status TEXT NOT NULL DEFAULT 'creating',
hostConfig TEXT NOT NULL DEFAULT '{}',
envVars TEXT NOT NULL DEFAULT '{}',
volumeMounts TEXT NOT NULL DEFAULT '[]',
resourceSizing TEXT NOT NULL DEFAULT '{}',
extraClis TEXT NOT NULL DEFAULT '[]',
persistentStorage INTEGER NOT NULL DEFAULT 1,
reachableUrl TEXT,
apiKey TEXT,
errorMessage TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idxManagedDockerNodesStatus ON managedDockerNodes(status);
CREATE INDEX IF NOT EXISTS idxManagedDockerNodesNodeId ON managedDockerNodes(nodeId);
-- Schema version tracking
CREATE TABLE IF NOT EXISTS __meta (
key TEXT PRIMARY KEY,
value TEXT
);
`;
const CENTRAL_SCHEMA_V2_MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL CHECK (type IN ('local', 'remote')),
url TEXT,
apiKey TEXT,
status TEXT NOT NULL DEFAULT 'offline',
capabilities TEXT,
maxConcurrent INTEGER NOT NULL DEFAULT 2,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idxNodesStatus ON nodes(status);
CREATE INDEX IF NOT EXISTS idxNodesType ON nodes(type);
`;
const CENTRAL_SCHEMA_V3_MIGRATION_SQL = `
ALTER TABLE nodes ADD COLUMN systemMetrics TEXT;
ALTER TABLE nodes ADD COLUMN knownPeers TEXT;
CREATE TABLE IF NOT EXISTS peerNodes (
id TEXT PRIMARY KEY,
nodeId TEXT NOT NULL,
peerNodeId TEXT NOT NULL,
name TEXT NOT NULL,
url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'unknown',
lastSeen TEXT NOT NULL,
connectedAt TEXT NOT NULL,
UNIQUE(nodeId, peerNodeId),
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxPeerNodesNodeId ON peerNodes(nodeId);
`;
const CENTRAL_SCHEMA_V3_CREATE_PEERS_SQL = CENTRAL_SCHEMA_V3_MIGRATION_SQL
.split("\n")
.filter((line) => !line.trim().startsWith("ALTER TABLE nodes ADD COLUMN"))
.join("\n");
// V4 migration is applied inline via ALTER TABLE checks (see runMigrations).
const CENTRAL_SCHEMA_V5_MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS settingsSyncState (
nodeId TEXT NOT NULL,
remoteNodeId TEXT NOT NULL,
lastSyncedAt TEXT,
localChecksum TEXT,
remoteChecksum TEXT,
syncCount INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (nodeId, remoteNodeId),
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxSettingsSyncNode ON settingsSyncState(nodeId);
`;
const CENTRAL_SCHEMA_V6_MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS managedDockerNodes (
id TEXT PRIMARY KEY,
nodeId TEXT,
name TEXT NOT NULL UNIQUE,
imageName TEXT NOT NULL,
imageTag TEXT NOT NULL,
containerId TEXT,
status TEXT NOT NULL DEFAULT 'creating',
hostConfig TEXT NOT NULL DEFAULT '{}',
envVars TEXT NOT NULL DEFAULT '{}',
volumeMounts TEXT NOT NULL DEFAULT '[]',
resourceSizing TEXT NOT NULL DEFAULT '{}',
extraClis TEXT NOT NULL DEFAULT '[]',
persistentStorage INTEGER NOT NULL DEFAULT 1,
reachableUrl TEXT,
apiKey TEXT,
errorMessage TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idxManagedDockerNodesStatus ON managedDockerNodes(status);
CREATE INDEX IF NOT EXISTS idxManagedDockerNodesNodeId ON managedDockerNodes(nodeId);
`;
// V7 migration adds dockerConfig persistence to nodes for Docker-managed runtime config updates.
// ── Central Database Class ────────────────────────────────────────────────
export class CentralDatabase {
private db: DatabaseSync;
private readonly dbPath: string;
private readonly globalDir: string;
/** Tracks transaction nesting depth for savepoint-based nested transactions. */
private transactionDepth = 0;
constructor(globalDir?: string) {
this.globalDir = resolveGlobalDir(globalDir);
this.dbPath = join(this.globalDir, "fusion-central.db");
// Ensure directory exists
if (!existsSync(this.globalDir)) {
mkdirSync(this.globalDir, { recursive: true });
}
try {
this.db = new DatabaseSync(this.dbPath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to open Fusion central database at ${this.dbPath}: ${message}`);
}
// Enable WAL mode for concurrent reader/writer access
this.db.exec("PRAGMA journal_mode = WAL");
// Wait up to 5s for locks to clear before returning SQLITE_BUSY
this.db.exec("PRAGMA busy_timeout = 5000");
// Enable foreign key enforcement
this.db.exec("PRAGMA foreign_keys = ON");
}
/**
* Initialize the database: create tables if they don't exist
* and seed meta values.
*/
init(): void {
this.db.exec(CENTRAL_SCHEMA_SQL);
const currentVersion = this.getSchemaVersion();
let migrated = false;
if (currentVersion < 2) {
this.db.exec(CENTRAL_SCHEMA_V2_MIGRATION_SQL);
if (!this.hasColumn("projects", "nodeId")) {
this.db.exec("ALTER TABLE projects ADD COLUMN nodeId TEXT");
}
migrated = true;
}
if (currentVersion < 3) {
if (!this.hasColumn("nodes", "systemMetrics")) {
this.db.exec("ALTER TABLE nodes ADD COLUMN systemMetrics TEXT");
}
if (!this.hasColumn("nodes", "knownPeers")) {
this.db.exec("ALTER TABLE nodes ADD COLUMN knownPeers TEXT");
}
this.db.exec(CENTRAL_SCHEMA_V3_CREATE_PEERS_SQL);
migrated = true;
}
if (currentVersion < 4) {
if (!this.hasColumn("nodes", "versionInfo")) {
this.db.exec("ALTER TABLE nodes ADD COLUMN versionInfo TEXT");
}
if (!this.hasColumn("nodes", "pluginVersions")) {
this.db.exec("ALTER TABLE nodes ADD COLUMN pluginVersions TEXT");
}
migrated = true;
}
if (currentVersion < 5) {
this.db.exec(CENTRAL_SCHEMA_V5_MIGRATION_SQL);
migrated = true;
}
if (currentVersion < 6) {
this.db.exec(CENTRAL_SCHEMA_V6_MIGRATION_SQL);
migrated = true;
}
if (currentVersion < 7) {
if (!this.hasColumn("nodes", "dockerConfig")) {
this.db.exec("ALTER TABLE nodes ADD COLUMN dockerConfig TEXT");
}
migrated = true;
}
if (migrated) {
this.db
.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run(String(CENTRAL_SCHEMA_VERSION));
} else {
this.db.exec(
`INSERT OR IGNORE INTO __meta (key, value) VALUES ('schemaVersion', '${CENTRAL_SCHEMA_VERSION}')`,
);
}
// Seed lastModified idempotently
this.db.exec(
`INSERT OR IGNORE INTO __meta (key, value) VALUES ('lastModified', '${Date.now()}')`,
);
}
private hasColumn(table: string, column: string): boolean {
const rows = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
return rows.some((row) => row.name === column);
}
/**
* Close the database connection.
*/
close(): void {
this.db.close();
}
/**
* Execute a function inside a SQLite transaction.
* Supports nested calls via SAVEPOINTs.
* If the function throws, the transaction/savepoint is rolled back.
* If the function returns normally, the transaction/savepoint is committed.
*/
transaction<T>(fn: () => T): T {
const depth = this.transactionDepth++;
const isOutermost = depth === 0;
const savepointName = `sp_${depth}`;
if (isOutermost) {
this.db.exec("BEGIN");
} else {
this.db.exec(`SAVEPOINT ${savepointName}`);
}
try {
const result = fn();
if (isOutermost) {
this.db.exec("COMMIT");
} else {
this.db.exec(`RELEASE ${savepointName}`);
}
return result;
} catch (err) {
if (isOutermost) {
this.db.exec("ROLLBACK");
} else {
this.db.exec(`ROLLBACK TO ${savepointName}`);
this.db.exec(`RELEASE ${savepointName}`);
}
throw err;
} finally {
this.transactionDepth--;
}
}
/**
* Prepare a SQL statement. Returns a Statement object.
*/
prepare(sql: string): Statement {
return this.db.prepare(sql);
}
/**
* Execute a raw SQL string (no parameters).
*/
exec(sql: string): void {
this.db.exec(sql);
}
/**
* Get the last modification timestamp (epoch ms).
* Returns 0 if the value is not set.
*/
getLastModified(): number {
const row = this.db.prepare("SELECT value FROM __meta WHERE key = 'lastModified'").get() as
| { value: string }
| undefined;
if (!row) return 0;
return parseInt(row.value, 10) || 0;
}
/**
* Update the last modification timestamp to the current time.
* Guarantees monotonicity: the new value is always strictly greater than
* the previous value, even if called multiple times within the same millisecond.
* Call this after every write operation to enable change detection polling.
*/
bumpLastModified(): void {
const current = this.getLastModified();
const next = Math.max(Date.now(), current + 1);
this.db.prepare("UPDATE __meta SET value = ? WHERE key = 'lastModified'").run(String(next));
}
/**
* Get the schema version number.
*/
getSchemaVersion(): number {
const row = this.db.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'").get() as
| { value: string }
| undefined;
if (!row) return 0;
return parseInt(row.value, 10) || 0;
}
/**
* Get the database file path.
*/
getPath(): string {
return this.dbPath;
}
/**
* Get the global directory path.
*/
getGlobalDir(): string {
return this.globalDir;
}
}
// ── Factory Function ──────────────────────────────────────────────────────
/**
* Create a new CentralDatabase instance (does NOT initialize schema).
* Callers must call `db.init()` separately.
* @param globalDir - Path to the global fusion directory (e.g., `~/.fusion/`)
* @returns CentralDatabase instance (not yet initialized)
*/
export function createCentralDatabase(globalDir?: string): CentralDatabase {
return new CentralDatabase(globalDir);
}