fix: make transaction() re-entrant and add slice_dependencies to initSchema

Two bugs fixed:

1. transaction() now tracks nesting depth. When deleteTask/deleteSlice
   (which wrap in transaction()) are called from within an outer
   transaction() in reassess-roadmap.ts or replan-slice.ts, the inner
   call skips BEGIN/COMMIT since SQLite doesn't support nested
   transactions. This fixes:
   - reassess-handler.test.ts: 3 failing tests
   - replan-handler.test.ts: 4 failing tests
   All errors were: 'cannot start a transaction within a transaction'

2. slice_dependencies table and v13/v14 indexes were only created in
   migrateSchema (for upgrades from older versions) but missing from
   initSchema (for fresh databases). New databases started at schema
   version 14 but never created the table, causing 'no such table:
   slice_dependencies' when deleteSlice was called.
This commit is contained in:
mastertyko 2026-03-26 23:59:46 +01:00
parent 2023291dca
commit 5ab375b773

View file

@ -387,9 +387,31 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void {
)
`);
// Slice dependency junction table (v14)
db.exec(`
CREATE TABLE IF NOT EXISTS slice_dependencies (
milestone_id TEXT NOT NULL,
slice_id TEXT NOT NULL,
depends_on_slice_id TEXT NOT NULL,
PRIMARY KEY (milestone_id, slice_id, depends_on_slice_id),
FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id),
FOREIGN KEY (milestone_id, depends_on_slice_id) REFERENCES slices(milestone_id, id)
)
`);
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)");
db.exec("CREATE INDEX IF NOT EXISTS idx_replan_history_milestone ON replan_history(milestone_id, created_at)");
// v13 indexes — hot-path dispatch queries
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_active ON tasks(milestone_id, slice_id, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_slices_active ON slices(milestone_id, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_milestones_status ON milestones(status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_quality_gates_pending ON quality_gates(milestone_id, slice_id, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_verification_evidence_task ON verification_evidence(milestone_id, slice_id, task_id)");
// v14 index — slice dependency lookups
db.exec("CREATE INDEX IF NOT EXISTS idx_slice_deps_target ON slice_dependencies(milestone_id, depends_on_slice_id)");
db.exec(`CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`);
db.exec(`CREATE VIEW IF NOT EXISTS active_requirements AS SELECT * FROM requirements WHERE superseded_by IS NULL`);
db.exec(`CREATE VIEW IF NOT EXISTS active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL`);
@ -800,8 +822,23 @@ export function vacuumDatabase(): void {
} catch { /* non-fatal */ }
}
let _txDepth = 0;
export function transaction<T>(fn: () => T): T {
if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
// Re-entrant: if already inside a transaction, just run fn() without
// starting a new one. SQLite does not support nested BEGIN/COMMIT.
if (_txDepth > 0) {
_txDepth++;
try {
return fn();
} finally {
_txDepth--;
}
}
_txDepth++;
currentDb.exec("BEGIN");
try {
const result = fn();
@ -810,6 +847,8 @@ export function transaction<T>(fn: () => T): T {
} catch (err) {
currentDb.exec("ROLLBACK");
throw err;
} finally {
_txDepth--;
}
}