- Add getAppVersion() utility that walks up directories to find package.json - Add parseSemver() for semver string parsing with major/minor/patch components - Add CentralCore version sync methods (getAppVersion, syncVersion, getLastSyncTime, getSyncStatus) - Add CentralCore events for version sync lifecycle (version-sync-started, version-sync-completed, version-sync-failed) - Add schema v4 migration with appVersion and lastSyncTime columns to projects table - Export new types (CentralSyncStatus, SyncResult) and utilities via @fusion/core - Update memory.md documentation with new types - Fix TypeScript error in app-version.ts (return pkg.version directly instead of cached variable)
546 lines
18 KiB
TypeScript
546 lines
18 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { mkdtempSync, rmSync, statSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { CentralDatabase, createCentralDatabase, toJson, fromJson } from "./central-db.js";
|
|
|
|
describe("CentralDatabase", () => {
|
|
let tempDir: string;
|
|
let db: CentralDatabase;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), "kb-central-test-"));
|
|
db = createCentralDatabase(tempDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("initialization", () => {
|
|
it("should create database at the specified path", () => {
|
|
db.init();
|
|
const dbPath = db.getPath();
|
|
expect(dbPath).toBe(join(tempDir, "fusion-central.db"));
|
|
// Verify file exists
|
|
const stats = statSync(dbPath);
|
|
expect(stats.isFile()).toBe(true);
|
|
});
|
|
|
|
it("should create the global directory if it doesn't exist", () => {
|
|
const newTempDir = join(tmpdir(), `kb-central-test-${Date.now()}`);
|
|
const newDb = createCentralDatabase(newTempDir);
|
|
newDb.init();
|
|
expect(statSync(newTempDir).isDirectory()).toBe(true);
|
|
newDb.close();
|
|
rmSync(newTempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("should initialize schema version", () => {
|
|
db.init();
|
|
expect(db.getSchemaVersion()).toBe(4);
|
|
});
|
|
|
|
it("should seed lastModified on init", () => {
|
|
db.init();
|
|
const lastModified = db.getLastModified();
|
|
expect(lastModified).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should seed globalConcurrency default row", () => {
|
|
db.init();
|
|
const row = db.prepare("SELECT * FROM globalConcurrency WHERE id = 1").get() as {
|
|
id: number;
|
|
globalMaxConcurrent: number;
|
|
currentlyActive: number;
|
|
queuedCount: number;
|
|
} | undefined;
|
|
expect(row).toBeDefined();
|
|
expect(row?.globalMaxConcurrent).toBe(4);
|
|
expect(row?.currentlyActive).toBe(0);
|
|
expect(row?.queuedCount).toBe(0);
|
|
});
|
|
|
|
it("should apply nodes defaults when optional values are omitted", () => {
|
|
db.init();
|
|
const now = new Date().toISOString();
|
|
|
|
db.prepare(
|
|
"INSERT INTO nodes (id, name, type, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)",
|
|
).run("node_test", "local-test", "local", now, now);
|
|
|
|
const row = db.prepare("SELECT status, maxConcurrent FROM nodes WHERE id = ?").get("node_test") as
|
|
| {
|
|
status: string;
|
|
maxConcurrent: number;
|
|
}
|
|
| undefined;
|
|
|
|
expect(row).toBeDefined();
|
|
expect(row?.status).toBe("offline");
|
|
expect(row?.maxConcurrent).toBe(2);
|
|
});
|
|
|
|
it("should create all required tables", () => {
|
|
db.init();
|
|
const tables = db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
.all() as Array<{ name: string }>;
|
|
const tableNames = tables.map((t) => t.name);
|
|
expect(tableNames).toContain("projects");
|
|
expect(tableNames).toContain("projectHealth");
|
|
expect(tableNames).toContain("centralActivityLog");
|
|
expect(tableNames).toContain("globalConcurrency");
|
|
expect(tableNames).toContain("nodes");
|
|
expect(tableNames).toContain("peerNodes");
|
|
expect(tableNames).toContain("__meta");
|
|
});
|
|
|
|
it("should include nodeId column on projects table", () => {
|
|
db.init();
|
|
|
|
const columns = db.prepare("PRAGMA table_info(projects)").all() as Array<{
|
|
name: string;
|
|
}>;
|
|
const columnNames = columns.map((column) => column.name);
|
|
expect(columnNames).toContain("nodeId");
|
|
});
|
|
|
|
it("should include systemMetrics and knownPeers columns on nodes table", () => {
|
|
db.init();
|
|
|
|
const columns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{
|
|
name: string;
|
|
}>;
|
|
const columnNames = columns.map((column) => column.name);
|
|
expect(columnNames).toContain("systemMetrics");
|
|
expect(columnNames).toContain("knownPeers");
|
|
});
|
|
|
|
it("should include versionInfo and pluginVersions columns on nodes table", () => {
|
|
db.init();
|
|
|
|
const columns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{
|
|
name: string;
|
|
}>;
|
|
const columnNames = columns.map((column) => column.name);
|
|
expect(columnNames).toContain("versionInfo");
|
|
expect(columnNames).toContain("pluginVersions");
|
|
});
|
|
|
|
it("should create peerNodes table with expected columns", () => {
|
|
db.init();
|
|
|
|
const columns = db.prepare("PRAGMA table_info(peerNodes)").all() as Array<{
|
|
name: string;
|
|
}>;
|
|
const columnNames = columns.map((column) => column.name);
|
|
|
|
expect(columnNames).toEqual(
|
|
expect.arrayContaining([
|
|
"id",
|
|
"nodeId",
|
|
"peerNodeId",
|
|
"name",
|
|
"url",
|
|
"status",
|
|
"lastSeen",
|
|
"connectedAt",
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("should create required indexes", () => {
|
|
db.init();
|
|
const indexes = db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type='index' ORDER BY name")
|
|
.all() as Array<{ name: string }>;
|
|
const indexNames = indexes.map((i) => i.name);
|
|
expect(indexNames).toContain("idxProjectsPath");
|
|
expect(indexNames).toContain("idxProjectsStatus");
|
|
expect(indexNames).toContain("idxActivityLogTimestamp");
|
|
expect(indexNames).toContain("idxActivityLogType");
|
|
expect(indexNames).toContain("idxActivityLogProjectId");
|
|
expect(indexNames).toContain("idxNodesStatus");
|
|
expect(indexNames).toContain("idxNodesType");
|
|
expect(indexNames).toContain("idxPeerNodesNodeId");
|
|
});
|
|
});
|
|
|
|
describe("schema migrations", () => {
|
|
it("should migrate from v2 to v3 with mesh node columns and peer table", () => {
|
|
const now = new Date().toISOString();
|
|
|
|
db.exec(`
|
|
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
|
|
);
|
|
|
|
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 TABLE IF NOT EXISTS __meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT
|
|
);
|
|
`);
|
|
|
|
db.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '2')").run();
|
|
db.prepare("INSERT INTO __meta (key, value) VALUES ('lastModified', ?)").run(String(Date.now()));
|
|
db.prepare(
|
|
"INSERT INTO nodes (id, name, type, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)",
|
|
).run("node_legacy", "legacy", "local", now, now);
|
|
|
|
db.init();
|
|
|
|
expect(db.getSchemaVersion()).toBe(4);
|
|
|
|
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
|
|
const nodeColumnNames = nodeColumns.map((column) => column.name);
|
|
expect(nodeColumnNames).toContain("systemMetrics");
|
|
expect(nodeColumnNames).toContain("knownPeers");
|
|
|
|
const peerTable = db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='peerNodes'")
|
|
.get() as { name: string } | undefined;
|
|
expect(peerTable?.name).toBe("peerNodes");
|
|
|
|
const peerIndexes = db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='peerNodes'")
|
|
.all() as Array<{ name: string }>;
|
|
expect(peerIndexes.map((index) => index.name)).toContain("idxPeerNodesNodeId");
|
|
});
|
|
|
|
it("should migrate from v3 to v4 with version tracking columns", () => {
|
|
const now = new Date().toISOString();
|
|
|
|
// Create v3 schema manually
|
|
db.exec(`
|
|
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
|
|
);
|
|
|
|
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,
|
|
maxConcurrent INTEGER NOT NULL DEFAULT 2,
|
|
createdAt TEXT NOT NULL,
|
|
updatedAt TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS __meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT
|
|
);
|
|
`);
|
|
|
|
db.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '3')").run();
|
|
db.prepare("INSERT INTO __meta (key, value) VALUES ('lastModified', ?)").run(String(Date.now()));
|
|
db.prepare(
|
|
"INSERT INTO nodes (id, name, type, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)",
|
|
).run("node_v3", "v3-node", "local", now, now);
|
|
|
|
db.init();
|
|
|
|
expect(db.getSchemaVersion()).toBe(4);
|
|
|
|
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
|
|
const nodeColumnNames = nodeColumns.map((column) => column.name);
|
|
expect(nodeColumnNames).toContain("versionInfo");
|
|
expect(nodeColumnNames).toContain("pluginVersions");
|
|
|
|
// Verify nullable columns - can insert node without them
|
|
const row = db.prepare("SELECT versionInfo, pluginVersions FROM nodes WHERE id = ?").get("node_v3") as {
|
|
versionInfo: string | null;
|
|
pluginVersions: string | null;
|
|
} | undefined;
|
|
expect(row).toBeDefined();
|
|
expect(row?.versionInfo).toBeNull();
|
|
expect(row?.pluginVersions).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("transactions", () => {
|
|
beforeEach(() => {
|
|
db.init();
|
|
});
|
|
|
|
it("should support basic transactions", () => {
|
|
db.transaction(() => {
|
|
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
|
|
"proj_1",
|
|
"Test Project",
|
|
"/test/path",
|
|
"active",
|
|
"in-process",
|
|
new Date().toISOString(),
|
|
new Date().toISOString()
|
|
);
|
|
});
|
|
|
|
const row = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_1") as { id: string; name: string } | undefined;
|
|
expect(row).toBeDefined();
|
|
expect(row?.name).toBe("Test Project");
|
|
});
|
|
|
|
it("should rollback on error", () => {
|
|
expect(() => {
|
|
db.transaction(() => {
|
|
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
|
|
"proj_2",
|
|
"Test Project",
|
|
"/test/path",
|
|
"active",
|
|
"in-process",
|
|
new Date().toISOString(),
|
|
new Date().toISOString()
|
|
);
|
|
throw new Error("Intentional error");
|
|
});
|
|
}).toThrow("Intentional error");
|
|
|
|
const row = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_2") as { id: string } | undefined;
|
|
expect(row).toBeUndefined();
|
|
});
|
|
|
|
it("should support nested transactions via savepoints", () => {
|
|
db.transaction(() => {
|
|
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
|
|
"proj_outer",
|
|
"Outer Project",
|
|
"/outer/path",
|
|
"active",
|
|
"in-process",
|
|
new Date().toISOString(),
|
|
new Date().toISOString()
|
|
);
|
|
|
|
db.transaction(() => {
|
|
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
|
|
"proj_inner",
|
|
"Inner Project",
|
|
"/inner/path",
|
|
"active",
|
|
"in-process",
|
|
new Date().toISOString(),
|
|
new Date().toISOString()
|
|
);
|
|
});
|
|
});
|
|
|
|
const outerRow = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_outer") as { id: string } | undefined;
|
|
const innerRow = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_inner") as { id: string } | undefined;
|
|
expect(outerRow).toBeDefined();
|
|
expect(innerRow).toBeDefined();
|
|
});
|
|
|
|
it("should rollback nested transaction without affecting outer", () => {
|
|
db.transaction(() => {
|
|
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
|
|
"proj_outer_2",
|
|
"Outer Project",
|
|
"/outer/path",
|
|
"active",
|
|
"in-process",
|
|
new Date().toISOString(),
|
|
new Date().toISOString()
|
|
);
|
|
|
|
// Inner transaction throws but is caught
|
|
try {
|
|
db.transaction(() => {
|
|
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
|
|
"proj_inner_2",
|
|
"Inner Project",
|
|
"/inner/path",
|
|
"active",
|
|
"in-process",
|
|
new Date().toISOString(),
|
|
new Date().toISOString()
|
|
);
|
|
throw new Error("Inner error");
|
|
});
|
|
} catch {
|
|
// Ignore inner error
|
|
}
|
|
});
|
|
|
|
const outerRow = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_outer_2") as { id: string } | undefined;
|
|
const innerRow = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_inner_2") as { id: string } | undefined;
|
|
expect(outerRow).toBeDefined();
|
|
expect(innerRow).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("lastModified tracking", () => {
|
|
beforeEach(() => {
|
|
db.init();
|
|
});
|
|
|
|
it("should bump lastModified", () => {
|
|
const before = db.getLastModified();
|
|
// Small delay to ensure different timestamp
|
|
const start = Date.now();
|
|
while (Date.now() < start + 2) { /* spin */ }
|
|
|
|
db.bumpLastModified();
|
|
const after = db.getLastModified();
|
|
expect(after).toBeGreaterThan(before);
|
|
});
|
|
|
|
it("should guarantee monotonic increase", () => {
|
|
db.bumpLastModified();
|
|
const first = db.getLastModified();
|
|
db.bumpLastModified();
|
|
const second = db.getLastModified();
|
|
expect(second).toBeGreaterThan(first);
|
|
});
|
|
});
|
|
|
|
describe("foreign key constraints", () => {
|
|
beforeEach(() => {
|
|
db.init();
|
|
});
|
|
|
|
it("should enforce foreign key constraints", () => {
|
|
// Try to insert health record for non-existent project
|
|
expect(() => {
|
|
db.prepare("INSERT INTO projectHealth (projectId, status, updatedAt) VALUES (?, ?, ?)").run(
|
|
"nonexistent",
|
|
"active",
|
|
new Date().toISOString()
|
|
);
|
|
}).toThrow();
|
|
});
|
|
|
|
it("should cascade delete project health on project deletion", () => {
|
|
const now = new Date().toISOString();
|
|
|
|
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
|
|
"proj_cascade",
|
|
"Cascade Test",
|
|
"/cascade/path",
|
|
"active",
|
|
"in-process",
|
|
now,
|
|
now
|
|
);
|
|
|
|
db.prepare("INSERT INTO projectHealth (projectId, status, updatedAt) VALUES (?, ?, ?)").run(
|
|
"proj_cascade",
|
|
"active",
|
|
now
|
|
);
|
|
|
|
// Verify health record exists
|
|
const healthBefore = db.prepare("SELECT * FROM projectHealth WHERE projectId = ?").get("proj_cascade") as { projectId: string } | undefined;
|
|
expect(healthBefore).toBeDefined();
|
|
|
|
// Delete project
|
|
db.prepare("DELETE FROM projects WHERE id = ?").run("proj_cascade");
|
|
|
|
// Health record should be gone (cascade delete)
|
|
const healthAfter = db.prepare("SELECT * FROM projectHealth WHERE projectId = ?").get("proj_cascade") as { projectId: string } | undefined;
|
|
expect(healthAfter).toBeUndefined();
|
|
});
|
|
|
|
it("should cascade delete activity log entries on project deletion", () => {
|
|
const now = new Date().toISOString();
|
|
|
|
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
|
|
"proj_activity",
|
|
"Activity Test",
|
|
"/activity/path",
|
|
"active",
|
|
"in-process",
|
|
now,
|
|
now
|
|
);
|
|
|
|
db.prepare("INSERT INTO centralActivityLog (id, timestamp, type, projectId, projectName, details) VALUES (?, ?, ?, ?, ?, ?)").run(
|
|
"log_1",
|
|
now,
|
|
"task:created",
|
|
"proj_activity",
|
|
"Activity Test",
|
|
"Test activity"
|
|
);
|
|
|
|
// Verify log entry exists
|
|
const logBefore = db.prepare("SELECT * FROM centralActivityLog WHERE id = ?").get("log_1") as { id: string } | undefined;
|
|
expect(logBefore).toBeDefined();
|
|
|
|
// Delete project
|
|
db.prepare("DELETE FROM projects WHERE id = ?").run("proj_activity");
|
|
|
|
// Log entry should be gone (cascade delete)
|
|
const logAfter = db.prepare("SELECT * FROM centralActivityLog WHERE id = ?").get("log_1") as { id: string } | undefined;
|
|
expect(logAfter).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("JSON helpers", () => {
|
|
it("should stringify arrays for JSON columns", () => {
|
|
const arr = ["a", "b", "c"];
|
|
expect(toJson(arr)).toBe('["a","b","c"]');
|
|
});
|
|
|
|
it("should return '[]' for null/undefined", () => {
|
|
expect(toJson(null)).toBe("[]");
|
|
expect(toJson(undefined)).toBe("[]");
|
|
});
|
|
|
|
it("should parse JSON columns correctly", () => {
|
|
const json = '{"key": "value", "num": 42}';
|
|
const parsed = fromJson<{ key: string; num: number }>(json);
|
|
expect(parsed).toEqual({ key: "value", num: 42 });
|
|
});
|
|
|
|
it("should return undefined for null/empty JSON", () => {
|
|
expect(fromJson(null)).toBeUndefined();
|
|
expect(fromJson(undefined)).toBeUndefined();
|
|
expect(fromJson("")).toBeUndefined();
|
|
});
|
|
|
|
it("should return undefined for invalid JSON", () => {
|
|
expect(fromJson("not valid json")).toBeUndefined();
|
|
});
|
|
});
|
|
});
|