feat(FN-1690): add RoadmapStore with SQLite persistence

- Implement RoadmapStore with CRUD operations for roadmaps, milestones, and features
- Add roadmap schema migration v32 with roadmaps, roadmap_milestones, and roadmap_features tables
- Add covering indexes for deterministic ordering within roadmaps and milestones
- Wire RoadmapStore access via TaskStore.getRoadmapStore()
- Update architecture.md documentation with RoadmapStore persistence
- Add comprehensive tests for RoadmapStore CRUD and ordering
- Update db.test.ts with schema v32 assertions
- Add UpdateInput type imports to task-documents.test.ts
- Export RoadmapStore from core index.ts
This commit is contained in:
gsxdsm
2026-04-14 09:57:09 -07:00
parent 7857b9660b
commit d8982d5336
10 changed files with 1592 additions and 17 deletions

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(31);
expect(db.getSchemaVersion()).toBe(32);
const index = db
.prepare(

View File

@@ -75,6 +75,10 @@ describe("Database", () => {
expect(tableNames).toContain("agentRatings");
expect(tableNames).toContain("task_documents");
expect(tableNames).toContain("task_document_revisions");
// Roadmap tables
expect(tableNames).toContain("roadmaps");
expect(tableNames).toContain("roadmap_milestones");
expect(tableNames).toContain("roadmap_features");
});
it("creates all expected indexes", () => {
@@ -109,10 +113,13 @@ describe("Database", () => {
expect(indexNames).toContain("idxTaskDocumentsTaskId");
expect(indexNames).toContain("idxTaskDocumentRevisionsTaskKey");
expect(indexNames).toContain("idxTasksCreatedAt");
// Roadmap indexes
expect(indexNames).toContain("idxRoadmapMilestonesRoadmapOrder");
expect(indexNames).toContain("idxRoadmapFeaturesMilestoneOrder");
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(31);
expect(db.getSchemaVersion()).toBe(32);
});
it("seeds lastModified", () => {
@@ -135,7 +142,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(31);
expect(db.getSchemaVersion()).toBe(32);
});
it("does not overwrite existing config on re-init", () => {
@@ -742,7 +749,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(31);
expect(db.getSchemaVersion()).toBe(32);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -767,11 +774,11 @@ describe("schema migrations", () => {
const db = new Database(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(31);
expect(db.getSchemaVersion()).toBe(32);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(31);
expect(db.getSchemaVersion()).toBe(32);
db.close();
});
@@ -787,7 +794,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(31);
expect(db.getSchemaVersion()).toBe(32);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -811,7 +818,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(31);
expect(db.getSchemaVersion()).toBe(32);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -915,7 +922,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(31);
expect(db.getSchemaVersion()).toBe(32);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1281,7 +1288,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(31);
expect(db.getSchemaVersion()).toBe(32);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 31;
const SCHEMA_VERSION = 32;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -1176,6 +1176,66 @@ export class Database {
this.db.exec(`CREATE INDEX IF NOT EXISTS idxFixLineageRunId ON mission_fix_feature_lineage(runId)`);
});
}
// Roadmap persistence tables (FN-1690)
// Standalone roadmap: Roadmap → RoadmapMilestone → RoadmapFeature
// with deterministic ordering indexes and FK cascade integrity
if (version < 32) {
this.applyMigration(32, () => {
// Roadmaps table
this.db.exec(`
CREATE TABLE IF NOT EXISTS roadmaps (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
// Roadmap milestones table
this.db.exec(`
CREATE TABLE IF NOT EXISTS roadmap_milestones (
id TEXT PRIMARY KEY,
roadmapId TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
orderIndex INTEGER NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (roadmapId) REFERENCES roadmaps(id) ON DELETE CASCADE
)
`);
// Roadmap features table
this.db.exec(`
CREATE TABLE IF NOT EXISTS roadmap_features (
id TEXT PRIMARY KEY,
milestoneId TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
orderIndex INTEGER NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (milestoneId) REFERENCES roadmap_milestones(id) ON DELETE CASCADE
)
`);
// Covering index for deterministic milestone ordering within a roadmap
// Covers: WHERE roadmapId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxRoadmapMilestonesRoadmapOrder
ON roadmap_milestones(roadmapId, orderIndex, createdAt, id)
`);
// Covering index for deterministic feature ordering within a milestone
// Covers: WHERE milestoneId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxRoadmapFeaturesMilestoneOrder
ON roadmap_features(milestoneId, orderIndex, createdAt, id)
`);
});
}
}
/**

View File

@@ -273,6 +273,8 @@ export type {
} from "./mission-types.js";
export { MissionStore } from "./mission-store.js";
export type { MissionStoreEvents, MissionSummary } from "./mission-store.js";
export { RoadmapStore } from "./roadmap-store.js";
export type { RoadmapStoreEvents } from "./roadmap-store.js";
// ── Central Infrastructure (Multi-Project Support) ───────────────────────────

View File

@@ -2457,8 +2457,8 @@ describe("MissionStore", () => {
// ── Loop State & Validator Run Schema Tests ───────────────────────────
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 31 after migration", () => {
expect(db.getSchemaVersion()).toBe(31);
it("schema version is 32 after migration", () => {
expect(db.getSchemaVersion()).toBe(32);
});
it("mission_features table has loop state columns", () => {

View File

@@ -0,0 +1,745 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Database, createDatabase } from "./db.js";
import { RoadmapStore } from "./roadmap-store.js";
import type {
RoadmapCreateInput,
RoadmapUpdateInput,
RoadmapMilestoneCreateInput,
RoadmapMilestoneUpdateInput,
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapMilestoneReorderInput,
RoadmapFeatureReorderInput,
RoadmapFeatureMoveInput,
} from "./roadmap-types.js";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "roadmap-store-test-"));
}
describe("RoadmapStore", () => {
let tmpDir: string;
let db: Database;
let store: RoadmapStore;
beforeEach(() => {
tmpDir = makeTmpDir();
db = new Database(join(tmpDir, ".fusion"));
db.init();
store = new RoadmapStore(db);
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
describe("roadmap CRUD", () => {
it("creates a roadmap", () => {
const input: RoadmapCreateInput = { title: "Test Roadmap" };
const roadmap = store.createRoadmap(input);
expect(roadmap.id).toMatch(/^RM-/);
expect(roadmap.title).toBe("Test Roadmap");
expect(roadmap.description).toBeUndefined();
expect(roadmap.createdAt).toBeTruthy();
expect(roadmap.updatedAt).toBeTruthy();
});
it("creates a roadmap with description", () => {
const input: RoadmapCreateInput = {
title: "Test Roadmap",
description: "A detailed description",
};
const roadmap = store.createRoadmap(input);
expect(roadmap.title).toBe("Test Roadmap");
expect(roadmap.description).toBe("A detailed description");
});
it("gets a roadmap by id", () => {
const created = store.createRoadmap({ title: "Test" });
const retrieved = store.getRoadmap(created.id);
expect(retrieved).toEqual(created);
});
it("returns undefined for non-existent roadmap", () => {
const retrieved = store.getRoadmap("RM-nonexistent");
expect(retrieved).toBeUndefined();
});
it("lists all roadmaps", () => {
const r1 = store.createRoadmap({ title: "Roadmap 1" });
const r2 = store.createRoadmap({ title: "Roadmap 2" });
const r3 = store.createRoadmap({ title: "Roadmap 3" });
const roadmaps = store.listRoadmaps();
expect(roadmaps.length).toBe(3);
// Should contain all three (order depends on createdAt timestamps)
const titles = roadmaps.map((r) => r.title);
expect(titles).toContain("Roadmap 1");
expect(titles).toContain("Roadmap 2");
expect(titles).toContain("Roadmap 3");
});
it("updates a roadmap", () => {
const created = store.createRoadmap({ title: "Original" });
const updated = store.updateRoadmap(created.id, { title: "Updated" } as RoadmapUpdateInput);
expect(updated.id).toBe(created.id);
expect(updated.title).toBe("Updated");
expect(updated.createdAt).toBe(created.createdAt);
});
it("throws when updating non-existent roadmap", () => {
expect(() => store.updateRoadmap("RM-nonexistent", { title: "Test" } as RoadmapUpdateInput))
.toThrow("Roadmap RM-nonexistent not found");
});
it("deletes a roadmap", () => {
const created = store.createRoadmap({ title: "Test" });
store.deleteRoadmap(created.id);
expect(store.getRoadmap(created.id)).toBeUndefined();
});
it("throws when deleting non-existent roadmap", () => {
expect(() => store.deleteRoadmap("RM-nonexistent"))
.toThrow("Roadmap RM-nonexistent not found");
});
it("emits roadmap:created event", () => {
const listener = vi.fn();
store.on("roadmap:created", listener);
const roadmap = store.createRoadmap({ title: "Test" });
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(roadmap);
});
it("emits roadmap:updated event", () => {
const created = store.createRoadmap({ title: "Original" });
const listener = vi.fn();
store.on("roadmap:updated", listener);
store.updateRoadmap(created.id, { title: "Updated" } as RoadmapUpdateInput);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ title: "Updated" }));
});
it("emits roadmap:deleted event", () => {
const created = store.createRoadmap({ title: "Test" });
const listener = vi.fn();
store.on("roadmap:deleted", listener);
store.deleteRoadmap(created.id);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(created.id);
});
});
describe("milestone CRUD", () => {
let roadmapId: string;
beforeEach(() => {
roadmapId = store.createRoadmap({ title: "Test Roadmap" }).id;
});
it("creates a milestone with auto-computed orderIndex", () => {
const m1 = store.createMilestone(roadmapId, { title: "Milestone 1" });
const m2 = store.createMilestone(roadmapId, { title: "Milestone 2" });
const m3 = store.createMilestone(roadmapId, { title: "Milestone 3" });
expect(m1.orderIndex).toBe(0);
expect(m2.orderIndex).toBe(1);
expect(m3.orderIndex).toBe(2);
});
it("creates a milestone with description", () => {
const milestone = store.createMilestone(roadmapId, {
title: "Milestone",
description: "A detailed description",
});
expect(milestone.title).toBe("Milestone");
expect(milestone.description).toBe("A detailed description");
expect(milestone.roadmapId).toBe(roadmapId);
});
it("throws when creating milestone for non-existent roadmap", () => {
expect(() => store.createMilestone("RM-nonexistent", { title: "Test" }))
.toThrow("Roadmap RM-nonexistent not found");
});
it("gets a milestone by id", () => {
const created = store.createMilestone(roadmapId, { title: "Test" });
const retrieved = store.getMilestone(created.id);
expect(retrieved).toEqual(created);
});
it("lists milestones with deterministic ordering", () => {
store.createMilestone(roadmapId, { title: "First" });
store.createMilestone(roadmapId, { title: "Second" });
store.createMilestone(roadmapId, { title: "Third" });
const milestones = store.listMilestones(roadmapId);
expect(milestones.length).toBe(3);
expect(milestones[0].title).toBe("First");
expect(milestones[1].title).toBe("Second");
expect(milestones[2].title).toBe("Third");
expect(milestones[0].orderIndex).toBe(0);
expect(milestones[1].orderIndex).toBe(1);
expect(milestones[2].orderIndex).toBe(2);
});
it("updates a milestone", () => {
const created = store.createMilestone(roadmapId, { title: "Original" });
const updated = store.updateMilestone(created.id, { title: "Updated" } as RoadmapMilestoneUpdateInput);
expect(updated.id).toBe(created.id);
expect(updated.title).toBe("Updated");
expect(updated.roadmapId).toBe(roadmapId);
});
it("throws when updating non-existent milestone", () => {
expect(() => store.updateMilestone("RMS-nonexistent", { title: "Test" } as RoadmapMilestoneUpdateInput))
.toThrow("Milestone RMS-nonexistent not found");
});
it("deletes a milestone", () => {
const created = store.createMilestone(roadmapId, { title: "Test" });
store.deleteMilestone(created.id);
expect(store.getMilestone(created.id)).toBeUndefined();
});
it("cascade-deletes features when deleting milestone", () => {
const milestone = store.createMilestone(roadmapId, { title: "Test" });
const feature = store.createFeature(milestone.id, { title: "Feature" });
store.deleteMilestone(milestone.id);
expect(store.getFeature(feature.id)).toBeUndefined();
});
it("cascade-deletes milestones when deleting roadmap", () => {
const m1 = store.createMilestone(roadmapId, { title: "Milestone 1" });
const m2 = store.createMilestone(roadmapId, { title: "Milestone 2" });
store.deleteRoadmap(roadmapId);
expect(store.getMilestone(m1.id)).toBeUndefined();
expect(store.getMilestone(m2.id)).toBeUndefined();
});
});
describe("feature CRUD", () => {
let milestoneId: string;
beforeEach(() => {
const roadmapId = store.createRoadmap({ title: "Test Roadmap" }).id;
milestoneId = store.createMilestone(roadmapId, { title: "Test Milestone" }).id;
});
it("creates a feature with auto-computed orderIndex", () => {
const f1 = store.createFeature(milestoneId, { title: "Feature 1" });
const f2 = store.createFeature(milestoneId, { title: "Feature 2" });
const f3 = store.createFeature(milestoneId, { title: "Feature 3" });
expect(f1.orderIndex).toBe(0);
expect(f2.orderIndex).toBe(1);
expect(f3.orderIndex).toBe(2);
});
it("creates a feature with description", () => {
const feature = store.createFeature(milestoneId, {
title: "Feature",
description: "A detailed description",
});
expect(feature.title).toBe("Feature");
expect(feature.description).toBe("A detailed description");
expect(feature.milestoneId).toBe(milestoneId);
});
it("throws when creating feature for non-existent milestone", () => {
expect(() => store.createFeature("RMS-nonexistent", { title: "Test" }))
.toThrow("Milestone RMS-nonexistent not found");
});
it("gets a feature by id", () => {
const created = store.createFeature(milestoneId, { title: "Test" });
const retrieved = store.getFeature(created.id);
expect(retrieved).toEqual(created);
});
it("lists features with deterministic ordering", () => {
store.createFeature(milestoneId, { title: "First" });
store.createFeature(milestoneId, { title: "Second" });
store.createFeature(milestoneId, { title: "Third" });
const features = store.listFeatures(milestoneId);
expect(features.length).toBe(3);
expect(features[0].title).toBe("First");
expect(features[1].title).toBe("Second");
expect(features[2].title).toBe("Third");
expect(features[0].orderIndex).toBe(0);
expect(features[1].orderIndex).toBe(1);
expect(features[2].orderIndex).toBe(2);
});
it("updates a feature", () => {
const created = store.createFeature(milestoneId, { title: "Original" });
const updated = store.updateFeature(created.id, { title: "Updated" } as RoadmapFeatureUpdateInput);
expect(updated.id).toBe(created.id);
expect(updated.title).toBe("Updated");
expect(updated.milestoneId).toBe(milestoneId);
});
it("throws when updating non-existent feature", () => {
expect(() => store.updateFeature("RF-nonexistent", { title: "Test" } as RoadmapFeatureUpdateInput))
.toThrow("Feature RF-nonexistent not found");
});
it("deletes a feature", () => {
const created = store.createFeature(milestoneId, { title: "Test" });
store.deleteFeature(created.id);
expect(store.getFeature(created.id)).toBeUndefined();
});
});
describe("milestone reorder", () => {
let roadmapId: string;
let milestoneIds: string[];
beforeEach(() => {
roadmapId = store.createRoadmap({ title: "Test Roadmap" }).id;
milestoneIds = [
store.createMilestone(roadmapId, { title: "M1" }).id,
store.createMilestone(roadmapId, { title: "M2" }).id,
store.createMilestone(roadmapId, { title: "M3" }).id,
];
});
it("reorders milestones with complete list", () => {
const input: RoadmapMilestoneReorderInput = {
roadmapId,
orderedMilestoneIds: [milestoneIds[2], milestoneIds[0], milestoneIds[1]],
};
const reordered = store.reorderMilestones(input);
expect(reordered.length).toBe(3);
expect(reordered[0].id).toBe(milestoneIds[2]);
expect(reordered[0].orderIndex).toBe(0);
expect(reordered[1].id).toBe(milestoneIds[0]);
expect(reordered[1].orderIndex).toBe(1);
expect(reordered[2].id).toBe(milestoneIds[1]);
expect(reordered[2].orderIndex).toBe(2);
});
it("rejects partial reorder list", () => {
const input: RoadmapMilestoneReorderInput = {
roadmapId,
orderedMilestoneIds: [milestoneIds[2], milestoneIds[0]], // Missing milestoneIds[1]
};
expect(() => store.reorderMilestones(input))
.toThrow("Expected 3 milestone ids but received 2");
});
it("rejects duplicate reorder list", () => {
const input: RoadmapMilestoneReorderInput = {
roadmapId,
orderedMilestoneIds: [milestoneIds[0], milestoneIds[0], milestoneIds[1]], // Duplicate
};
expect(() => store.reorderMilestones(input))
.toThrow("Duplicate milestone id in requested order");
});
it("rejects non-existent milestone in reorder list", () => {
const input: RoadmapMilestoneReorderInput = {
roadmapId,
orderedMilestoneIds: [milestoneIds[0], milestoneIds[1], "RMS-nonexistent"],
};
expect(() => store.reorderMilestones(input))
.toThrow("Milestone RMS-nonexistent not found");
});
it("emits milestone:reordered event", () => {
const listener = vi.fn();
store.on("milestone:reordered", listener);
const input: RoadmapMilestoneReorderInput = {
roadmapId,
orderedMilestoneIds: [milestoneIds[1], milestoneIds[0], milestoneIds[2]],
};
store.reorderMilestones(input);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({
roadmapId,
milestones: expect.any(Array),
});
});
});
describe("feature reorder", () => {
let roadmapId: string;
let milestoneId: string;
let featureIds: string[];
beforeEach(() => {
roadmapId = store.createRoadmap({ title: "Test Roadmap" }).id;
milestoneId = store.createMilestone(roadmapId, { title: "Test Milestone" }).id;
featureIds = [
store.createFeature(milestoneId, { title: "F1" }).id,
store.createFeature(milestoneId, { title: "F2" }).id,
store.createFeature(milestoneId, { title: "F3" }).id,
];
});
it("reorders features with complete list", () => {
const input: RoadmapFeatureReorderInput = {
roadmapId,
milestoneId,
orderedFeatureIds: [featureIds[2], featureIds[0], featureIds[1]],
};
const reordered = store.reorderFeatures(input);
expect(reordered.length).toBe(3);
expect(reordered[0].id).toBe(featureIds[2]);
expect(reordered[0].orderIndex).toBe(0);
expect(reordered[1].id).toBe(featureIds[0]);
expect(reordered[1].orderIndex).toBe(1);
expect(reordered[2].id).toBe(featureIds[1]);
expect(reordered[2].orderIndex).toBe(2);
});
it("rejects partial reorder list", () => {
const input: RoadmapFeatureReorderInput = {
roadmapId,
milestoneId,
orderedFeatureIds: [featureIds[2], featureIds[0]], // Missing featureIds[1]
};
expect(() => store.reorderFeatures(input))
.toThrow("Expected 3 feature ids but received 2");
});
it("rejects duplicate reorder list", () => {
const input: RoadmapFeatureReorderInput = {
roadmapId,
milestoneId,
orderedFeatureIds: [featureIds[0], featureIds[0], featureIds[1]], // Duplicate
};
expect(() => store.reorderFeatures(input))
.toThrow("Duplicate feature id in requested order");
});
it("rejects feature from wrong milestone", () => {
const m2 = store.createMilestone(roadmapId, { title: "M2" });
const fWrongMilestone = store.createFeature(m2.id, { title: "Wrong" });
const input: RoadmapFeatureReorderInput = {
roadmapId,
milestoneId,
orderedFeatureIds: [featureIds[0], featureIds[1], fWrongMilestone.id],
};
expect(() => store.reorderFeatures(input))
.toThrow(`Feature ${fWrongMilestone.id} not found in scoped list`);
});
it("emits feature:reordered event", () => {
const listener = vi.fn();
store.on("feature:reordered", listener);
const input: RoadmapFeatureReorderInput = {
roadmapId,
milestoneId,
orderedFeatureIds: [featureIds[1], featureIds[0], featureIds[2]],
};
store.reorderFeatures(input);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({
milestoneId,
features: expect.any(Array),
});
});
});
describe("feature move", () => {
let roadmapId: string;
let milestoneA: string;
let milestoneB: string;
let featureA1: string;
let featureA2: string;
let featureB1: string;
beforeEach(() => {
roadmapId = store.createRoadmap({ title: "Test Roadmap" }).id;
milestoneA = store.createMilestone(roadmapId, { title: "Milestone A" }).id;
milestoneB = store.createMilestone(roadmapId, { title: "Milestone B" }).id;
featureA1 = store.createFeature(milestoneA, { title: "A1" }).id;
featureA2 = store.createFeature(milestoneA, { title: "A2" }).id;
featureB1 = store.createFeature(milestoneB, { title: "B1" }).id;
});
it("moves feature within same milestone", () => {
const input: RoadmapFeatureMoveInput = {
roadmapId,
featureId: featureA1,
fromMilestoneId: milestoneA,
toMilestoneId: milestoneA,
targetOrderIndex: 1,
};
const result = store.moveFeature(input);
expect(result.movedFeature.id).toBe(featureA1);
expect(result.movedFeature.milestoneId).toBe(milestoneA);
// Same milestone move: source and target are the same list
expect(result.sourceMilestoneFeatures.length).toBe(2);
expect(result.targetMilestoneFeatures.length).toBe(2);
expect(result.sourceMilestoneFeatures).toEqual(result.targetMilestoneFeatures);
// featureA1 should now be at index 1 (A2 at 0, A1 at 1)
const moved = result.sourceMilestoneFeatures.find((f) => f.id === featureA1);
expect(moved?.orderIndex).toBe(1);
});
it("moves feature across milestones", () => {
const input: RoadmapFeatureMoveInput = {
roadmapId,
featureId: featureA1,
fromMilestoneId: milestoneA,
toMilestoneId: milestoneB,
targetOrderIndex: 1,
};
const result = store.moveFeature(input);
expect(result.movedFeature.id).toBe(featureA1);
expect(result.movedFeature.milestoneId).toBe(milestoneB);
expect(result.movedFeature.orderIndex).toBe(1);
// Source milestone should have featureA2 only
expect(result.sourceMilestoneFeatures.length).toBe(1);
expect(result.sourceMilestoneFeatures[0].id).toBe(featureA2);
// Target milestone should have B1 and A1
expect(result.targetMilestoneFeatures.length).toBe(2);
expect(result.targetMilestoneFeatures[0].id).toBe(featureB1);
expect(result.targetMilestoneFeatures[1].id).toBe(featureA1);
});
it("atomically renumbers both milestones on cross-milestone move", () => {
const input: RoadmapFeatureMoveInput = {
roadmapId,
featureId: featureA1,
fromMilestoneId: milestoneA,
toMilestoneId: milestoneB,
targetOrderIndex: 0,
};
const result = store.moveFeature(input);
// Verify source milestone renumbered
const sourceOrder = result.sourceMilestoneFeatures.map((f) => f.orderIndex);
expect(sourceOrder).toEqual([0]); // Only A2 remains, should be 0
// Verify target milestone renumbered
const targetOrder = result.targetMilestoneFeatures.map((f) => f.orderIndex);
expect(targetOrder).toEqual([0, 1]); // A1 at 0, B1 at 1
});
it("rejects move of non-existent feature", () => {
const input: RoadmapFeatureMoveInput = {
roadmapId,
featureId: "RF-nonexistent",
fromMilestoneId: milestoneA,
toMilestoneId: milestoneB,
targetOrderIndex: 0,
};
expect(() => store.moveFeature(input))
.toThrow("Feature RF-nonexistent not found in affected milestone scope");
});
it("rejects move from non-existent milestone", () => {
const input: RoadmapFeatureMoveInput = {
roadmapId,
featureId: featureA1,
fromMilestoneId: "RMS-nonexistent",
toMilestoneId: milestoneB,
targetOrderIndex: 0,
};
expect(() => store.moveFeature(input))
.toThrow("Source milestone RMS-nonexistent not found");
});
it("rejects move to non-existent milestone", () => {
const input: RoadmapFeatureMoveInput = {
roadmapId,
featureId: featureA1,
fromMilestoneId: milestoneA,
toMilestoneId: "RMS-nonexistent",
targetOrderIndex: 0,
};
expect(() => store.moveFeature(input))
.toThrow("Destination milestone RMS-nonexistent not found");
});
it("rejects move to milestone in different roadmap", () => {
const otherRoadmap = store.createRoadmap({ title: "Other" }).id;
const otherMilestone = store.createMilestone(otherRoadmap, { title: "Other M" }).id;
const input: RoadmapFeatureMoveInput = {
roadmapId,
featureId: featureA1,
fromMilestoneId: milestoneA,
toMilestoneId: otherMilestone,
targetOrderIndex: 0,
};
expect(() => store.moveFeature(input))
.toThrow(`Destination milestone ${otherMilestone} does not belong to roadmap ${roadmapId}`);
});
it("emits feature:moved event", () => {
const listener = vi.fn();
store.on("feature:moved", listener);
const input: RoadmapFeatureMoveInput = {
roadmapId,
featureId: featureA1,
fromMilestoneId: milestoneA,
toMilestoneId: milestoneB,
targetOrderIndex: 0,
};
store.moveFeature(input);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({
feature: expect.objectContaining({ id: featureA1, milestoneId: milestoneB }),
fromMilestoneId: milestoneA,
toMilestoneId: milestoneB,
});
});
});
describe("hierarchy operations", () => {
let roadmapId: string;
let milestoneId1: string;
let milestoneId2: string;
beforeEach(() => {
roadmapId = store.createRoadmap({ title: "Test Roadmap" }).id;
milestoneId1 = store.createMilestone(roadmapId, { title: "M1" }).id;
milestoneId2 = store.createMilestone(roadmapId, { title: "M2" }).id;
store.createFeature(milestoneId1, { title: "F1" });
store.createFeature(milestoneId1, { title: "F2" });
store.createFeature(milestoneId2, { title: "F3" });
});
it("gets milestone with features", () => {
const result = store.getMilestoneWithFeatures(milestoneId1);
expect(result).toBeDefined();
expect(result!.id).toBe(milestoneId1);
expect(result!.features.length).toBe(2);
expect(result!.features[0].title).toBe("F1");
expect(result!.features[1].title).toBe("F2");
});
it("returns undefined for non-existent milestone in getMilestoneWithFeatures", () => {
const result = store.getMilestoneWithFeatures("RMS-nonexistent");
expect(result).toBeUndefined();
});
it("gets roadmap with full hierarchy", () => {
const result = store.getRoadmapWithHierarchy(roadmapId);
expect(result).toBeDefined();
expect(result!.id).toBe(roadmapId);
expect(result!.title).toBe("Test Roadmap");
expect(result!.milestones.length).toBe(2);
// Milestones should be in order
expect(result!.milestones[0].id).toBe(milestoneId1);
expect(result!.milestones[1].id).toBe(milestoneId2);
// Features should be in order
expect(result!.milestones[0].features.length).toBe(2);
expect(result!.milestones[1].features.length).toBe(1);
});
it("returns undefined for non-existent roadmap in getRoadmapWithHierarchy", () => {
const result = store.getRoadmapWithHierarchy("RM-nonexistent");
expect(result).toBeUndefined();
});
});
describe("deterministic ordering", () => {
it("orders by orderIndex, createdAt, id when orderIndex values are equal", () => {
const roadmapId = store.createRoadmap({ title: "Test" }).id;
const milestoneId = store.createMilestone(roadmapId, { title: "M1" }).id;
// Create features rapidly (same millisecond timestamps possible)
const f1 = store.createFeature(milestoneId, { title: "Alpha" });
const f2 = store.createFeature(milestoneId, { title: "Beta" });
const f3 = store.createFeature(milestoneId, { title: "Gamma" });
// Verify deterministic ordering
const features = store.listFeatures(milestoneId);
expect(features.map((f) => f.id)).toEqual([f1.id, f2.id, f3.id]);
});
it("handles gap in orderIndex values", () => {
const roadmapId = store.createRoadmap({ title: "Test" }).id;
const milestoneId = store.createMilestone(roadmapId, { title: "M" }).id;
// Manually create gaps
db.prepare("UPDATE roadmap_milestones SET orderIndex = 10 WHERE id = ?").run(milestoneId);
db.prepare("UPDATE roadmap_milestones SET orderIndex = 20 WHERE id = ?").run(
store.createMilestone(roadmapId, { title: "Second" }).id
);
const milestones = store.listMilestones(roadmapId);
expect(milestones[0].orderIndex).toBe(10);
expect(milestones[1].orderIndex).toBe(20);
});
});
describe("schema version", () => {
it("schema version is 32 after init", () => {
expect(db.getSchemaVersion()).toBe(32);
});
});
});

View File

@@ -0,0 +1,734 @@
/**
* RoadmapStore - Data layer for standalone roadmap persistence.
*
* Manages CRUD operations for roadmaps, milestones, and features.
* Provides deterministic ordering via covering indexes and atomic reorder/move operations.
*
* Ordering invariants:
* - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based
* - feature ordering is scoped to a single milestone and must be contiguous + 0-based
* - all list/read queries use deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
* - cross-milestone feature moves atomically renumber both affected milestone scopes
*/
import { EventEmitter } from "node:events";
import type { Database } from "./db.js";
import { toJsonNullable } from "./db.js";
import type {
Roadmap,
RoadmapMilestone,
RoadmapFeature,
RoadmapCreateInput,
RoadmapUpdateInput,
RoadmapMilestoneCreateInput,
RoadmapMilestoneUpdateInput,
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapMilestoneReorderInput,
RoadmapFeatureReorderInput,
RoadmapFeatureMoveInput,
RoadmapMilestoneWithFeatures,
RoadmapWithHierarchy,
} from "./roadmap-types.js";
import {
normalizeRoadmapMilestoneOrder,
applyRoadmapMilestoneReorder,
normalizeRoadmapFeatureOrder,
applyRoadmapFeatureReorder,
moveRoadmapFeature,
} from "./roadmap-ordering.js";
// ── Event Types ─────────────────────────────────────────────────────
export interface RoadmapStoreEvents {
/** Emitted when a roadmap is created */
"roadmap:created": [Roadmap];
/** Emitted when a roadmap is updated */
"roadmap:updated": [Roadmap];
/** Emitted when a roadmap is deleted */
"roadmap:deleted": [string];
/** Emitted when a milestone is created */
"milestone:created": [RoadmapMilestone];
/** Emitted when a milestone is updated */
"milestone:updated": [RoadmapMilestone];
/** Emitted when a milestone is deleted */
"milestone:deleted": [string];
/** Emitted when a milestone is reordered */
"milestone:reordered": [{ roadmapId: string; milestones: RoadmapMilestone[] }];
/** Emitted when a feature is created */
"feature:created": [RoadmapFeature];
/** Emitted when a feature is updated */
"feature:updated": [RoadmapFeature];
/** Emitted when a feature is deleted */
"feature:deleted": [RoadmapFeature];
/** Emitted when features are reordered within a milestone */
"feature:reordered": [{ milestoneId: string; features: RoadmapFeature[] }];
/** Emitted when a feature is moved (including cross-milestone moves) */
"feature:moved": [{ feature: RoadmapFeature; fromMilestoneId: string; toMilestoneId: string }];
}
// ── RoadmapStore Class ──────────────────────────────────────────────
export class RoadmapStore extends EventEmitter<RoadmapStoreEvents> {
/**
* Creates a new RoadmapStore instance.
*
* @param db - Shared Database instance (same instance used by TaskStore)
*/
constructor(private db: Database) {
super();
this.setMaxListeners(50);
}
// ── ID Generators ───────────────────────────────────────────────────
private generateRoadmapId(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `RM-${timestamp.toString(36).toUpperCase()}-${random}`;
}
private generateMilestoneId(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `RMS-${timestamp.toString(36).toUpperCase()}-${random}`;
}
private generateFeatureId(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `RF-${timestamp.toString(36).toUpperCase()}-${random}`;
}
// ── Row-to-Object Converters ───────────────────────────────────────
private rowToRoadmap(row: any): Roadmap {
return {
id: row.id,
title: row.title,
description: row.description || undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private rowToMilestone(row: any): RoadmapMilestone {
return {
id: row.id,
roadmapId: row.roadmapId,
title: row.title,
description: row.description || undefined,
orderIndex: row.orderIndex,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private rowToFeature(row: any): RoadmapFeature {
return {
id: row.id,
milestoneId: row.milestoneId,
title: row.title,
description: row.description || undefined,
orderIndex: row.orderIndex,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
// ── Roadmap CRUD ─────────────────────────────────────────────────
/**
* Create a new roadmap.
*
* @param input - Roadmap creation input
* @returns The created roadmap
*/
createRoadmap(input: RoadmapCreateInput): Roadmap {
const now = new Date().toISOString();
const id = this.generateRoadmapId();
const roadmap: Roadmap = {
id,
title: input.title,
description: input.description,
createdAt: now,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO roadmaps (id, title, description, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?)
`).run(
roadmap.id,
roadmap.title,
roadmap.description ?? null,
roadmap.createdAt,
roadmap.updatedAt,
);
this.db.bumpLastModified();
this.emit("roadmap:created", roadmap);
return roadmap;
}
/**
* Get a roadmap by ID.
*
* @param id - Roadmap ID
* @returns The roadmap, or undefined if not found
*/
getRoadmap(id: string): Roadmap | undefined {
const row = this.db.prepare("SELECT * FROM roadmaps WHERE id = ?").get(id);
if (!row) return undefined;
return this.rowToRoadmap(row);
}
/**
* List all roadmaps, ordered by creation date (newest first).
*
* @returns Array of roadmaps
*/
listRoadmaps(): Roadmap[] {
const rows = this.db.prepare(
"SELECT * FROM roadmaps ORDER BY createdAt DESC"
).all();
return (rows as any[]).map((row) => this.rowToRoadmap(row));
}
/**
* Update a roadmap.
*
* @param id - Roadmap ID
* @param updates - Partial roadmap updates
* @returns The updated roadmap
* @throws Error if roadmap not found
*/
updateRoadmap(id: string, updates: RoadmapUpdateInput): Roadmap {
const roadmap = this.getRoadmap(id);
if (!roadmap) {
throw new Error(`Roadmap ${id} not found`);
}
const updated: Roadmap = {
...roadmap,
...updates,
id, // Prevent changing ID
createdAt: roadmap.createdAt, // Prevent changing creation time
updatedAt: new Date().toISOString(),
};
this.db.prepare(`
UPDATE roadmaps SET
title = ?,
description = ?,
updatedAt = ?
WHERE id = ?
`).run(
updated.title,
updated.description ?? null,
updated.updatedAt,
updated.id,
);
this.db.bumpLastModified();
this.emit("roadmap:updated", updated);
return updated;
}
/**
* Delete a roadmap and all its milestones/features (cascading).
*
* @param id - Roadmap ID
* @throws Error if roadmap not found
*/
deleteRoadmap(id: string): void {
const roadmap = this.getRoadmap(id);
if (!roadmap) {
throw new Error(`Roadmap ${id} not found`);
}
// SQLite FK cascade will handle milestones and features
this.db.prepare("DELETE FROM roadmaps WHERE id = ?").run(id);
this.db.bumpLastModified();
this.emit("roadmap:deleted", id);
}
// ── Milestone CRUD ────────────────────────────────────────────────
/**
* Add a milestone to a roadmap.
* Automatically computes the orderIndex (max + 1).
*
* @param roadmapId - Parent roadmap ID
* @param input - Milestone creation input
* @returns The created milestone
* @throws Error if roadmap not found
*/
createMilestone(roadmapId: string, input: RoadmapMilestoneCreateInput): RoadmapMilestone {
const roadmap = this.getRoadmap(roadmapId);
if (!roadmap) {
throw new Error(`Roadmap ${roadmapId} not found`);
}
const now = new Date().toISOString();
const id = this.generateMilestoneId();
// Compute next orderIndex
const existingMilestones = this.listMilestones(roadmapId);
const orderIndex = existingMilestones.length > 0
? Math.max(...existingMilestones.map((m) => m.orderIndex)) + 1
: 0;
const milestone: RoadmapMilestone = {
id,
roadmapId,
title: input.title,
description: input.description,
orderIndex,
createdAt: now,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO roadmap_milestones (id, roadmapId, title, description, orderIndex, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
milestone.id,
milestone.roadmapId,
milestone.title,
milestone.description ?? null,
milestone.orderIndex,
milestone.createdAt,
milestone.updatedAt,
);
this.db.bumpLastModified();
this.emit("milestone:created", milestone);
return milestone;
}
/**
* Get a milestone by ID.
*
* @param id - Milestone ID
* @returns The milestone, or undefined if not found
*/
getMilestone(id: string): RoadmapMilestone | undefined {
const row = this.db.prepare("SELECT * FROM roadmap_milestones WHERE id = ?").get(id);
if (!row) return undefined;
return this.rowToMilestone(row);
}
/**
* List milestones for a roadmap, ordered deterministically.
*
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
* to ensure consistent results when stored order data is incomplete or conflicting.
*
* @param roadmapId - Roadmap ID
* @returns Array of milestones in deterministic order
*/
listMilestones(roadmapId: string): RoadmapMilestone[] {
const rows = this.db.prepare(
"SELECT * FROM roadmap_milestones WHERE roadmapId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC"
).all(roadmapId);
return (rows as any[]).map((row) => this.rowToMilestone(row));
}
/**
* Update a milestone.
*
* @param id - Milestone ID
* @param updates - Partial milestone updates
* @returns The updated milestone
* @throws Error if milestone not found
*/
updateMilestone(id: string, updates: RoadmapMilestoneUpdateInput): RoadmapMilestone {
const milestone = this.getMilestone(id);
if (!milestone) {
throw new Error(`Milestone ${id} not found`);
}
const updated: RoadmapMilestone = {
...milestone,
...updates,
id, // Prevent changing ID
roadmapId: milestone.roadmapId, // Prevent moving to different roadmap
createdAt: milestone.createdAt, // Prevent changing creation time
updatedAt: new Date().toISOString(),
};
this.db.prepare(`
UPDATE roadmap_milestones SET
title = ?,
description = ?,
updatedAt = ?
WHERE id = ?
`).run(
updated.title,
updated.description ?? null,
updated.updatedAt,
updated.id,
);
this.db.bumpLastModified();
this.emit("milestone:updated", updated);
return updated;
}
/**
* Delete a milestone and all its features (cascading).
*
* @param id - Milestone ID
* @throws Error if milestone not found
*/
deleteMilestone(id: string): void {
const milestone = this.getMilestone(id);
if (!milestone) {
throw new Error(`Milestone ${id} not found`);
}
const roadmapId = milestone.roadmapId;
// SQLite FK cascade will handle features
this.db.prepare("DELETE FROM roadmap_milestones WHERE id = ?").run(id);
this.db.bumpLastModified();
this.emit("milestone:deleted", id);
}
// ── Feature CRUD ─────────────────────────────────────────────────
/**
* Add a feature to a milestone.
* Automatically computes the orderIndex (max + 1).
*
* @param milestoneId - Parent milestone ID
* @param input - Feature creation input
* @returns The created feature
* @throws Error if milestone not found
*/
createFeature(milestoneId: string, input: RoadmapFeatureCreateInput): RoadmapFeature {
const milestone = this.getMilestone(milestoneId);
if (!milestone) {
throw new Error(`Milestone ${milestoneId} not found`);
}
const now = new Date().toISOString();
const id = this.generateFeatureId();
// Compute next orderIndex
const existingFeatures = this.listFeatures(milestoneId);
const orderIndex = existingFeatures.length > 0
? Math.max(...existingFeatures.map((f) => f.orderIndex)) + 1
: 0;
const feature: RoadmapFeature = {
id,
milestoneId,
title: input.title,
description: input.description,
orderIndex,
createdAt: now,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO roadmap_features (id, milestoneId, title, description, orderIndex, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
feature.id,
feature.milestoneId,
feature.title,
feature.description ?? null,
feature.orderIndex,
feature.createdAt,
feature.updatedAt,
);
this.db.bumpLastModified();
this.emit("feature:created", feature);
return feature;
}
/**
* Get a feature by ID.
*
* @param id - Feature ID
* @returns The feature, or undefined if not found
*/
getFeature(id: string): RoadmapFeature | undefined {
const row = this.db.prepare("SELECT * FROM roadmap_features WHERE id = ?").get(id);
if (!row) return undefined;
return this.rowToFeature(row);
}
/**
* List features for a milestone, ordered deterministically.
*
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
* to ensure consistent results when stored order data is incomplete or conflicting.
*
* @param milestoneId - Milestone ID
* @returns Array of features in deterministic order
*/
listFeatures(milestoneId: string): RoadmapFeature[] {
const rows = this.db.prepare(
"SELECT * FROM roadmap_features WHERE milestoneId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC"
).all(milestoneId);
return (rows as any[]).map((row) => this.rowToFeature(row));
}
/**
* Update a feature.
*
* @param id - Feature ID
* @param updates - Partial feature updates
* @returns The updated feature
* @throws Error if feature not found
*/
updateFeature(id: string, updates: RoadmapFeatureUpdateInput): RoadmapFeature {
const feature = this.getFeature(id);
if (!feature) {
throw new Error(`Feature ${id} not found`);
}
const updated: RoadmapFeature = {
...feature,
...updates,
id, // Prevent changing ID
milestoneId: feature.milestoneId, // Prevent moving via update (use moveFeature instead)
createdAt: feature.createdAt, // Prevent changing creation time
updatedAt: new Date().toISOString(),
};
this.db.prepare(`
UPDATE roadmap_features SET
title = ?,
description = ?,
updatedAt = ?
WHERE id = ?
`).run(
updated.title,
updated.description ?? null,
updated.updatedAt,
updated.id,
);
this.db.bumpLastModified();
this.emit("feature:updated", updated);
return updated;
}
/**
* Delete a feature.
*
* @param id - Feature ID
* @throws Error if feature not found
*/
deleteFeature(id: string): void {
const feature = this.getFeature(id);
if (!feature) {
throw new Error(`Feature ${id} not found`);
}
this.db.prepare("DELETE FROM roadmap_features WHERE id = ?").run(id);
this.db.bumpLastModified();
this.emit("feature:deleted", feature);
}
// ── Reorder Operations ────────────────────────────────────────────
/**
* Reorder milestones within a roadmap.
*
* Applies an explicit reorder input and persists the full normalized order.
* The input must contain all milestone IDs exactly once.
*
* @param input - Reorder input with complete milestone ID list
* @returns The reordered milestones in their new order
* @throws Error if milestone set is incomplete, duplicate, or not found
*/
reorderMilestones(input: RoadmapMilestoneReorderInput): RoadmapMilestone[] {
// Validate roadmap exists
const roadmap = this.getRoadmap(input.roadmapId);
if (!roadmap) {
throw new Error(`Roadmap ${input.roadmapId} not found`);
}
// Load current milestones with deterministic ordering
const milestones = this.listMilestones(input.roadmapId);
// Apply the reorder using the pure ordering helper
const reordered = applyRoadmapMilestoneReorder(milestones, input);
// Persist in a transaction
this.db.transaction(() => {
for (const milestone of reordered) {
this.db.prepare(`
UPDATE roadmap_milestones SET orderIndex = ?, updatedAt = ? WHERE id = ?
`).run(milestone.orderIndex, new Date().toISOString(), milestone.id);
}
});
this.db.bumpLastModified();
this.emit("milestone:reordered", { roadmapId: input.roadmapId, milestones: reordered });
return reordered;
}
/**
* Reorder features within a milestone.
*
* Applies an explicit reorder input and persists the full normalized order.
* The input must contain all feature IDs for the milestone exactly once.
*
* @param input - Reorder input with complete feature ID list
* @returns The reordered features in their new order
* @throws Error if feature set is incomplete, duplicate, or not found
*/
reorderFeatures(input: RoadmapFeatureReorderInput): RoadmapFeature[] {
// Validate milestone exists and belongs to the roadmap
const milestone = this.getMilestone(input.milestoneId);
if (!milestone) {
throw new Error(`Milestone ${input.milestoneId} not found`);
}
if (milestone.roadmapId !== input.roadmapId) {
throw new Error(`Milestone ${input.milestoneId} does not belong to roadmap ${input.roadmapId}`);
}
// Load current features with deterministic ordering
const features = this.listFeatures(input.milestoneId);
// Apply the reorder using the pure ordering helper
const reordered = applyRoadmapFeatureReorder(features, input);
// Persist in a transaction
this.db.transaction(() => {
for (const feature of reordered) {
this.db.prepare(`
UPDATE roadmap_features SET orderIndex = ?, updatedAt = ? WHERE id = ?
`).run(feature.orderIndex, new Date().toISOString(), feature.id);
}
});
this.db.bumpLastModified();
this.emit("feature:reordered", { milestoneId: input.milestoneId, features: reordered });
return reordered;
}
/**
* Move a feature, including cross-milestone moves.
*
* Atomically renumbers both the source and destination milestone scopes.
*
* @param input - Move input with source/destination milestone info
* @returns The moved feature and both affected milestone feature lists
* @throws Error if feature or milestone not found, or scope validation fails
*/
moveFeature(input: RoadmapFeatureMoveInput): {
movedFeature: RoadmapFeature;
sourceMilestoneFeatures: RoadmapFeature[];
targetMilestoneFeatures: RoadmapFeature[];
} {
// Validate roadmap exists
const roadmap = this.getRoadmap(input.roadmapId);
if (!roadmap) {
throw new Error(`Roadmap ${input.roadmapId} not found`);
}
// Validate both milestones exist and belong to the roadmap
const fromMilestone = this.getMilestone(input.fromMilestoneId);
const toMilestone = this.getMilestone(input.toMilestoneId);
if (!fromMilestone) {
throw new Error(`Source milestone ${input.fromMilestoneId} not found`);
}
if (!toMilestone) {
throw new Error(`Destination milestone ${input.toMilestoneId} not found`);
}
if (fromMilestone.roadmapId !== input.roadmapId) {
throw new Error(`Source milestone ${input.fromMilestoneId} does not belong to roadmap ${input.roadmapId}`);
}
if (toMilestone.roadmapId !== input.roadmapId) {
throw new Error(`Destination milestone ${input.toMilestoneId} does not belong to roadmap ${input.roadmapId}`);
}
// Load features from both milestones with deterministic ordering
const sourceFeatures = this.listFeatures(input.fromMilestoneId);
const targetFeatures = this.listFeatures(input.toMilestoneId);
// For same-milestone moves, pass only one list to avoid duplication
// For cross-milestone moves, pass the combined list
const allFeatures = input.fromMilestoneId === input.toMilestoneId
? sourceFeatures
: [...sourceFeatures, ...targetFeatures];
// Apply the move using the pure ordering helper
const result = moveRoadmapFeature(allFeatures, input);
// Persist in a transaction
this.db.transaction(() => {
// Update all affected features
for (const feature of result.affectedFeatures) {
this.db.prepare(`
UPDATE roadmap_features SET milestoneId = ?, orderIndex = ?, updatedAt = ? WHERE id = ?
`).run(feature.milestoneId, feature.orderIndex, new Date().toISOString(), feature.id);
}
});
this.db.bumpLastModified();
this.emit("feature:moved", {
feature: result.movedFeature,
fromMilestoneId: input.fromMilestoneId,
toMilestoneId: input.toMilestoneId,
});
return {
movedFeature: result.movedFeature,
sourceMilestoneFeatures: result.sourceMilestoneFeatures,
targetMilestoneFeatures: result.targetMilestoneFeatures,
};
}
// ── Hierarchy Operations ───────────────────────────────────────────
/**
* Get a milestone with all of its features in deterministic order.
*
* @param id - Milestone ID
* @returns The milestone with features, or undefined if not found
*/
getMilestoneWithFeatures(id: string): RoadmapMilestoneWithFeatures | undefined {
const milestone = this.getMilestone(id);
if (!milestone) return undefined;
return {
...milestone,
features: this.listFeatures(id),
};
}
/**
* Get a roadmap with its full hierarchy (milestones → features).
*
* @param id - Roadmap ID
* @returns The roadmap with hierarchy, or undefined if not found
*/
getRoadmapWithHierarchy(id: string): RoadmapWithHierarchy | undefined {
const roadmap = this.getRoadmap(id);
if (!roadmap) return undefined;
return {
...roadmap,
milestones: this.listMilestones(id).map((milestone) => ({
...milestone,
features: this.listFeatures(milestone.id),
})),
};
}
}

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 28", () => {
expect(db.getSchemaVersion()).toBe(31);
expect(db.getSchemaVersion()).toBe(32);
});
});
});

View File

@@ -10,6 +10,7 @@ import { Database, toJson, toJsonNullable, fromJson } from "./db.js";
import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js";
import { MissionStore } from "./mission-store.js";
import { PluginStore } from "./plugin-store.js";
import { RoadmapStore } from "./roadmap-store.js";
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
import { CentralCore } from "./central-core.js";
import { getTaskMergeBlocker } from "./task-merge.js";
@@ -130,6 +131,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private missionStore: MissionStore | null = null;
/** Cached PluginStore instance */
private pluginStore: PluginStore | null = null;
/** Cached RoadmapStore instance */
private roadmapStore: RoadmapStore | null = null;
constructor(private rootDir: string, globalSettingsDir?: string) {
super();
@@ -4529,6 +4532,17 @@ ${notificationsSection}`;
return this.pluginStore;
}
/**
* Get the RoadmapStore instance for standalone roadmap operations.
* Lazily initializes the RoadmapStore on first access.
*/
getRoadmapStore(): RoadmapStore {
if (!this.roadmapStore) {
this.roadmapStore = new RoadmapStore(this.db);
}
return this.roadmapStore;
}
// ── Backward Compatibility (Multi-Project Support) ────────────────────────
}