feat(FN-3159): move roadmap ownership from core to fusion-plugin-roadmap pl
Moves roadmap ownership from `@fusion/core` to `fusion-plugin-roadmap`, deleting ~1,800 lines of roadmap store, types, ordering, and handoff logic from core and replacing it with ~760 lines of new route handlers in the plugin. The dashboard's roadmap routes and suggestions modules are substantially Fusion-Task-Id: FN-3159
This commit is contained in:
37
plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts
vendored
Normal file
37
plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { RoadmapFeature, RoadmapFeatureMoveInput, RoadmapFeatureMoveResult, RoadmapFeatureReorderInput, RoadmapMilestone, RoadmapMilestoneReorderInput } from "../roadmap-types.js";
|
||||
/**
|
||||
* Repairs milestone ordering for a single roadmap scope.
|
||||
*
|
||||
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||
*/
|
||||
export declare function normalizeRoadmapMilestoneOrder(milestones: readonly RoadmapMilestone[]): RoadmapMilestone[];
|
||||
/**
|
||||
* 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 declare function applyRoadmapMilestoneReorder(milestones: readonly RoadmapMilestone[], input: RoadmapMilestoneReorderInput): RoadmapMilestone[];
|
||||
/**
|
||||
* Repairs feature ordering for a single milestone scope.
|
||||
*
|
||||
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||
*/
|
||||
export declare function normalizeRoadmapFeatureOrder(features: readonly RoadmapFeature[]): RoadmapFeature[];
|
||||
/**
|
||||
* 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 declare function applyRoadmapFeatureReorder(features: readonly RoadmapFeature[], input: RoadmapFeatureReorderInput): RoadmapFeature[];
|
||||
/**
|
||||
* 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 declare function moveRoadmapFeature(features: readonly RoadmapFeature[], input: RoadmapFeatureMoveInput): RoadmapFeatureMoveResult;
|
||||
//# sourceMappingURL=roadmap-ordering.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"roadmap-ordering.d.ts","sourceRoot":"","sources":["roadmap-ordering.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,uBAAuB,EACvB,wBAAwB,EACxB,0BAA0B,EAC1B,gBAAgB,EAChB,4BAA4B,EAC7B,MAAM,qBAAqB,CAAC;AA8I7B;;;;GAIG;AACH,wBAAgB,8BAA8B,CAC5C,UAAU,EAAE,SAAS,gBAAgB,EAAE,GACtC,gBAAgB,EAAE,CAUpB;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,UAAU,EAAE,SAAS,gBAAgB,EAAE,EACvC,KAAK,EAAE,4BAA4B,GAClC,gBAAgB,EAAE,CAWpB;AAED;;;;GAIG;AACH,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,SAAS,cAAc,EAAE,GAClC,cAAc,EAAE,CAUlB;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,SAAS,cAAc,EAAE,EACnC,KAAK,EAAE,0BAA0B,GAChC,cAAc,EAAE,CAWlB;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,SAAS,cAAc,EAAE,EACnC,KAAK,EAAE,uBAAuB,GAC7B,wBAAwB,CAoE1B"}
|
||||
188
plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js
Normal file
188
plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js
Normal file
@@ -0,0 +1,188 @@
|
||||
function compareOrderedEntities(a, b) {
|
||||
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, length) {
|
||||
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, roadmapId) {
|
||||
for (const milestone of milestones) {
|
||||
if (milestone.roadmapId !== roadmapId) {
|
||||
throw new Error(`Milestone ${milestone.id} does not belong to roadmap ${roadmapId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function assertScopedMilestoneFeatures(features, milestoneId) {
|
||||
for (const feature of features) {
|
||||
if (feature.milestoneId !== milestoneId) {
|
||||
throw new Error(`Feature ${feature.id} does not belong to milestone ${milestoneId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function assertScopedMoveFeatures(features, fromMilestoneId, toMilestoneId) {
|
||||
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, actualIds, orderedIds) {
|
||||
const requestedIds = new Set();
|
||||
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) {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
function assignContiguousOrder(items) {
|
||||
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) {
|
||||
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, input) {
|
||||
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) {
|
||||
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, input) {
|
||||
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, input) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=roadmap-ordering.js.map
|
||||
File diff suppressed because one or more lines are too long
299
plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts
vendored
Normal file
299
plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts
vendored
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 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 } from "../roadmap-types.js";
|
||||
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;
|
||||
}];
|
||||
}
|
||||
export declare class RoadmapStore extends EventEmitter<RoadmapStoreEvents> {
|
||||
private db;
|
||||
/**
|
||||
* Creates a new RoadmapStore instance.
|
||||
*
|
||||
* @param db - Shared Database instance (same instance used by TaskStore)
|
||||
*/
|
||||
constructor(db: Database);
|
||||
private ensureSchema;
|
||||
private generateRoadmapId;
|
||||
private generateMilestoneId;
|
||||
private generateFeatureId;
|
||||
private rowToRoadmap;
|
||||
private rowToMilestone;
|
||||
private rowToFeature;
|
||||
/**
|
||||
* Create a new roadmap.
|
||||
*
|
||||
* @param input - Roadmap creation input
|
||||
* @returns The created roadmap
|
||||
*/
|
||||
createRoadmap(input: RoadmapCreateInput): Roadmap;
|
||||
/**
|
||||
* Get a roadmap by ID.
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @returns The roadmap, or undefined if not found
|
||||
*/
|
||||
getRoadmap(id: string): Roadmap | undefined;
|
||||
/**
|
||||
* List all roadmaps, ordered by creation date (newest first).
|
||||
*
|
||||
* @returns Array of roadmaps
|
||||
*/
|
||||
listRoadmaps(): Roadmap[];
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Delete a roadmap and all its milestones/features (cascading).
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
deleteRoadmap(id: string): void;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Get a milestone by ID.
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @returns The milestone, or undefined if not found
|
||||
*/
|
||||
getMilestone(id: string): RoadmapMilestone | undefined;
|
||||
/**
|
||||
* 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[];
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Delete a milestone and all its features (cascading).
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
deleteMilestone(id: string): void;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Get a feature by ID.
|
||||
*
|
||||
* @param id - Feature ID
|
||||
* @returns The feature, or undefined if not found
|
||||
*/
|
||||
getFeature(id: string): RoadmapFeature | undefined;
|
||||
/**
|
||||
* 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[];
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Delete a feature.
|
||||
*
|
||||
* @param id - Feature ID
|
||||
* @throws Error if feature not found
|
||||
*/
|
||||
deleteFeature(id: string): void;
|
||||
/**
|
||||
* 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[];
|
||||
/**
|
||||
* 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[];
|
||||
/**
|
||||
* 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[];
|
||||
};
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
//# sourceMappingURL=roadmap-store.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"roadmap-store.d.ts","sourceRoot":"","sources":["roadmap-store.ts"],"names":[],"mappings":"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"}
|
||||
765
plugins/fusion-plugin-roadmap/src/store/roadmap-store.js
Normal file
765
plugins/fusion-plugin-roadmap/src/store/roadmap-store.js
Normal file
@@ -0,0 +1,765 @@
|
||||
/**
|
||||
* 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 { applyRoadmapMilestoneReorder, applyRoadmapFeatureReorder, moveRoadmapFeature, } from "./roadmap-ordering.js";
|
||||
// ── RoadmapStore Class ──────────────────────────────────────────────
|
||||
export class RoadmapStore extends EventEmitter {
|
||||
db;
|
||||
/**
|
||||
* Creates a new RoadmapStore instance.
|
||||
*
|
||||
* @param db - Shared Database instance (same instance used by TaskStore)
|
||||
*/
|
||||
constructor(db) {
|
||||
super();
|
||||
this.db = db;
|
||||
this.setMaxListeners(50);
|
||||
this.ensureSchema();
|
||||
}
|
||||
ensureSchema() {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS roadmaps (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
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);
|
||||
`);
|
||||
}
|
||||
// ── ID Generators ───────────────────────────────────────────────────
|
||||
generateRoadmapId() {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `RM-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||
}
|
||||
generateMilestoneId() {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `RMS-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||
}
|
||||
generateFeatureId() {
|
||||
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 ───────────────────────────────────────
|
||||
rowToRoadmap(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description || undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
rowToMilestone(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
roadmapId: row.roadmapId,
|
||||
title: row.title,
|
||||
description: row.description || undefined,
|
||||
orderIndex: row.orderIndex,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
rowToFeature(row) {
|
||||
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) {
|
||||
const now = new Date().toISOString();
|
||||
const id = this.generateRoadmapId();
|
||||
const 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) {
|
||||
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() {
|
||||
const rows = this.db.prepare("SELECT * FROM roadmaps ORDER BY createdAt DESC").all();
|
||||
return rows.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, updates) {
|
||||
const roadmap = this.getRoadmap(id);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${id} not found`);
|
||||
}
|
||||
const updated = {
|
||||
...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) {
|
||||
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, input) {
|
||||
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 = {
|
||||
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) {
|
||||
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) {
|
||||
const rows = this.db.prepare("SELECT * FROM roadmap_milestones WHERE roadmapId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC").all(roadmapId);
|
||||
return rows.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, updates) {
|
||||
const milestone = this.getMilestone(id);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${id} not found`);
|
||||
}
|
||||
const updated = {
|
||||
...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) {
|
||||
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, input) {
|
||||
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 = {
|
||||
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) {
|
||||
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) {
|
||||
const rows = this.db.prepare("SELECT * FROM roadmap_features WHERE milestoneId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC").all(milestoneId);
|
||||
return rows.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, updates) {
|
||||
const feature = this.getFeature(id);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${id} not found`);
|
||||
}
|
||||
const updated = {
|
||||
...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) {
|
||||
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) {
|
||||
// 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) {
|
||||
// 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) {
|
||||
// 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) {
|
||||
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) {
|
||||
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) {
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
const allFeatures = [];
|
||||
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) {
|
||||
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, milestoneId, featureId) {
|
||||
// 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 = {
|
||||
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) {
|
||||
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) {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
const handoffs = [];
|
||||
for (const milestone of milestones) {
|
||||
const features = this.listFeatures(milestone.id);
|
||||
for (const feature of features) {
|
||||
const source = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=roadmap-store.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -112,6 +112,47 @@ export class RoadmapStore extends EventEmitter<RoadmapStoreEvents> {
|
||||
constructor(private db: Database) {
|
||||
super();
|
||||
this.setMaxListeners(50);
|
||||
this.ensureSchema();
|
||||
}
|
||||
|
||||
private ensureSchema(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS roadmaps (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
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);
|
||||
`);
|
||||
}
|
||||
|
||||
// ── ID Generators ───────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user