feat(FN-3158): migrate roadmap domain modules to fusion-plugin-roadmap pack

The merge extracts the roadmap domain into a standalone plugin package (`plugins/fusion-plugin-roadmap`), wiring it into the CLI bundle and dashboard scaffold, with new modules for roadmap types, store, ordering, and handoff logic. A secondary change normalizes shell host bootstrap, introducing `She

Fusion-Task-Id: FN-3158
This commit is contained in:
Fusion
2026-05-07 23:17:56 -07:00
committed by gsxdsm
parent a6d019d70a
commit ae2baa09ee
18 changed files with 1936 additions and 35 deletions

View File

@@ -1,32 +1,27 @@
# fusion-plugin-roadmap
`@fusion-plugin-examples/roadmap` is the workspace package that owns the roadmap plugin boundary used by the roadmap migration.
`@fusion-plugin-examples/roadmap` is the workspace package for the bundled `fusion-plugin-roadmap` plugin.
## Plugin identity
- Manifest id: `fusion-plugin-roadmap`
- Package default export: `definePlugin(...)` manifest object
- Route namespace: `/api/plugins/fusion-plugin-roadmap/*`
- Dashboard view id: `plugin:fusion-plugin-roadmap:roadmaps`
## Exported roadmap domain surface
## Package layout
The package root exports (re-exported from `@fusion/core`):
- `manifest.json` — plugin metadata and dashboard view declaration
- `src/index.ts` — plugin definition (`onSchemaInit`, routes, dashboard view metadata)
- `src/server/index.ts` — backend server exports
- `src/dashboard-view.tsx` — dashboard view entry export
- `src/roadmap-types.ts` + `src/store/*` — roadmap domain ownership target (migrated in follow-up steps)
- Roadmap domain types
- Ordering helpers
- `normalizeRoadmapMilestoneOrder`
- `applyRoadmapMilestoneReorder`
- `normalizeRoadmapFeatureOrder`
- `applyRoadmapFeatureReorder`
- `moveRoadmapFeature`
- Handoff mappers
- `mapFeatureToTaskHandoff`
- `mapRoadmapToMissionHandoff`
- `mapRoadmapWithHierarchyToMissionHandoff`
- `mapAllFeaturesToTaskHandoffs`
- Store exports
- `RoadmapStore`
- `RoadmapStoreEvents`
## Exported surfaces
## Compatibility boundary
- Root export: plugin default + roadmap domain helpers/types
- `./server`: roadmap route + AI suggestion service exports
- `./dashboard-view`: Roadmaps dashboard view export for host registry wiring
This task only backfills the package and export surface expected by migration work. Existing `@fusion/core` and dashboard roadmap consumers intentionally remain in place for now; consumer switchover is deferred to later roadmap-plugin migration tasks.
## Notes
The plugin keeps a single canonical ID/path (`fusion-plugin-roadmap`). Do not introduce alternate route namespaces or plugin IDs for this feature.

View File

@@ -0,0 +1,16 @@
{
"id": "fusion-plugin-roadmap",
"name": "Roadmaps",
"version": "0.1.0",
"description": "Standalone roadmap planning plugin",
"dashboardViews": [
{
"viewId": "roadmaps",
"label": "Roadmaps",
"componentPath": "./dashboard-view",
"icon": "Map",
"placement": "primary",
"order": 30
}
]
}

View File

@@ -2,12 +2,20 @@
"name": "@fusion-plugin-examples/roadmap",
"version": "0.1.0",
"type": "module",
"description": "Roadmap backend plugin for Fusion",
"description": "Roadmap plugin package for Fusion",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./server": {
"types": "./src/server/index.ts",
"import": "./src/server/index.ts"
},
"./dashboard-view": {
"types": "./src/dashboard-view.ts",
"import": "./src/dashboard-view.ts"
}
},
"scripts": {
@@ -16,6 +24,7 @@
},
"dependencies": {
"@fusion/core": "workspace:*",
"@fusion/dashboard": "workspace:*",
"@fusion/plugin-sdk": "workspace:*",
"express": "^5.1.0"
},

View File

@@ -0,0 +1,3 @@
export function RoadmapsView() {
return null;
}

View File

@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { RoadmapStore as CoreRoadmapStore } from "@fusion/core";
import plugin, {
RoadmapStore,
applyRoadmapFeatureReorder,
@@ -14,12 +15,33 @@ import plugin, {
} from "../index.js";
describe("fusion-plugin-roadmap package surface", () => {
it("keeps manifest and plugin entry metadata aligned", () => {
const manifest = JSON.parse(readFileSync(resolve(process.cwd(), "manifest.json"), "utf8")) as {
id: string;
version: string;
dashboardViews?: Array<{ viewId: string }>;
};
expect(plugin.manifest.id).toBe(manifest.id);
expect(plugin.manifest.version).toBe(manifest.version);
expect(plugin.dashboardViews?.[0]?.viewId).toBe(manifest.dashboardViews?.[0]?.viewId);
});
it("declares expected package exports", () => {
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf8")) as {
exports: Record<string, unknown>;
};
expect(pkg.exports).toHaveProperty(".");
expect(pkg.exports).toHaveProperty("./server");
expect(pkg.exports).toHaveProperty("./dashboard-view");
});
it("exports plugin manifest with roadmap id", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-roadmap");
});
it("re-exports roadmap domain symbols", () => {
expect(RoadmapStore).toBe(CoreRoadmapStore);
expect(typeof normalizeRoadmapMilestoneOrder).toBe("function");
expect(typeof applyRoadmapMilestoneReorder).toBe("function");
expect(typeof normalizeRoadmapFeatureOrder).toBe("function");

View File

@@ -0,0 +1,3 @@
import { RoadmapsView } from "./RoadmapsViewBridge.js";
export { RoadmapsView as RoadmapDashboardView };

View File

@@ -1,16 +1,69 @@
import type { Database } from "@fusion/core";
import { definePlugin } from "@fusion/plugin-sdk";
import { createRoadmapPluginRoutes } from "./roadmap-routes.js";
export function ensureRoadmapSchema(db: Database): void {
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
);
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
);
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
);
CREATE INDEX IF NOT EXISTS idxRoadmapMilestonesRoadmapOrder
ON roadmap_milestones(roadmapId, orderIndex, createdAt, id);
CREATE INDEX IF NOT EXISTS idxRoadmapFeaturesMilestoneOrder
ON roadmap_features(milestoneId, orderIndex, createdAt, id);
`);
}
const plugin = definePlugin({
manifest: {
id: "fusion-plugin-roadmap",
name: "Roadmap",
name: "Roadmaps",
version: "0.1.0",
description: "Roadmap domain package for plugin-owned roadmap migration",
description: "Standalone roadmap planning plugin",
},
state: "installed",
hooks: {},
hooks: {
onSchemaInit: ensureRoadmapSchema,
},
routes: createRoadmapPluginRoutes(),
dashboardViews: [
{
viewId: "roadmaps",
label: "Roadmaps",
componentPath: "./dashboard-view",
icon: "Map",
placement: "primary",
order: 30,
},
],
});
export default plugin;
@@ -36,8 +89,7 @@ export type {
RoadmapFeatureTaskPlanningHandoff,
RoadmapMissionPlanningMilestoneHandoff,
RoadmapMissionPlanningHandoff,
RoadmapStoreEvents,
} from "@fusion/core";
} from "./roadmap-types.js";
export {
normalizeRoadmapMilestoneOrder,
@@ -45,9 +97,17 @@ export {
normalizeRoadmapFeatureOrder,
applyRoadmapFeatureReorder,
moveRoadmapFeature,
} from "./store/roadmap-ordering.js";
export {
mapFeatureToTaskHandoff,
mapRoadmapToMissionHandoff,
mapRoadmapWithHierarchyToMissionHandoff,
mapAllFeaturesToTaskHandoffs,
RoadmapStore,
} from "@fusion/core";
} from "./store/roadmap-handoff.js";
export { RoadmapStore } from "./store/roadmap-store.js";
export type { RoadmapStoreEvents } from "./store/roadmap-store.js";
export { RoadmapDashboardView } from "./dashboard-view.js";
export * from "./server/index.js";

View File

@@ -0,0 +1,310 @@
/**
* Standalone roadmap planning types.
*
* This model is intentionally separate from the mission hierarchy so roadmap
* work can evolve independently of `MissionStore`/`MissionManager`.
*
* Core 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
* - cross-milestone feature moves must renumber both the source and target
* milestone deterministically after the move
* - whenever stored order data is incomplete or conflicting, consumers should
* repair it using a stable tie-breaker (`createdAt`, then `id`, both ASC)
*
* These contracts are persistence-agnostic and UI-agnostic. They define the
* canonical domain surface that downstream storage, API, and dashboard work use.
*
* @module roadmap-types
*/
/**
* A standalone roadmap container.
*
* Roadmaps do not reuse mission lifecycle or mission status concepts. They are
* lightweight planning artifacts that own ordered milestones.
*/
export interface Roadmap {
/** Unique identifier (for example `RM-01HXYZ...`) */
id: string;
/** Display title shown in roadmap lists and detail views */
title: string;
/** Optional long-form planning context for the roadmap */
description?: string;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
/**
* A milestone within a roadmap.
*
* `orderIndex` is the canonical persisted ordering field. It is always scoped to
* the parent roadmap and must remain contiguous + 0-based after reorder flows.
*/
export interface RoadmapMilestone {
/** Unique identifier (for example `RMS-01HXYZ...`) */
id: string;
/** Parent roadmap ID */
roadmapId: string;
/** Display title for the milestone */
title: string;
/** Optional description of the milestone's goals */
description?: string;
/** 0-based contiguous ordering within the roadmap */
orderIndex: number;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
/**
* A feature within a roadmap milestone.
*
* `orderIndex` is scoped to the parent milestone. Cross-milestone moves must
* update `milestoneId` and then normalize both affected milestone lists back to
* contiguous 0-based order.
*/
export interface RoadmapFeature {
/** Unique identifier (for example `RF-01HXYZ...`) */
id: string;
/** Parent milestone ID */
milestoneId: string;
/** Display title for the feature */
title: string;
/** Optional description of the feature's intent */
description?: string;
/** 0-based contiguous ordering within the parent milestone */
orderIndex: number;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
// ── CRUD Input Types ────────────────────────────────────────────────
/** Input for creating a roadmap. */
export interface RoadmapCreateInput {
/** Display title of the roadmap (required) */
title: string;
/** Optional roadmap description */
description?: string;
}
/** Input for updating roadmap metadata. Ordering is handled by dedicated move/reorder DTOs. */
export interface RoadmapUpdateInput {
/** Updated display title */
title?: string;
/** Updated roadmap description */
description?: string;
}
/** Input for creating a milestone inside a roadmap. */
export interface RoadmapMilestoneCreateInput {
/** Display title of the milestone (required) */
title: string;
/** Optional milestone description */
description?: string;
}
/** Input for updating milestone metadata. Ordering is handled separately. */
export interface RoadmapMilestoneUpdateInput {
/** Updated milestone title */
title?: string;
/** Updated milestone description */
description?: string;
}
/** Input for creating a feature inside a milestone. */
export interface RoadmapFeatureCreateInput {
/** Display title of the feature (required) */
title: string;
/** Optional feature description */
description?: string;
}
/** Input for updating feature metadata. Ordering is handled separately. */
export interface RoadmapFeatureUpdateInput {
/** Updated feature title */
title?: string;
/** Updated feature description */
description?: string;
}
// ── Ordering / Move Payload Types ───────────────────────────────────
/**
* Explicit reorder payload for milestones within a roadmap.
*
* `orderedMilestoneIds` must contain the full set of milestone IDs for the
* roadmap exactly once. Consumers should reject partial or duplicate lists.
*/
export interface RoadmapMilestoneReorderInput {
/** Roadmap whose milestone sequence is being rewritten */
roadmapId: string;
/** Complete milestone ID sequence in final order */
orderedMilestoneIds: string[];
}
/**
* Explicit reorder payload for features within a single milestone.
*
* `orderedFeatureIds` must contain the full set of feature IDs for the milestone
* exactly once. The resulting `orderIndex` values must be normalized to 0-based
* contiguous order.
*/
export interface RoadmapFeatureReorderInput {
/** Parent roadmap for integrity validation */
roadmapId: string;
/** Milestone whose internal feature ordering is being rewritten */
milestoneId: string;
/** Complete feature ID sequence in final order */
orderedFeatureIds: string[];
}
/**
* Explicit move payload for relocating a feature, including cross-milestone moves.
*
* `targetOrderIndex` is the desired insertion position in the destination
* milestone before final normalization. Consumers should clamp out-of-range
* values and must deterministically renumber both source and destination
* milestones after the move.
*/
export interface RoadmapFeatureMoveInput {
/** Parent roadmap for integrity validation */
roadmapId: string;
/** Feature being moved */
featureId: string;
/** Current milestone that owns the feature */
fromMilestoneId: string;
/** Destination milestone after the move */
toMilestoneId: string;
/** Requested insertion index in the destination milestone */
targetOrderIndex: number;
}
/**
* Result of a feature move operation after deterministic renumbering.
*
* `affectedFeatures` contains the canonical post-move feature records for the
* source and target milestones. When a feature is moved within the same
* milestone, `sourceMilestoneFeatures` and `targetMilestoneFeatures` will be
* the same normalized list.
*/
export interface RoadmapFeatureMoveResult {
/** The moved feature after `milestoneId` and `orderIndex` updates */
movedFeature: RoadmapFeature;
/** Canonical post-move features for the affected milestone scope */
affectedFeatures: RoadmapFeature[];
/** Canonical feature list for the source milestone after the move */
sourceMilestoneFeatures: RoadmapFeature[];
/** Canonical feature list for the destination milestone after the move */
targetMilestoneFeatures: RoadmapFeature[];
}
// ── Composite Read Models ───────────────────────────────────────────
/** Milestone with all of its ordered features loaded. */
export interface RoadmapMilestoneWithFeatures extends RoadmapMilestone {
/** Features belonging to this milestone */
features: RoadmapFeature[];
}
/** Full roadmap hierarchy loaded in roadmap → milestone → feature order. */
export interface RoadmapWithHierarchy extends Roadmap {
/** Ordered milestones with ordered features */
milestones: RoadmapMilestoneWithFeatures[];
}
// ── Export / Handoff Contracts ──────────────────────────────────────
/**
* Flat export payload for persistence, APIs, import/export, and sync jobs.
*
* This shape intentionally keeps entities separate so downstream persistence
* layers can upsert by table/collection without first denormalizing a nested
* hierarchy.
*/
export interface RoadmapExportBundle {
/** Roadmap being exported */
roadmap: Roadmap;
/** Ordered milestones for the roadmap */
milestones: RoadmapMilestone[];
/** Ordered features for the roadmap's milestones */
features: RoadmapFeature[];
}
/**
* Source metadata carried forward when a roadmap feature is converted into a
* task-planning input or other downstream artifact.
*/
export interface RoadmapFeatureSourceRef {
/** Source roadmap ID */
roadmapId: string;
/** Source milestone ID */
milestoneId: string;
/** Source feature ID */
featureId: string;
/** Human-readable roadmap title for prompt context */
roadmapTitle: string;
/** Human-readable milestone title for prompt context */
milestoneTitle: string;
/** Canonical milestone order at handoff time */
milestoneOrderIndex: number;
/** Canonical feature order at handoff time */
featureOrderIndex: number;
}
/**
* Handoff payload for converting a single roadmap feature into task planning
* flows without coupling the task system to roadmap persistence details.
*/
export interface RoadmapFeatureTaskPlanningHandoff {
/** Source lineage and ordering context */
source: RoadmapFeatureSourceRef;
/** Title to seed the downstream task or planning prompt */
title: string;
/** Optional description to seed the downstream task or planning prompt */
description?: string;
}
/** Source-preserving milestone payload used for mission conversion handoffs. */
export interface RoadmapMissionPlanningMilestoneHandoff {
/** Source roadmap milestone ID */
sourceMilestoneId: string;
/** Canonical milestone title */
title: string;
/** Optional milestone description */
description?: string;
/** Canonical milestone ordering within the roadmap */
orderIndex: number;
/** Ordered roadmap features that belong to this milestone */
features: Array<{
/** Source roadmap feature ID */
sourceFeatureId: string;
/** Canonical feature title */
title: string;
/** Optional feature description */
description?: string;
/** Canonical feature ordering within the milestone */
orderIndex: number;
}>;
}
/**
* Handoff payload for converting a standalone roadmap into mission planning
* structures while preserving source IDs and deterministic order.
*/
export interface RoadmapMissionPlanningHandoff {
/** Source roadmap ID */
sourceRoadmapId: string;
/** Canonical roadmap title */
title: string;
/** Optional roadmap description */
description?: string;
/** Ordered milestone breakdown captured at handoff time */
milestones: RoadmapMissionPlanningMilestoneHandoff[];
}

View File

@@ -0,0 +1 @@
export { createRoadmapPluginRoutes } from "../roadmap-routes.js";

View File

@@ -0,0 +1,445 @@
/**
* Tests for roadmap handoff mapping helpers.
*/
import { describe, it, expect } from "vitest";
import {
mapFeatureToTaskHandoff,
mapRoadmapToMissionHandoff,
mapRoadmapWithHierarchyToMissionHandoff,
mapAllFeaturesToTaskHandoffs,
} from "../roadmap-handoff.js";
import { normalizeRoadmapMilestoneOrder } from "../roadmap-ordering.js";
import type {
Roadmap,
RoadmapMilestone,
RoadmapFeature,
RoadmapWithHierarchy,
RoadmapFeatureTaskPlanningHandoff,
RoadmapMissionPlanningHandoff,
} from "../../roadmap-types.js";
// ── Test Fixtures ─────────────────────────────────────────────────────────────
function createRoadmap(overrides: Partial<Roadmap> = {}): Roadmap {
return {
id: "RM-001",
title: "Test Roadmap",
description: "A test roadmap",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
...overrides,
};
}
function createMilestone(id: string, roadmapId: string, orderIndex: number, overrides: Partial<RoadmapMilestone> = {}): RoadmapMilestone {
return {
id,
roadmapId,
title: `Milestone ${id}`,
description: `Description for ${id}`,
orderIndex,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
...overrides,
};
}
function createFeature(id: string, milestoneId: string, orderIndex: number, overrides: Partial<RoadmapFeature> = {}): RoadmapFeature {
return {
id,
milestoneId,
title: `Feature ${id}`,
description: `Description for ${id}`,
orderIndex,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
...overrides,
};
}
// ── Tests for mapFeatureToTaskHandoff ─────────────────────────────────────────
describe("mapFeatureToTaskHandoff", () => {
it("maps a feature to a task planning handoff with all fields", () => {
const roadmap = createRoadmap();
const milestone = createMilestone("MS-001", "RM-001", 0);
const feature = createFeature("F-001", "MS-001", 0);
const handoff = mapFeatureToTaskHandoff(roadmap, milestone, feature);
expect(handoff.title).toBe("Feature F-001");
expect(handoff.description).toBe("Description for F-001");
expect(handoff.source.roadmapId).toBe("RM-001");
expect(handoff.source.milestoneId).toBe("MS-001");
expect(handoff.source.featureId).toBe("F-001");
expect(handoff.source.roadmapTitle).toBe("Test Roadmap");
expect(handoff.source.milestoneTitle).toBe("Milestone MS-001");
expect(handoff.source.milestoneOrderIndex).toBe(0);
expect(handoff.source.featureOrderIndex).toBe(0);
});
it("handles features without descriptions", () => {
const roadmap = createRoadmap();
const milestone = createMilestone("MS-001", "RM-001", 0);
const feature = createFeature("F-001", "MS-001", 0, { description: undefined });
const handoff = mapFeatureToTaskHandoff(roadmap, milestone, feature);
expect(handoff.title).toBe("Feature F-001");
expect(handoff.description).toBeUndefined();
});
it("preserves exact IDs from source entities", () => {
const roadmap = createRoadmap({ id: "RM-SPECIAL-123" });
const milestone = createMilestone("RMS-SPECIAL-456", "RM-SPECIAL-123", 5);
const feature = createFeature("RF-SPECIAL-789", "RMS-SPECIAL-456", 3);
const handoff = mapFeatureToTaskHandoff(roadmap, milestone, feature);
expect(handoff.source.roadmapId).toBe("RM-SPECIAL-123");
expect(handoff.source.milestoneId).toBe("RMS-SPECIAL-456");
expect(handoff.source.featureId).toBe("RF-SPECIAL-789");
});
});
// ── Tests for mapRoadmapToMissionHandoff ─────────────────────────────────────
describe("mapRoadmapToMissionHandoff", () => {
it("maps a roadmap with milestones and features to mission handoff", () => {
const roadmap = createRoadmap({ title: "Q1 Planning", description: "Quarterly goals" });
const milestones = [
createMilestone("MS-001", "RM-001", 0, { title: "Phase 1" }),
createMilestone("MS-002", "RM-001", 1, { title: "Phase 2" }),
];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-001", [
createFeature("F-001", "MS-001", 0, { title: "Auth Feature" }),
createFeature("F-002", "MS-001", 1, { title: "Dashboard Feature" }),
]],
["MS-002", [
createFeature("F-003", "MS-002", 0, { title: "Reporting Feature" }),
]],
]);
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
expect(handoff.sourceRoadmapId).toBe("RM-001");
expect(handoff.title).toBe("Q1 Planning");
expect(handoff.description).toBe("Quarterly goals");
expect(handoff.milestones).toHaveLength(2);
// Verify milestone ordering
expect(handoff.milestones[0].title).toBe("Phase 1");
expect(handoff.milestones[0].orderIndex).toBe(0);
expect(handoff.milestones[1].title).toBe("Phase 2");
expect(handoff.milestones[1].orderIndex).toBe(1);
// Verify feature ordering within milestones
expect(handoff.milestones[0].features).toHaveLength(2);
expect(handoff.milestones[0].features[0].title).toBe("Auth Feature");
expect(handoff.milestones[0].features[0].orderIndex).toBe(0);
expect(handoff.milestones[0].features[1].title).toBe("Dashboard Feature");
expect(handoff.milestones[0].features[1].orderIndex).toBe(1);
expect(handoff.milestones[1].features).toHaveLength(1);
expect(handoff.milestones[1].features[0].title).toBe("Reporting Feature");
});
it("handles empty milestones array", () => {
const roadmap = createRoadmap();
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
const handoff = mapRoadmapToMissionHandoff(roadmap, [], featuresByMilestoneId);
expect(handoff.sourceRoadmapId).toBe("RM-001");
expect(handoff.milestones).toHaveLength(0);
});
it("handles milestones with empty features", () => {
const roadmap = createRoadmap();
const milestones = [
createMilestone("MS-001", "RM-001", 0),
];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
expect(handoff.milestones).toHaveLength(1);
expect(handoff.milestones[0].features).toHaveLength(0);
});
it("normalizes deterministic ordering when order indices are out of sequence", () => {
const roadmap = createRoadmap();
// Simulate out-of-sequence order indices
const milestones = [
createMilestone("MS-001", "RM-001", 10), // Out of sequence
createMilestone("MS-002", "RM-001", 5), // Out of sequence
createMilestone("MS-003", "RM-001", 20), // Out of sequence
];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
// Should be normalized to 0, 1, 2
expect(handoff.milestones[0].orderIndex).toBe(0);
expect(handoff.milestones[1].orderIndex).toBe(1);
expect(handoff.milestones[2].orderIndex).toBe(2);
});
});
// ── Tests for mapRoadmapWithHierarchyToMissionHandoff ────────────────────────
describe("mapRoadmapWithHierarchyToMissionHandoff", () => {
it("maps RoadmapWithHierarchy to mission handoff", () => {
const roadmapWithHierarchy: RoadmapWithHierarchy = {
id: "RM-001",
title: "Hierarchy Roadmap",
description: "With full hierarchy",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
milestones: [
{
...createMilestone("MS-001", "RM-001", 0, { title: "Alpha Phase" }),
features: [
createFeature("F-001", "MS-001", 0, { title: "Alpha Feature 1" }),
createFeature("F-002", "MS-001", 1, { title: "Alpha Feature 2" }),
],
},
{
...createMilestone("MS-002", "RM-001", 1, { title: "Beta Phase" }),
features: [
createFeature("F-003", "MS-002", 0, { title: "Beta Feature" }),
],
},
],
};
const handoff = mapRoadmapWithHierarchyToMissionHandoff(roadmapWithHierarchy);
expect(handoff.sourceRoadmapId).toBe("RM-001");
expect(handoff.title).toBe("Hierarchy Roadmap");
expect(handoff.milestones).toHaveLength(2);
expect(handoff.milestones[0].title).toBe("Alpha Phase");
expect(handoff.milestones[0].features).toHaveLength(2);
expect(handoff.milestones[1].title).toBe("Beta Phase");
expect(handoff.milestones[1].features).toHaveLength(1);
});
it("handles empty milestone hierarchy", () => {
const roadmapWithHierarchy: RoadmapWithHierarchy = {
...createRoadmap(),
milestones: [],
};
const handoff = mapRoadmapWithHierarchyToMissionHandoff(roadmapWithHierarchy);
expect(handoff.milestones).toHaveLength(0);
});
});
// ── Tests for mapAllFeaturesToTaskHandoffs ────────────────────────────────────
describe("mapAllFeaturesToTaskHandoffs", () => {
it("flattens all features from a roadmap into individual handoffs", () => {
const roadmap = createRoadmap();
const milestones = [
createMilestone("MS-001", "RM-001", 0),
createMilestone("MS-002", "RM-001", 1),
];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-001", [
createFeature("F-001", "MS-001", 0),
createFeature("F-002", "MS-001", 1),
]],
["MS-002", [
createFeature("F-003", "MS-002", 0),
]],
]);
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
expect(handoffs).toHaveLength(3);
expect(handoffs[0].source.featureId).toBe("F-001");
expect(handoffs[0].source.milestoneOrderIndex).toBe(0);
expect(handoffs[0].source.featureOrderIndex).toBe(0);
expect(handoffs[1].source.featureId).toBe("F-002");
expect(handoffs[1].source.milestoneOrderIndex).toBe(0);
expect(handoffs[1].source.featureOrderIndex).toBe(1);
expect(handoffs[2].source.featureId).toBe("F-003");
expect(handoffs[2].source.milestoneOrderIndex).toBe(1);
expect(handoffs[2].source.featureOrderIndex).toBe(0);
});
it("returns empty array when no milestones exist", () => {
const roadmap = createRoadmap();
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, [], featuresByMilestoneId);
expect(handoffs).toHaveLength(0);
});
it("returns empty array when milestones have no features", () => {
const roadmap = createRoadmap();
const milestones = [createMilestone("MS-001", "RM-001", 0)];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
expect(handoffs).toHaveLength(0);
});
it("preserves feature titles and descriptions", () => {
const roadmap = createRoadmap();
const milestones = [createMilestone("MS-001", "RM-001", 0)];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-001", [
createFeature("F-001", "MS-001", 0, { title: "Core Feature", description: "Main functionality" }),
createFeature("F-002", "MS-001", 1, { title: "Secondary Feature", description: undefined }),
]],
]);
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
expect(handoffs[0].title).toBe("Core Feature");
expect(handoffs[0].description).toBe("Main functionality");
expect(handoffs[1].title).toBe("Secondary Feature");
expect(handoffs[1].description).toBeUndefined();
});
it("normalizes ordering when feature order indices are out of sequence", () => {
const roadmap = createRoadmap();
const milestones = [createMilestone("MS-001", "RM-001", 0)];
// Simulate out-of-sequence feature order indices
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-001", [
createFeature("F-001", "MS-001", 100),
createFeature("F-002", "MS-001", 50),
createFeature("F-003", "MS-001", 75),
]],
]);
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
expect(handoffs).toHaveLength(3);
// Should be normalized to 0, 1, 2
expect(handoffs[0].source.featureOrderIndex).toBe(0);
expect(handoffs[1].source.featureOrderIndex).toBe(1);
expect(handoffs[2].source.featureOrderIndex).toBe(2);
});
it("skips features from unknown milestone IDs", () => {
const roadmap = createRoadmap();
const milestones = [createMilestone("MS-001", "RM-001", 0)];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-001", [createFeature("F-001", "MS-001", 0)]],
// MS-999 is not in milestones, so its features should be ignored
["MS-999", [createFeature("F-999", "MS-999", 0)]],
]);
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
expect(handoffs).toHaveLength(1);
expect(handoffs[0].source.featureId).toBe("F-001");
});
});
// ── Deterministic Ordering Tests ───────────────────────────────────────────────
describe("deterministic ordering", () => {
it("uses stable ordering when order indices are equal", () => {
const roadmap = createRoadmap();
// Same order index for all milestones - should sort by createdAt then id
const rawMilestones = [
createMilestone("MS-001", "RM-001", 0, { createdAt: "2024-01-01T00:00:00.000Z" }),
createMilestone("MS-002", "RM-001", 0, { createdAt: "2024-01-01T00:00:00.000Z" }),
createMilestone("MS-003", "RM-001", 0, { createdAt: "2024-01-02T00:00:00.000Z" }),
];
// Normalize before passing to handoff function (mirrors store behavior)
const milestones = normalizeRoadmapMilestoneOrder(rawMilestones);
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
// MS-001 and MS-002 have same orderIndex and createdAt, so should sort by id
// MS-003 has later createdAt
expect(handoff.milestones[0].sourceMilestoneId).toBe("MS-001");
expect(handoff.milestones[1].sourceMilestoneId).toBe("MS-002");
expect(handoff.milestones[2].sourceMilestoneId).toBe("MS-003");
});
it("produces consistent output across multiple calls with same input", () => {
const roadmap = createRoadmap({ id: "RM-STABLE" });
const milestones = [
createMilestone("MS-001", "RM-STABLE", 1),
createMilestone("MS-002", "RM-STABLE", 0),
];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-001", [createFeature("F-001", "MS-001", 1)]],
["MS-002", [createFeature("F-002", "MS-002", 0)]],
]);
const first = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
const second = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
expect(first).toEqual(second);
expect(first.milestones[0].sourceMilestoneId).toBe(second.milestones[0].sourceMilestoneId);
expect(first.milestones[1].sourceMilestoneId).toBe(second.milestones[1].sourceMilestoneId);
});
});
// ── Source Lineage Preservation Tests ─────────────────────────────────────────
describe("source lineage preservation", () => {
it("preserves roadmap context in all feature handoffs", () => {
const roadmap = createRoadmap({ id: "RM-LINEAGE", title: "Lineage Test" });
const milestones = [createMilestone("MS-LINEAGE", "RM-LINEAGE", 0)];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-LINEAGE", [createFeature("F-LINEAGE", "MS-LINEAGE", 0)]],
]);
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
expect(handoffs[0].source.roadmapId).toBe("RM-LINEAGE");
expect(handoffs[0].source.roadmapTitle).toBe("Lineage Test");
});
it("preserves milestone context in all feature handoffs", () => {
const roadmap = createRoadmap();
const milestones = [
createMilestone("MS-ALPHA", "RM-001", 0, { title: "Alpha Milestone" }),
createMilestone("MS-BETA", "RM-001", 1, { title: "Beta Milestone" }),
];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-ALPHA", [createFeature("F-001", "MS-ALPHA", 0)]],
["MS-BETA", [createFeature("F-002", "MS-BETA", 0)]],
]);
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
expect(handoffs).toHaveLength(2);
expect(handoffs[0].source.milestoneId).toBe("MS-ALPHA");
expect(handoffs[0].source.milestoneTitle).toBe("Alpha Milestone");
expect(handoffs[1].source.milestoneId).toBe("MS-BETA");
expect(handoffs[1].source.milestoneTitle).toBe("Beta Milestone");
});
it("mission handoff preserves source IDs on all entities", () => {
const roadmap = createRoadmap({ id: "RM-MISSION" });
const milestones = [
createMilestone("MS-MISSION-1", "RM-MISSION", 0, { title: "First Phase" }),
];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-MISSION-1", [
createFeature("RF-MISSION-1", "MS-MISSION-1", 0, { title: "Mission Feature" }),
]],
]);
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
expect(handoff.sourceRoadmapId).toBe("RM-MISSION");
expect(handoff.milestones[0].sourceMilestoneId).toBe("MS-MISSION-1");
expect(handoff.milestones[0].features[0].sourceFeatureId).toBe("RF-MISSION-1");
});
});

