diff --git a/packages/daemon/src/db/migrations/003_events.ts b/packages/daemon/src/db/migrations/003_events.ts index 9b680959f..29975332e 100644 --- a/packages/daemon/src/db/migrations/003_events.ts +++ b/packages/daemon/src/db/migrations/003_events.ts @@ -5,11 +5,12 @@ export const eventsSchema: Migration = { sql: ` CREATE TABLE events ( seq INTEGER PRIMARY KEY AUTOINCREMENT, - rig_id TEXT REFERENCES rigs(id) ON DELETE CASCADE, + -- INTENTIONALLY NOT AN FK to rigs(id). + -- Events are an append-only history log. They must survive rig deletion + -- so the full timeline is preserved for replay and audit. + rig_id TEXT, -- INTENTIONALLY NOT AN FK to nodes(id). - -- Events are an append-only history log. They must survive node deletion - -- so the full timeline is preserved for replay and audit. An FK with - -- CASCADE would destroy history; SET NULL would lose the node reference. + -- Same rationale: events must survive node deletion. node_id TEXT, type TEXT NOT NULL, payload TEXT NOT NULL, diff --git a/packages/daemon/src/routes/rigs.ts b/packages/daemon/src/routes/rigs.ts index 446d89626..34ed8df19 100644 --- a/packages/daemon/src/routes/rigs.ts +++ b/packages/daemon/src/routes/rigs.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import type { RigRepository } from "../domain/rig-repository.js"; import type { SessionRegistry } from "../domain/session-registry.js"; +import type { EventBus } from "../domain/event-bus.js"; import { projectRigToGraph } from "../domain/graph-projection.js"; export const rigsRoutes = new Hono(); @@ -46,6 +47,32 @@ rigsRoutes.get("/:id/graph", (c) => { }); rigsRoutes.delete("/:id", (c) => { - getRepo(c).deleteRig(c.req.param("id")); - return c.body(null, 204); + const rigId = c.req.param("id"); + const repo = getRepo(c); + const eventBus = c.get("eventBus" as never) as EventBus; + + // Only emit event + delete if rig exists + const rig = repo.getRig(rigId); + if (!rig) { + return c.body(null, 204); + } + + // Atomic: event persist + rig delete in one transaction + // Uses eventBus.db (same handle as rigRepo.db — enforced by shared AppDeps) + const txn = eventBus.db.transaction(() => { + const persisted = eventBus.persistWithinTransaction({ + type: "rig.deleted", + rigId, + }); + repo.deleteRig(rigId); + return persisted; + }); + + try { + const persistedEvent = txn(); + eventBus.notifySubscribers(persistedEvent); + return c.body(null, 204); + } catch (err) { + return c.json({ error: "delete failed" }, 500); + } }); diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index 996c922aa..4782fe6d0 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -20,6 +20,15 @@ export interface AppDeps { } export function createApp(deps: AppDeps): Hono { + // Hard runtime invariant: all domain services must share the same db handle. + // Routes that use atomic transactions across services depend on this. + if (deps.rigRepo.db !== deps.eventBus.db) { + throw new Error("createApp: rigRepo and eventBus must share the same db handle"); + } + if (deps.rigRepo.db !== deps.sessionRegistry.db) { + throw new Error("createApp: rigRepo and sessionRegistry must share the same db handle"); + } + const app = new Hono(); // Inject dependencies into context for all routes diff --git a/packages/daemon/test/rigs-routes.test.ts b/packages/daemon/test/rigs-routes.test.ts index 1c7cb58ad..8b0d268a3 100644 --- a/packages/daemon/test/rigs-routes.test.ts +++ b/packages/daemon/test/rigs-routes.test.ts @@ -89,9 +89,49 @@ describe("Rig CRUD routes", () => { expect(repo.getRig(rig.id)).toBeNull(); }); - it("DELETE /api/rigs/:id with nonexistent id -> 204 (idempotent)", async () => { + it("DELETE /api/rigs/:id -> rig.deleted event row in DB", async () => { + const rig = repo.createRig("test-rig"); + + const res = await app.request(`/api/rigs/${rig.id}`, { method: "DELETE" }); + expect(res.status).toBe(204); + + const events = db + .prepare("SELECT type, payload FROM events WHERE type = 'rig.deleted'") + .all() as { type: string; payload: string }[]; + expect(events).toHaveLength(1); + const payload = JSON.parse(events[0]!.payload); + expect(payload.rigId).toBe(rig.id); + }); + + it("DELETE /api/rigs/:id with nonexistent id -> 204 + no rig.deleted event", async () => { const res = await app.request("/api/rigs/nonexistent", { method: "DELETE" }); expect(res.status).toBe(204); + + const events = db + .prepare("SELECT * FROM events WHERE type = 'rig.deleted'") + .all(); + expect(events).toHaveLength(0); + }); + + it("DELETE /api/rigs/:id with sabotaged events -> rig still exists + no event row", async () => { + const rig = repo.createRig("test-rig"); + + // Sabotage events table so event insert fails inside the transaction + db.exec("DROP TABLE events"); + db.exec( + "CREATE TABLE events (seq INTEGER PRIMARY KEY AUTOINCREMENT, rig_id TEXT, node_id TEXT, type TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), CONSTRAINT force_fail CHECK(length(type) < 1))" + ); + + const res = await app.request(`/api/rigs/${rig.id}`, { method: "DELETE" }); + // Should fail (transaction rolled back) + expect(res.status).toBe(500); + + // Rig still exists (rollback) + expect(repo.getRig(rig.id)).not.toBeNull(); + + // No partial event row + const events = db.prepare("SELECT * FROM events").all(); + expect(events).toHaveLength(0); }); it("POST /api/rigs with invalid body -> 400", async () => { diff --git a/packages/daemon/test/schema-events.test.ts b/packages/daemon/test/schema-events.test.ts index be128da88..7895eb888 100644 --- a/packages/daemon/test/schema-events.test.ts +++ b/packages/daemon/test/schema-events.test.ts @@ -173,13 +173,37 @@ describe("003_events", () => { expect(events[1]!.type).toBe("session.status_changed"); }); - it("cascades on rig delete", () => { + it("events survive rig deletion (audit trail preserved)", () => { db.prepare( "INSERT INTO events (rig_id, type, payload) VALUES (?, ?, ?)" ).run("rig-1", "event.a", "{}"); + db.prepare( + "INSERT INTO events (rig_id, type, payload) VALUES (?, ?, ?)" + ).run("rig-1", "event.b", "{}"); db.prepare("DELETE FROM rigs WHERE id = ?").run("rig-1"); - const events = db.prepare("SELECT * FROM events").all(); - expect(events).toHaveLength(0); + + // Events must still exist with original rig_id intact + const events = db + .prepare("SELECT rig_id, type FROM events ORDER BY seq") + .all() as { rig_id: string; type: string }[]; + expect(events).toHaveLength(2); + expect(events[0]!.rig_id).toBe("rig-1"); + expect(events[1]!.rig_id).toBe("rig-1"); + }); + + it("allows event with rig_id referencing nonexistent rig (orphan refs)", () => { + expect(() => + db + .prepare( + "INSERT INTO events (rig_id, type, payload) VALUES (?, ?, ?)" + ) + .run("nonexistent-rig", "rig.created", "{}") + ).not.toThrow(); + + const event = db + .prepare("SELECT rig_id FROM events WHERE rig_id = ?") + .get("nonexistent-rig") as { rig_id: string }; + expect(event.rig_id).toBe("nonexistent-rig"); }); }); diff --git a/packages/daemon/test/server.test.ts b/packages/daemon/test/server.test.ts index 180982055..310c99c8c 100644 --- a/packages/daemon/test/server.test.ts +++ b/packages/daemon/test/server.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from "vitest"; import { createFullTestDb, createTestApp } from "./helpers/test-app.js"; +import { RigRepository } from "../src/domain/rig-repository.js"; +import { SessionRegistry } from "../src/domain/session-registry.js"; +import { EventBus } from "../src/domain/event-bus.js"; +import { NodeLauncher } from "../src/domain/node-launcher.js"; +import { createApp } from "../src/server.js"; +import { mockTmuxAdapter, unavailableCmuxAdapter } from "./helpers/test-app.js"; describe("Hono server (production app)", () => { it("GET /healthz returns 200 with status ok", async () => { @@ -29,4 +35,23 @@ describe("Hono server (production app)", () => { expect(Array.isArray(body)).toBe(true); db.close(); }); + + it("createApp throws if rigRepo and eventBus use different db handles", () => { + const db1 = createFullTestDb(); + const db2 = createFullTestDb(); + + const rigRepo = new RigRepository(db1); + const sessionRegistry = new SessionRegistry(db1); + const eventBus = new EventBus(db2); // different handle + const tmux = mockTmuxAdapter(); + const cmux = unavailableCmuxAdapter(); + const nodeLauncher = new NodeLauncher({ db: db1, rigRepo, sessionRegistry, eventBus: new EventBus(db1), tmuxAdapter: tmux }); + + expect(() => + createApp({ rigRepo, sessionRegistry, eventBus, nodeLauncher, tmuxAdapter: tmux, cmuxAdapter: cmux }) + ).toThrow(/same db handle/); + + db1.close(); + db2.close(); + }); });