Files
fusion/packages/core/src/central-db.test.ts

339 lines
11 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(1);
});
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 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("__meta");
});
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");
});
});
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();
});
});
});