View File

@@ -0,0 +1,334 @@
import { describe, expect, it } from "vitest";
import {
applyRoadmapFeatureReorder,
applyRoadmapMilestoneReorder,
moveRoadmapFeature,
normalizeRoadmapFeatureOrder,
normalizeRoadmapMilestoneOrder,
} from "../roadmap-ordering.js";
import type { RoadmapFeature, RoadmapMilestone } from "../../roadmap-types.js";
function createMilestone(
id: string,
roadmapId: string,
orderIndex: number,
createdAt: string,
): RoadmapMilestone {
return {
id,
roadmapId,
title: id,
description: `${id} description`,
orderIndex,
createdAt,
updatedAt: createdAt,
};
}
function createFeature(
id: string,
milestoneId: string,
orderIndex: number,
createdAt: string,
): RoadmapFeature {
return {
id,
milestoneId,
title: id,
description: `${id} description`,
orderIndex,
createdAt,
updatedAt: createdAt,
};
}
describe("roadmap-ordering", () => {
describe("normalizeRoadmapMilestoneOrder", () => {
it("repairs milestone ordering deterministically using createdAt and id tiebreakers", () => {
const milestones = [
createMilestone("RMS-C", "RM-1", 2, "2026-04-13T00:00:02.000Z"),
createMilestone("RMS-B", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
createMilestone("RMS-A", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
];
const normalized = normalizeRoadmapMilestoneOrder(milestones);
expect(normalized.map((milestone) => milestone.id)).toEqual([
"RMS-A",
"RMS-B",
"RMS-C",
]);
expect(normalized.map((milestone) => milestone.orderIndex)).toEqual([0, 1, 2]);
expect(milestones.map((milestone) => milestone.orderIndex)).toEqual([2, 1, 1]);
});
it("rejects mixed-roadmap milestone scopes", () => {
const milestones = [
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
createMilestone("RMS-2", "RM-2", 1, "2026-04-13T00:00:01.000Z"),
];
expect(() => normalizeRoadmapMilestoneOrder(milestones)).toThrow(
"Milestone RMS-2 does not belong to roadmap RM-1",
);
});
});
describe("applyRoadmapMilestoneReorder", () => {
it("reorders milestones and rewrites contiguous order indexes", () => {
const milestones = [
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
createMilestone("RMS-2", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
createMilestone("RMS-3", "RM-1", 2, "2026-04-13T00:00:02.000Z"),
];
const reordered = applyRoadmapMilestoneReorder(milestones, {
roadmapId: "RM-1",
orderedMilestoneIds: ["RMS-3", "RMS-1", "RMS-2"],
});
expect(reordered.map((milestone) => milestone.id)).toEqual([
"RMS-3",
"RMS-1",
"RMS-2",
]);
expect(reordered.map((milestone) => milestone.orderIndex)).toEqual([0, 1, 2]);
});
it("rejects duplicate milestone ids in reorder input", () => {
const milestones = [
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
createMilestone("RMS-2", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
];
expect(() =>
applyRoadmapMilestoneReorder(milestones, {
roadmapId: "RM-1",
orderedMilestoneIds: ["RMS-2", "RMS-2"],
}),
).toThrow("Duplicate milestone id in requested order: RMS-2");
});
});
describe("normalizeRoadmapFeatureOrder", () => {
it("repairs feature ordering deterministically using createdAt and id tiebreakers", () => {
const features = [
createFeature("RF-C", "RMS-1", 3, "2026-04-13T00:00:03.000Z"),
createFeature("RF-B", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-A", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
const normalized = normalizeRoadmapFeatureOrder(features);
expect(normalized.map((feature) => feature.id)).toEqual([
"RF-A",
"RF-B",
"RF-C",
]);
expect(normalized.map((feature) => feature.orderIndex)).toEqual([0, 1, 2]);
});
});
describe("applyRoadmapFeatureReorder", () => {
it("reorders features within a milestone and rewrites contiguous order indexes", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-3", "RMS-1", 2, "2026-04-13T00:00:02.000Z"),
];
const reordered = applyRoadmapFeatureReorder(features, {
roadmapId: "RM-1",
milestoneId: "RMS-1",
orderedFeatureIds: ["RF-2", "RF-3", "RF-1"],
});
expect(reordered.map((feature) => feature.id)).toEqual([
"RF-2",
"RF-3",
"RF-1",
]);
expect(reordered.map((feature) => feature.orderIndex)).toEqual([0, 1, 2]);
});
it("rejects partial feature reorder payloads", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
expect(() =>
applyRoadmapFeatureReorder(features, {
roadmapId: "RM-1",
milestoneId: "RMS-1",
orderedFeatureIds: ["RF-2"],
}),
).toThrow("Expected 2 feature ids but received 1");
});
});
describe("moveRoadmapFeature", () => {
it("moves a feature across milestones and normalizes both milestone orders", () => {
const features = [
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-SOURCE", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-3", "RMS-TARGET", 0, "2026-04-13T00:00:02.000Z"),
createFeature("RF-4", "RMS-TARGET", 1, "2026-04-13T00:00:03.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-2",
fromMilestoneId: "RMS-SOURCE",
toMilestoneId: "RMS-TARGET",
targetOrderIndex: 1,
});
expect(result.movedFeature).toMatchObject({
id: "RF-2",
milestoneId: "RMS-TARGET",
orderIndex: 1,
});
expect(result.sourceMilestoneFeatures.map((feature) => feature.id)).toEqual([
"RF-1",
]);
expect(result.sourceMilestoneFeatures.map((feature) => feature.orderIndex)).toEqual([0]);
expect(result.targetMilestoneFeatures.map((feature) => feature.id)).toEqual([
"RF-3",
"RF-2",
"RF-4",
]);
expect(result.targetMilestoneFeatures.map((feature) => feature.orderIndex)).toEqual([
0,
1,
2,
]);
expect(result.affectedFeatures).toHaveLength(4);
});
it("clamps same-milestone moves into range and returns a single normalized list", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-3", "RMS-1", 2, "2026-04-13T00:00:02.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-1",
fromMilestoneId: "RMS-1",
toMilestoneId: "RMS-1",
targetOrderIndex: 99,
});
expect(result.sourceMilestoneFeatures.map((feature) => feature.id)).toEqual([
"RF-2",
"RF-3",
"RF-1",
]);
expect(result.targetMilestoneFeatures).toEqual(result.sourceMilestoneFeatures);
expect(result.movedFeature.orderIndex).toBe(2);
});
it("rejects features outside the affected milestone scope", () => {
const features = [
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-OTHER", 0, "2026-04-13T00:00:01.000Z"),
];
expect(() =>
moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-1",
fromMilestoneId: "RMS-SOURCE",
toMilestoneId: "RMS-TARGET",
targetOrderIndex: 0,
}),
).toThrow(
"Feature RF-2 is outside the affected milestone scope (RMS-SOURCE → RMS-TARGET)",
);
});
it("clamps negative targetOrderIndex to 0", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-2",
fromMilestoneId: "RMS-1",
toMilestoneId: "RMS-1",
targetOrderIndex: -5,
});
// RF-2 should be moved to index 0, RF-1 to index 1
expect(result.movedFeature.orderIndex).toBe(0);
expect(result.sourceMilestoneFeatures.map((f) => f.id)).toEqual(["RF-2", "RF-1"]);
});
it("clamps NaN targetOrderIndex to end", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-1",
fromMilestoneId: "RMS-1",
toMilestoneId: "RMS-1",
targetOrderIndex: NaN,
});
// NaN is clamped to the end (length of the remaining list)
expect(result.movedFeature.orderIndex).toBe(1);
});
it("clamps Infinity targetOrderIndex to end", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-1",
fromMilestoneId: "RMS-1",
toMilestoneId: "RMS-1",
targetOrderIndex: Infinity,
});
// Infinity is clamped to the end
expect(result.movedFeature.orderIndex).toBe(1);
});
it("produces strictly contiguous orderIndex values after move", () => {
const features = [
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-SOURCE", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-3", "RMS-SOURCE", 2, "2026-04-13T00:00:02.000Z"),
createFeature("RF-4", "RMS-TARGET", 0, "2026-04-13T00:00:03.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-2",
fromMilestoneId: "RMS-SOURCE",
toMilestoneId: "RMS-TARGET",
targetOrderIndex: 0,
});
// Verify contiguous orderIndex for source
const sourceOrderIndices = result.sourceMilestoneFeatures.map((f) => f.orderIndex);
expect(sourceOrderIndices).toEqual([0, 1]);
expect(new Set(sourceOrderIndices).size).toBe(sourceOrderIndices.length);
// Verify contiguous orderIndex for target
const targetOrderIndices = result.targetMilestoneFeatures.map((f) => f.orderIndex);
expect(targetOrderIndices).toEqual([0, 1]);
expect(new Set(targetOrderIndices).size).toBe(targetOrderIndices.length);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
/**
* Pure mapping helpers for converting roadmap hierarchy data into mission/task planning handoffs.
*
* These helpers are read-only transformations that preserve source lineage and deterministic
* ordering without coupling to MissionStore or task persistence.
*
* @module roadmap-handoff
*/
import type {
Roadmap,
RoadmapMilestone,
RoadmapFeature,
RoadmapWithHierarchy,
RoadmapFeatureTaskPlanningHandoff,
RoadmapMissionPlanningHandoff,
RoadmapFeatureSourceRef,
RoadmapMissionPlanningMilestoneHandoff,
} from "./roadmap-types.js";
import {
normalizeRoadmapMilestoneOrder,
normalizeRoadmapFeatureOrder,
} from "./roadmap-ordering.js";
/**
* Build a source reference for a roadmap feature.
*
* Includes roadmap and milestone context for downstream planning prompts.
*/
function buildFeatureSourceRef(
roadmap: Roadmap,
milestone: RoadmapMilestone,
feature: RoadmapFeature,
): RoadmapFeatureSourceRef {
return {
roadmapId: roadmap.id,
milestoneId: milestone.id,
featureId: feature.id,
roadmapTitle: roadmap.title,
milestoneTitle: milestone.title,
milestoneOrderIndex: milestone.orderIndex,
featureOrderIndex: feature.orderIndex,
};
}
/**
* Convert a single roadmap feature into a task planning handoff payload.
*
* The handoff preserves source lineage for traceability and deterministic ordering
* for consistent downstream processing.
*/
export function mapFeatureToTaskHandoff(
roadmap: Roadmap,
milestone: RoadmapMilestone,
feature: RoadmapFeature,
): RoadmapFeatureTaskPlanningHandoff {
return {
source: buildFeatureSourceRef(roadmap, milestone, feature),
title: feature.title,
description: feature.description,
};
}
/**
* Convert a full roadmap hierarchy into a mission planning handoff payload.
*
* The handoff preserves deterministic ordering by normalizing milestone and feature
* order indices before building the payload.
*
* @param roadmap - The roadmap to convert
* @param milestones - Ordered milestones (will be re-normalized for deterministic output)
* @param featuresByMilestoneId - Features grouped by milestone ID
* @returns Mission planning handoff payload
*/
export function mapRoadmapToMissionHandoff(
roadmap: Roadmap,
milestones: readonly RoadmapMilestone[],
featuresByMilestoneId: ReadonlyMap<string, readonly RoadmapFeature[]>,
): RoadmapMissionPlanningHandoff {
// Normalize milestone ordering deterministically
const normalizedMilestones = normalizeRoadmapMilestoneOrder(milestones);
const milestoneHandoffs: RoadmapMissionPlanningMilestoneHandoff[] = normalizedMilestones.map((milestone) => {
// Get features for this milestone and normalize their order
const rawFeatures = featuresByMilestoneId.get(milestone.id) ?? [];
const normalizedFeatures = normalizeRoadmapFeatureOrder(rawFeatures);
return {
sourceMilestoneId: milestone.id,
title: milestone.title,
description: milestone.description,
orderIndex: milestone.orderIndex,
features: normalizedFeatures.map((feature) => ({
sourceFeatureId: feature.id,
title: feature.title,
description: feature.description,
orderIndex: feature.orderIndex,
})),
};
});
return {
sourceRoadmapId: roadmap.id,
title: roadmap.title,
description: roadmap.description,
milestones: milestoneHandoffs,
};
}
/**
* Convert a roadmap with full hierarchy into a mission planning handoff payload.
*
* Convenience overload that accepts the composite RoadmapWithHierarchy type.
*/
export function mapRoadmapWithHierarchyToMissionHandoff(
roadmapWithHierarchy: RoadmapWithHierarchy,
): RoadmapMissionPlanningHandoff {
// Build features by milestone ID map
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
for (const milestone of roadmapWithHierarchy.milestones) {
featuresByMilestoneId.set(milestone.id, milestone.features);
}
return mapRoadmapToMissionHandoff(
roadmapWithHierarchy,
roadmapWithHierarchy.milestones,
featuresByMilestoneId,
);
}
/**
* Convert all features from a roadmap into task planning handoff payloads.
*
* Flattens the roadmap hierarchy into individual feature handoffs, each preserving
* source lineage and deterministic ordering.
*
* @param roadmap - The parent roadmap
* @param milestones - Ordered milestones (will be re-normalized for deterministic output)
* @param featuresByMilestoneId - Features grouped by milestone ID
* @returns Array of feature task planning handoffs, ordered by milestone order then feature order
*/
export function mapAllFeaturesToTaskHandoffs(
roadmap: Roadmap,
milestones: readonly RoadmapMilestone[],
featuresByMilestoneId: ReadonlyMap<string, readonly RoadmapFeature[]>,
): RoadmapFeatureTaskPlanningHandoff[] {
// Normalize milestone ordering deterministically
const normalizedMilestones = normalizeRoadmapMilestoneOrder(milestones);
const handoffs: RoadmapFeatureTaskPlanningHandoff[] = [];
for (const milestone of normalizedMilestones) {
const rawFeatures = featuresByMilestoneId.get(milestone.id) ?? [];
const normalizedFeatures = normalizeRoadmapFeatureOrder(rawFeatures);
for (const feature of normalizedFeatures) {
handoffs.push(mapFeatureToTaskHandoff(roadmap, milestone, feature));
}
}
return handoffs;
}

View File

@@ -0,0 +1,311 @@
import type {
RoadmapFeature,
RoadmapFeatureMoveInput,
RoadmapFeatureMoveResult,
RoadmapFeatureReorderInput,
RoadmapMilestone,
RoadmapMilestoneReorderInput,
} from "./roadmap-types.js";
/**
* Pure ordering helpers for the standalone roadmap model.
*
* Ordering invariants enforced by this module:
* - helpers operate on scoped arrays (single roadmap for milestones, single
* milestone for feature reorders, source+target milestones for feature moves)
* - normalized `orderIndex` values are always contiguous + 0-based
* - when stored order data is conflicting, helpers repair deterministically via
* `orderIndex ASC`, `createdAt ASC`, `id ASC`
* - explicit reorder helpers reject partial or duplicate ID lists instead of
* guessing user intent
*/
interface OrderedEntity {
id: string;
orderIndex: number;
createdAt: string;
}
function compareOrderedEntities<T extends OrderedEntity>(a: T, b: T): number {
if (a.orderIndex !== b.orderIndex) {
return a.orderIndex - b.orderIndex;
}
if (a.createdAt !== b.createdAt) {
return a.createdAt.localeCompare(b.createdAt);
}
return a.id.localeCompare(b.id);
}
function clampInsertionIndex(targetIndex: number, length: number): number {
if (!Number.isFinite(targetIndex)) {
return length;
}
const normalized = Math.trunc(targetIndex);
if (normalized < 0) {
return 0;
}
if (normalized > length) {
return length;
}
return normalized;
}
function assertScopedRoadmapMilestones(
milestones: readonly RoadmapMilestone[],
roadmapId: string,
): void {
for (const milestone of milestones) {
if (milestone.roadmapId !== roadmapId) {
throw new Error(
`Milestone ${milestone.id} does not belong to roadmap ${roadmapId}`,
);
}
}
}
function assertScopedMilestoneFeatures(
features: readonly RoadmapFeature[],
milestoneId: string,
): void {
for (const feature of features) {
if (feature.milestoneId !== milestoneId) {
throw new Error(
`Feature ${feature.id} does not belong to milestone ${milestoneId}`,
);
}
}
}
function assertScopedMoveFeatures(
features: readonly RoadmapFeature[],
fromMilestoneId: string,
toMilestoneId: string,
): void {
const validMilestoneIds = new Set([fromMilestoneId, toMilestoneId]);
for (const feature of features) {
if (!validMilestoneIds.has(feature.milestoneId)) {
throw new Error(
`Feature ${feature.id} is outside the affected milestone scope (${fromMilestoneId}${toMilestoneId})`,
);
}
}
}
function assertExactIdSet(
entityLabel: string,
actualIds: readonly string[],
orderedIds: readonly string[],
): void {
const requestedIds = new Set<string>();
for (const id of orderedIds) {
if (requestedIds.has(id)) {
throw new Error(`Duplicate ${entityLabel} id in requested order: ${id}`);
}
requestedIds.add(id);
}
if (actualIds.length !== orderedIds.length) {
throw new Error(
`Expected ${actualIds.length} ${entityLabel} ids but received ${orderedIds.length}`,
);
}
const actualIdSet = new Set(actualIds);
for (const id of orderedIds) {
if (!actualIdSet.has(id)) {
throw new Error(`${capitalize(entityLabel)} ${id} not found in scoped list`);
}
}
for (const id of actualIds) {
if (!requestedIds.has(id)) {
throw new Error(`Missing ${entityLabel} id in requested order: ${id}`);
}
}
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function assignContiguousOrder<T extends OrderedEntity>(items: readonly T[]): T[] {
return items.map((item, orderIndex) => {
if (item.orderIndex === orderIndex) {
return { ...item };
}
return {
...item,
orderIndex,
};
});
}
/**
* Repairs milestone ordering for a single roadmap scope.
*
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
*/
export function normalizeRoadmapMilestoneOrder(
milestones: readonly RoadmapMilestone[],
): RoadmapMilestone[] {
if (milestones.length === 0) {
return [];
}
assertScopedRoadmapMilestones(milestones, milestones[0].roadmapId);
return assignContiguousOrder(
[...milestones].sort(compareOrderedEntities),
);
}
/**
* Applies an explicit milestone reorder for a single roadmap scope.
*
* The caller must provide the complete milestone ID set exactly once. Partial
* or duplicate lists are rejected to keep reorders deterministic.
*/
export function applyRoadmapMilestoneReorder(
milestones: readonly RoadmapMilestone[],
input: RoadmapMilestoneReorderInput,
): RoadmapMilestone[] {
assertScopedRoadmapMilestones(milestones, input.roadmapId);
const normalized = normalizeRoadmapMilestoneOrder(milestones);
const ids = normalized.map((milestone) => milestone.id);
assertExactIdSet("milestone", ids, input.orderedMilestoneIds);
const byId = new Map(normalized.map((milestone) => [milestone.id, milestone]));
return assignContiguousOrder(
input.orderedMilestoneIds.map((id) => byId.get(id)!),
);
}
/**
* Repairs feature ordering for a single milestone scope.
*
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
*/
export function normalizeRoadmapFeatureOrder(
features: readonly RoadmapFeature[],
): RoadmapFeature[] {
if (features.length === 0) {
return [];
}
assertScopedMilestoneFeatures(features, features[0].milestoneId);
return assignContiguousOrder(
[...features].sort(compareOrderedEntities),
);
}
/**
* Applies an explicit feature reorder for a single milestone scope.
*
* The caller must provide the complete feature ID set exactly once. Partial or
* duplicate lists are rejected to keep reorder behavior deterministic.
*/
export function applyRoadmapFeatureReorder(
features: readonly RoadmapFeature[],
input: RoadmapFeatureReorderInput,
): RoadmapFeature[] {
assertScopedMilestoneFeatures(features, input.milestoneId);
const normalized = normalizeRoadmapFeatureOrder(features);
const ids = normalized.map((feature) => feature.id);
assertExactIdSet("feature", ids, input.orderedFeatureIds);
const byId = new Map(normalized.map((feature) => [feature.id, feature]));
return assignContiguousOrder(
input.orderedFeatureIds.map((id) => byId.get(id)!),
);
}
/**
* Moves a feature within the affected milestone scope and deterministically
* normalizes both source and destination order.
*
* For cross-milestone moves, pass the combined feature list from the source and
* target milestones. For within-milestone moves, pass the current milestone's
* feature list. `targetOrderIndex` is clamped into the destination range.
*/
export function moveRoadmapFeature(
features: readonly RoadmapFeature[],
input: RoadmapFeatureMoveInput,
): RoadmapFeatureMoveResult {
assertScopedMoveFeatures(features, input.fromMilestoneId, input.toMilestoneId);
const existingFeature = features.find((feature) => feature.id === input.featureId);
if (!existingFeature) {
throw new Error(`Feature ${input.featureId} not found in affected milestone scope`);
}
if (existingFeature.milestoneId !== input.fromMilestoneId) {
throw new Error(
`Feature ${input.featureId} does not belong to milestone ${input.fromMilestoneId}`,
);
}
const sourceFeatures = normalizeRoadmapFeatureOrder(
features.filter((feature) => feature.milestoneId === input.fromMilestoneId),
);
const sourceWithoutFeature = sourceFeatures.filter(
(feature) => feature.id !== input.featureId,
);
if (input.fromMilestoneId === input.toMilestoneId) {
const insertionIndex = clampInsertionIndex(
input.targetOrderIndex,
sourceWithoutFeature.length,
);
const reordered = [...sourceWithoutFeature];
reordered.splice(insertionIndex, 0, {
...existingFeature,
milestoneId: input.toMilestoneId,
orderIndex: insertionIndex,
});
const normalized = assignContiguousOrder(reordered);
const movedFeature = normalized.find((feature) => feature.id === input.featureId)!;
return {
movedFeature,
affectedFeatures: normalized,
sourceMilestoneFeatures: normalized,
targetMilestoneFeatures: normalized,
};
}
const targetFeatures = normalizeRoadmapFeatureOrder(
features.filter((feature) => feature.milestoneId === input.toMilestoneId),
);
const insertionIndex = clampInsertionIndex(
input.targetOrderIndex,
targetFeatures.length,
);
const targetWithInsertedFeature = [...targetFeatures];
targetWithInsertedFeature.splice(insertionIndex, 0, {
...existingFeature,
milestoneId: input.toMilestoneId,
orderIndex: insertionIndex,
});
const normalizedSource = assignContiguousOrder(sourceWithoutFeature);
const normalizedTarget = assignContiguousOrder(targetWithInsertedFeature);
const movedFeature = normalizedTarget.find((feature) => feature.id === input.featureId)!;
return {
movedFeature,
affectedFeatures: [...normalizedSource, ...normalizedTarget],
sourceMilestoneFeatures: normalizedSource,
targetMilestoneFeatures: normalizedTarget,
};
}

View File

@@ -0,0 +1,960 @@
/**
* 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 "@fusion/core";
import type {
Roadmap,
RoadmapMilestone,
RoadmapFeature,
RoadmapCreateInput,
RoadmapUpdateInput,
RoadmapMilestoneCreateInput,
RoadmapMilestoneUpdateInput,
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapMilestoneReorderInput,
RoadmapFeatureReorderInput,
RoadmapFeatureMoveInput,
RoadmapMilestoneWithFeatures,
RoadmapWithHierarchy,
RoadmapExportBundle,
RoadmapMissionPlanningHandoff,
RoadmapFeatureTaskPlanningHandoff,
RoadmapFeatureSourceRef,
} from "../roadmap-types.js";
import {
applyRoadmapMilestoneReorder,
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 }];
}
// ── Row Interfaces ──────────────────────────────────────────────────
/** Database row shape for roadmaps. */
interface RoadmapRow {
id: string;
title: string;
description: string | null;
createdAt: string;
updatedAt: string;
}
/** Database row shape for roadmap_milestones. */
interface RoadmapMilestoneRow {
id: string;
roadmapId: string;
title: string;
description: string | null;
orderIndex: number;
createdAt: string;
updatedAt: string;
}
/** Database row shape for roadmap_features. */
interface RoadmapFeatureRow {
id: string;
milestoneId: string;
title: string;
description: string | null;
orderIndex: number;
createdAt: string;
updatedAt: 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: RoadmapRow): Roadmap {
return {
id: row.id,
title: row.title,
description: row.description || undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private rowToMilestone(row: RoadmapMilestoneRow): 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: RoadmapFeatureRow): 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) as unknown as RoadmapRow | undefined;
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 unknown as RoadmapRow[]).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) as unknown as RoadmapMilestoneRow | undefined;
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 unknown as RoadmapMilestoneRow[]).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`);
}
// 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) as unknown as RoadmapFeatureRow | undefined;
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 unknown as RoadmapFeatureRow[]).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),
})),
};
}
// ── Export / Handoff Operations ────────────────────────────────────
/**
* Get a flat export bundle for a roadmap.
*
* Returns all roadmap data in a flat structure suitable for persistence,
* APIs, import/export, and sync jobs. Entities are separated so downstream
* persistence layers can upsert by table/collection.
*
* @param roadmapId - Roadmap ID
* @returns The export bundle with ordered entities
* @throws Error if roadmap not found
*/
getRoadmapExport(roadmapId: string): RoadmapExportBundle {
const roadmap = this.getRoadmap(roadmapId);
if (!roadmap) {
throw new Error(`Roadmap ${roadmapId} not found`);
}
const milestones = this.listMilestones(roadmapId);
const allFeatures: RoadmapFeature[] = [];
for (const milestone of milestones) {
const features = this.listFeatures(milestone.id);
allFeatures.push(...features);
}
return {
roadmap,
milestones,
features: allFeatures,
};
}
/**
* Get a mission planning handoff payload for a roadmap.
*
* Converts the roadmap into a mission planning structure while preserving
* source IDs and deterministic order. Does not couple to MissionStore internals.
*
* @param roadmapId - Roadmap ID
* @returns The mission planning handoff payload
* @throws Error if roadmap not found
*/
getRoadmapMissionHandoff(roadmapId: string): RoadmapMissionPlanningHandoff {
const roadmap = this.getRoadmap(roadmapId);
if (!roadmap) {
throw new Error(`Roadmap ${roadmapId} not found`);
}
const milestones = this.listMilestones(roadmapId);
return {
sourceRoadmapId: roadmap.id,
title: roadmap.title,
description: roadmap.description,
milestones: milestones.map((milestone) => {
const features = this.listFeatures(milestone.id);
return {
sourceMilestoneId: milestone.id,
title: milestone.title,
description: milestone.description,
orderIndex: milestone.orderIndex,
features: features.map((feature) => ({
sourceFeatureId: feature.id,
title: feature.title,
description: feature.description,
orderIndex: feature.orderIndex,
})),
};
}),
};
}
/**
* Get a task planning handoff payload for a single roadmap feature.
*
* Returns a self-contained handoff payload for converting a roadmap feature
* into task planning flows without coupling to MissionStore internals.
*
* @param roadmapId - Parent roadmap ID (for validation)
* @param milestoneId - Parent milestone ID (for validation)
* @param featureId - Feature ID to generate handoff for
* @returns The task planning handoff payload
* @throws Error if any entity is not found or if ownership validation fails
*/
getRoadmapFeatureHandoff(
roadmapId: string,
milestoneId: string,
featureId: string,
): RoadmapFeatureTaskPlanningHandoff {
// Validate roadmap exists
const roadmap = this.getRoadmap(roadmapId);
if (!roadmap) {
throw new Error(`Roadmap ${roadmapId} not found`);
}
// Validate milestone exists and belongs to roadmap
const milestone = this.getMilestone(milestoneId);
if (!milestone) {
throw new Error(`Milestone ${milestoneId} not found`);
}
if (milestone.roadmapId !== roadmapId) {
throw new Error(`Milestone ${milestoneId} does not belong to roadmap ${roadmapId}`);
}
// Validate feature exists and belongs to milestone
const feature = this.getFeature(featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
if (feature.milestoneId !== milestoneId) {
throw new Error(`Feature ${featureId} does not belong to milestone ${milestoneId}`);
}
// Build the source reference with ordering context
const source: RoadmapFeatureSourceRef = {
roadmapId: roadmap.id,
milestoneId: milestone.id,
featureId: feature.id,
roadmapTitle: roadmap.title,
milestoneTitle: milestone.title,
milestoneOrderIndex: milestone.orderIndex,
featureOrderIndex: feature.orderIndex,
};
return {
source,
title: feature.title,
description: feature.description,
};
}
/**
* Get a mission planning handoff payload for a roadmap.
*
* Alias for getRoadmapMissionHandoff() for API consistency.
* Converts the roadmap into a mission planning structure while preserving
* source IDs and deterministic order.
*
* @param roadmapId - Roadmap ID
* @returns The mission planning handoff payload
* @throws Error if roadmap not found
*/
getMissionPlanningHandoff(roadmapId: string): RoadmapMissionPlanningHandoff {
return this.getRoadmapMissionHandoff(roadmapId);
}
/**
* List all task planning handoff payloads for a roadmap.
*
* Returns a flat list of all feature handoffs in deterministic order
* (milestone order index, then feature order index).
*
* @param roadmapId - Roadmap ID
* @returns Array of task planning handoff payloads for all features
* @throws Error if roadmap not found
*/
listFeatureTaskPlanningHandoffs(roadmapId: string): RoadmapFeatureTaskPlanningHandoff[] {
// Validate roadmap exists
const roadmap = this.getRoadmap(roadmapId);
if (!roadmap) {
throw new Error(`Roadmap ${roadmapId} not found`);
}
const milestones = this.listMilestones(roadmapId);
const handoffs: RoadmapFeatureTaskPlanningHandoff[] = [];
for (const milestone of milestones) {
const features = this.listFeatures(milestone.id);
for (const feature of features) {
const source: RoadmapFeatureSourceRef = {
roadmapId: roadmap.id,
milestoneId: milestone.id,
featureId: feature.id,
roadmapTitle: roadmap.title,
milestoneTitle: milestone.title,
milestoneOrderIndex: milestone.orderIndex,
featureOrderIndex: feature.orderIndex,
};
handoffs.push({
source,
title: feature.title,
description: feature.description,
});
}
}
return handoffs;
}
}