feat(FN-1690): implement RoadmapStore with CRUD and deterministic ordering

This commit is contained in:
gsxdsm
2026-04-14 09:39:53 -07:00
parent 34b82490b4
commit 356c6808da
2 changed files with 1477 additions and 0 deletions

View File

@@ -0,0 +1,743 @@
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,
RoadmapFeatureCreateInput,
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),
})),
};
}
}