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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
41
plugins/fusion-plugin-roadmap/src/roadmap-schema.ts
Normal file
41
plugins/fusion-plugin-roadmap/src/roadmap-schema.ts
Normal 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);
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user