feat(FN-3162): add plugin-owned roadmap schema hook and store bootstrap

The merge introduces a plugin-owned roadmap schema system (FN-3162), allowing plugins to define their own schema initialization hook, with tests bootstrapped in the roadmap store and documentation added to the plugin authoring guide. The parallel FN-3281 work delivers review revisions, updates the l

Fusion-Task-Id: FN-3162
This commit is contained in:
Fusion
2026-05-08 17:34:33 -07:00
committed by gsxdsm
parent c0cd5553da
commit 726eb9a1d7
6 changed files with 88 additions and 44 deletions

View File

@@ -12,6 +12,7 @@
- `manifest.json` — plugin metadata and dashboard view declaration
- `src/index.ts` — plugin definition (`onSchemaInit`, routes, dashboard view metadata)
- `src/roadmap-schema.ts` — canonical roadmap DDL used by `hooks.onSchemaInit`
- `src/server/index.ts` — backend server exports
- `src/dashboard-view.tsx` — dashboard view entry export for host registration
- `src/dashboard/RoadmapsView.tsx` — plugin-owned roadmap planner page
@@ -28,4 +29,6 @@
## Notes
Roadmap tables are plugin-owned and created via `hooks.onSchemaInit` in `src/index.ts`, which delegates to `src/roadmap-schema.ts`. Core database bootstrap no longer creates roadmap tables/indexes.
The plugin keeps a single canonical dashboard entrypoint (`./dashboard-view`) and accepts host-supplied dashboard context (`projectId`, optional `addToast`). Do not deep-import dashboard internals from this plugin.

View File

@@ -1,6 +1,9 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { rm } from "node:fs/promises";
import { Database } from "@fusion/core";
import { afterEach, describe, expect, it } from "vitest";
import plugin, {
RoadmapStore,
applyRoadmapFeatureReorder,
@@ -15,6 +18,13 @@ import plugin, {
} from "../index.js";
describe("roadmap-planner package surface", () => {
const tmpDirs: string[] = [];
afterEach(async () => {
await Promise.all(tmpDirs.map((dir) => rm(dir, { recursive: true, force: true })));
tmpDirs.length = 0;
});
it("keeps manifest and plugin entry metadata aligned", () => {
const manifest = JSON.parse(readFileSync(resolve(process.cwd(), "manifest.json"), "utf8")) as {
id: string;
@@ -41,6 +51,32 @@ describe("roadmap-planner package surface", () => {
expect(plugin.manifest.id).toBe("roadmap-planner");
});
it("registers onSchemaInit hook that creates roadmap tables and indexes", () => {
const tmpDir = mkdtempSync(join(tmpdir(), "roadmap-plugin-schema-test-"));
tmpDirs.push(tmpDir);
const db = new Database(join(tmpDir, ".fusion"), { inMemory: true });
db.init();
expect(plugin.hooks?.onSchemaInit).toBeTypeOf("function");
plugin.hooks?.onSchemaInit?.(db);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as Array<{ name: string }>;
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type = 'index'").all() as Array<{ name: string }>;
expect(tables.map((row) => row.name)).toEqual(expect.arrayContaining([
"roadmaps",
"roadmap_milestones",
"roadmap_features",
]));
expect(indexes.map((row) => row.name)).toEqual(expect.arrayContaining([
"idxRoadmapMilestonesRoadmapOrder",
"idxRoadmapFeaturesMilestoneOrder",
]));
db.close();
});
it("re-exports roadmap domain symbols", () => {
expect(typeof normalizeRoadmapMilestoneOrder).toBe("function");
expect(typeof applyRoadmapMilestoneReorder).toBe("function");

View File

@@ -1,46 +1,6 @@
import type { Database } from "@fusion/core";
import { definePlugin } from "@fusion/plugin-sdk";
import { createRoadmapPluginRoutes } from "./routes/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);
`);
}
import { ensureRoadmapSchema } from "./roadmap-schema.js";
const plugin = definePlugin({
manifest: {
@@ -109,5 +69,6 @@ export {
export { RoadmapStore } from "./store/roadmap-store.js";
export type { RoadmapStoreEvents } from "./store/roadmap-store.js";
export { ensureRoadmapSchema } from "./roadmap-schema.js";
export { RoadmapDashboardView } from "./dashboard-view.js";
export * from "./server/index.js";

View File

@@ -0,0 +1,41 @@
import type { Database } from "@fusion/core";
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);
`);
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Database, createDatabase } from "@fusion/core";
import { RoadmapStore } from "../roadmap-store.js";
import { ensureRoadmapSchema } from "../../roadmap-schema.js";
import type {
RoadmapCreateInput,
RoadmapUpdateInput,
@@ -33,6 +34,7 @@ describe("RoadmapStore", () => {
// Database instances explicitly (search for `persistDb`).
db = new Database(join(tmpDir, ".fusion"), { inMemory: true });
db.init();
ensureRoadmapSchema(db);
store = new RoadmapStore(db);
});