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:
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) ───────────────────────────
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
745
packages/core/src/roadmap-store.test.ts
Normal file
745
packages/core/src/roadmap-store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
734
packages/core/src/roadmap-store.ts
Normal file
734
packages/core/src/roadmap-store.ts
Normal 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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 28", () => {
|
||||
expect(db.getSchemaVersion()).toBe(31);
|
||||
expect(db.getSchemaVersion()).toBe(32);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) ────────────────────────
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user