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 a4550ff4f6
commit a3299f72b0
6 changed files with 88 additions and 44 deletions

View File

@@ -321,6 +321,7 @@ const plugin: FusionPlugin = {
- **Schema hook execution**: `onSchemaInit` hooks run sequentially in plugin dependency order (from `resolveLoadOrder`) after `loadAllPlugins()`. - **Schema hook execution**: `onSchemaInit` hooks run sequentially in plugin dependency order (from `resolveLoadOrder`) after `loadAllPlugins()`.
- **Schema hook database API**: The hook receives the runtime `Database` instance, including `db.exec()` and `db.prepare()` for SQL DDL. - **Schema hook database API**: The hook receives the runtime `Database` instance, including `db.exec()` and `db.prepare()` for SQL DDL.
- **Schema hook constraints**: `onSchemaInit` is intended for idempotent DDL only (`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`). Avoid data backfills or long-running logic. - **Schema hook constraints**: `onSchemaInit` is intended for idempotent DDL only (`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`). Avoid data backfills or long-running logic.
- **Bundled plugin pattern**: Keep DDL in a plugin-local schema module (for example `src/<plugin>-schema.ts`) and call it from `hooks.onSchemaInit` so schema ownership stays with the plugin package instead of `@fusion/core` bootstrap SQL.
### Example: Schema initialization hook ### Example: Schema initialization hook

View File

@@ -12,6 +12,7 @@
- `manifest.json` — plugin metadata and dashboard view declaration - `manifest.json` — plugin metadata and dashboard view declaration
- `src/index.ts` — plugin definition (`onSchemaInit`, routes, dashboard view metadata) - `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/server/index.ts` — backend server exports
- `src/dashboard-view.tsx` — dashboard view entry export for host registration - `src/dashboard-view.tsx` — dashboard view entry export for host registration
- `src/dashboard/RoadmapsView.tsx` — plugin-owned roadmap planner page - `src/dashboard/RoadmapsView.tsx` — plugin-owned roadmap planner page
@@ -28,4 +29,6 @@
## Notes ## 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. 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 { mkdtempSync, readFileSync } from "node:fs";
import { resolve } from "node:path"; import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest"; 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, { import plugin, {
RoadmapStore, RoadmapStore,
applyRoadmapFeatureReorder, applyRoadmapFeatureReorder,
@@ -15,6 +18,13 @@ import plugin, {
} from "../index.js"; } from "../index.js";
describe("roadmap-planner package surface", () => { 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", () => { it("keeps manifest and plugin entry metadata aligned", () => {
const manifest = JSON.parse(readFileSync(resolve(process.cwd(), "manifest.json"), "utf8")) as { const manifest = JSON.parse(readFileSync(resolve(process.cwd(), "manifest.json"), "utf8")) as {
id: string; id: string;
@@ -41,6 +51,32 @@ describe("roadmap-planner package surface", () => {
expect(plugin.manifest.id).toBe("roadmap-planner"); 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", () => { it("re-exports roadmap domain symbols", () => {
expect(typeof normalizeRoadmapMilestoneOrder).toBe("function"); expect(typeof normalizeRoadmapMilestoneOrder).toBe("function");
expect(typeof applyRoadmapMilestoneReorder).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 { definePlugin } from "@fusion/plugin-sdk";
import { createRoadmapPluginRoutes } from "./routes/roadmap-routes.js"; import { createRoadmapPluginRoutes } from "./routes/roadmap-routes.js";
import { ensureRoadmapSchema } from "./roadmap-schema.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({ const plugin = definePlugin({
manifest: { manifest: {
@@ -109,5 +69,6 @@ export {
export { RoadmapStore } from "./store/roadmap-store.js"; export { RoadmapStore } from "./store/roadmap-store.js";
export type { RoadmapStoreEvents } 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 { RoadmapDashboardView } from "./dashboard-view.js";
export * from "./server/index.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 { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Database, createDatabase } from "@fusion/core"; import { Database, createDatabase } from "@fusion/core";
import { RoadmapStore } from "../roadmap-store.js"; import { RoadmapStore } from "../roadmap-store.js";
import { ensureRoadmapSchema } from "../../roadmap-schema.js";
import type { import type {
RoadmapCreateInput, RoadmapCreateInput,
RoadmapUpdateInput, RoadmapUpdateInput,
@@ -33,6 +34,7 @@ describe("RoadmapStore", () => {
// Database instances explicitly (search for `persistDb`). // Database instances explicitly (search for `persistDb`).
db = new Database(join(tmpDir, ".fusion"), { inMemory: true }); db = new Database(join(tmpDir, ".fusion"), { inMemory: true });
db.init(); db.init();
ensureRoadmapSchema(db);
store = new RoadmapStore(db); store = new RoadmapStore(db);
}); });