FN-5896: persist mission-goal linkage in schema
Persist mission↔goal relationships through the core mission schema and store APIs. - add the mission_goals join table, schema migration coverage, and schema version bump to 101 - add MissionStore goal link/unlink/list helpers plus a MissionGoalLink type and emitted linkage events - add regression tests for persistence, idempotency, ordering, and cascade behavior, and document the new linkage model with a published changeset Files changed: .changeset/fn-5896-mission-goal-linkage.md | 5 + docs/missions.md | 19 +++ docs/storage.md | 1 + packages/core/src/__tests__/db-migrate.test.ts | 49 ++++++- packages/core/src/__tests__/db.test.ts | 34 ++--- packages/core/src/__tests__/goals-schema.test.ts | 4 +- packages/core/src/__tests__/insight-store.test.ts | 10 +- .../src/__tests__/merge-request-record.test.ts | 2 +- .../core/src/__tests__/mission-goals-link.test.ts | 142 +++++++++++++++++++++ packages/core/src/__tests__/mission-store.test.ts | 4 +- packages/core/src/__tests__/run-audit.test.ts | 2 +- packages/core/src/__tests__/secrets-schema.test.ts | 6 +- .../core/src/__tests__/store-merge-queue.test.ts | 2 +- packages/core/src/__tests__/task-documents.test.ts | 2 +- packages/core/src/db.ts | 31 ++++- packages/core/src/mission-store.ts | 111 ++++++++++++++++ packages/core/src/mission-types.ts | 6 + .../src/store/__tests__/roadmap-store.test.ts | 2 +- 18 files changed, 391 insertions(+), 41 deletions(-) Fusion-Task-Id: FN-5896 Fusion-Task-Lineage: a4c7c495-b20a-454b-9605-782671cdd8c8
This commit is contained in:
5
.changeset/fn-5896-mission-goal-linkage.md
Normal file
5
.changeset/fn-5896-mission-goal-linkage.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Persist mission↔goal many-to-many links with a new `mission_goals` join table, MissionStore link/unlink/list helpers, and a project schema version bump from 100 to 101.
|
||||
@@ -23,6 +23,25 @@ Mission: Improve Reliability
|
||||
Task: FN-214
|
||||
```
|
||||
|
||||
## Mission ↔ Goal persistence
|
||||
|
||||
Missions and goals are stored independently, but Fusion now persists an optional many-to-many linkage in the `mission_goals` join table.
|
||||
|
||||
- Columns: `missionId`, `goalId`, `createdAt`
|
||||
- Primary key: `(missionId, goalId)`
|
||||
- Foreign keys: `missionId → missions.id`, `goalId → goals.id`
|
||||
- Delete behavior: both foreign keys use `ON DELETE CASCADE`, so removing either parent deletes only the corresponding join rows
|
||||
- Reverse lookups are indexed via `idxMissionGoalsGoalId`
|
||||
|
||||
`MissionStore` owns the linkage CRUD surface:
|
||||
|
||||
- `linkGoal(missionId, goalId)` — idempotently create a link and return `{ missionId, goalId, createdAt }`
|
||||
- `unlinkGoal(missionId, goalId)` — remove a link and report whether anything changed
|
||||
- `listGoalIdsForMission(missionId)` — list linked goals in deterministic creation order
|
||||
- `listMissionIdsForGoal(goalId)` — list linked missions in deterministic creation order
|
||||
|
||||
Existing missions are **not** backfilled with goal links as part of this schema change; that decision is deferred to FN-5898.
|
||||
|
||||
## Creating Missions
|
||||
|
||||
### Mission base branch defaults
|
||||
|
||||
@@ -342,6 +342,7 @@ The `tasks.githubTracking` JSON column stores per-task GitHub tracking state (`e
|
||||
| `task_document_revisions` | Immutable revision history for task documents (content snapshots by revision). |
|
||||
| `__meta` | Schema version + monotonic `lastModified` change detector, plus one-time bootstrap metadata such as `bootstrappedAt` and `projectIdentity`. |
|
||||
| `goals` | Strategic intent records (`title`, optional `description`, `status`, timestamps) that can outlive mission timelines. |
|
||||
| `mission_goals` | Many-to-many join between missions and goals with composite PK `(missionId, goalId)`, `createdAt`, and cascade-delete foreign keys to both parents. |
|
||||
| `missions` | Mission-level planning hierarchy root. |
|
||||
| `milestones` | Milestones under missions, including dependency lists and validation state. |
|
||||
| `slices` | Slices under milestones with plan-state/activation metadata. |
|
||||
|
||||
@@ -715,7 +715,7 @@ describe("schema migration", () => {
|
||||
|
||||
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
|
||||
expect(row.deletedAt).toBeNull();
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -748,7 +748,7 @@ describe("schema migration", () => {
|
||||
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
|
||||
{ id: "WS-002", mode: "script", gateMode: "advisory" },
|
||||
]);
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -798,7 +798,7 @@ describe("schema migration", () => {
|
||||
reviewerContextRetryCount: 0,
|
||||
reviewerFallbackRetryCount: 0,
|
||||
});
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -827,7 +827,7 @@ describe("schema migration", () => {
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -868,7 +868,7 @@ describe("schema migration", () => {
|
||||
const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>;
|
||||
expect(missionColumns.map((column) => column.name)).toContain("autoMerge");
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -902,7 +902,44 @@ describe("schema migration", () => {
|
||||
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
|
||||
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
|
||||
]);
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("adds mission_goals table and index when migrating from schema version 100", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '100')");
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS missions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
interviewState TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS goals (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
db.init();
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(mission_goals)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toEqual(["missionId", "goalId", "createdAt"]);
|
||||
|
||||
const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>;
|
||||
expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -335,7 +335,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
});
|
||||
|
||||
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
|
||||
@@ -394,7 +394,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
});
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
// Update the config
|
||||
@@ -1464,7 +1464,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1489,11 +1489,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1528,7 +1528,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("priority");
|
||||
@@ -1569,7 +1569,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1641,7 +1641,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1881,7 +1881,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||
@@ -1955,7 +1955,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -1979,7 +1979,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -2083,7 +2083,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -2302,7 +2302,7 @@ describe("schema migrations", () => {
|
||||
|
||||
localDb.init();
|
||||
|
||||
expect(localDb.getSchemaVersion()).toBe(100);
|
||||
expect(localDb.getSchemaVersion()).toBe(101);
|
||||
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
|
||||
|
||||
@@ -2613,7 +2613,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
@@ -2767,7 +2767,7 @@ describe("migration v77 task token budget columns", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(100);
|
||||
expect(migrated.getSchemaVersion()).toBe(101);
|
||||
const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const names = new Set(rows.map((row) => row.name));
|
||||
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
|
||||
@@ -2813,7 +2813,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(100);
|
||||
expect(migrated.getSchemaVersion()).toBe(101);
|
||||
const tables = migrated
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
@@ -2840,7 +2840,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(100);
|
||||
expect(fresh.getSchemaVersion()).toBe(101);
|
||||
const tables = fresh
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("goals schema", () => {
|
||||
expect(table?.name).toBe("goals");
|
||||
});
|
||||
|
||||
it("reports schema version 92", () => {
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
it("reports schema version 101", () => {
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
||||
const db1 = createDatabase(legacyDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(100);
|
||||
expect(db1.getSchemaVersion()).toBe(101);
|
||||
db1.close();
|
||||
|
||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||
@@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
||||
// Now run init — this triggers the v32→v33 migration
|
||||
db3.init();
|
||||
expect(db3.getSchemaVersion()).toBe(100);
|
||||
expect(db3.getSchemaVersion()).toBe(101);
|
||||
|
||||
// Step 4: Verify insight tables exist after migration
|
||||
const tablesAfter = db3.prepare(
|
||||
@@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
try {
|
||||
const db1 = createDatabase(testDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(100);
|
||||
expect(db1.getSchemaVersion()).toBe(101);
|
||||
db1.close();
|
||||
|
||||
const db2 = createDatabase(testDir);
|
||||
expect(() => db2.init()).not.toThrow();
|
||||
expect(db2.getSchemaVersion()).toBe(100);
|
||||
expect(db2.getSchemaVersion()).toBe(101);
|
||||
db2.close();
|
||||
} finally {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
@@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh DB and run migrations
|
||||
const db1 = createDatabase(compatDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(100);
|
||||
expect(db1.getSchemaVersion()).toBe(101);
|
||||
|
||||
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
|
||||
// table without them. This simulates a DB that was created before the
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => {
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]);
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
});
|
||||
|
||||
it("upserts merge request records", async () => {
|
||||
|
||||
142
packages/core/src/__tests__/mission-goals-link.test.ts
Normal file
142
packages/core/src/__tests__/mission-goals-link.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { Database } from "../db.js";
|
||||
import { GoalStore } from "../goal-store.js";
|
||||
import { MissionStore } from "../mission-store.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-mission-goals-test-"));
|
||||
}
|
||||
|
||||
describe("MissionStore mission-goal linkage", () => {
|
||||
let tmpDir: string;
|
||||
let fusionDir: string;
|
||||
let db: Database;
|
||||
let missionStore: MissionStore;
|
||||
let goalStore: GoalStore;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir();
|
||||
fusionDir = join(tmpDir, ".fusion");
|
||||
db = new Database(fusionDir, { inMemory: true });
|
||||
db.init();
|
||||
missionStore = new MissionStore(fusionDir, db);
|
||||
goalStore = new GoalStore(fusionDir, db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("links a mission to a goal and persists the row", () => {
|
||||
const mission = missionStore.createMission({ title: "Mission Alpha" });
|
||||
const goal = goalStore.createGoal({ title: "Goal Alpha" });
|
||||
const onLinked = vi.fn();
|
||||
missionStore.on("mission:goal-linked", onLinked);
|
||||
|
||||
const link = missionStore.linkGoal(mission.id, goal.id);
|
||||
|
||||
expect(link).toMatchObject({ missionId: mission.id, goalId: goal.id });
|
||||
expect(link.createdAt).toBeTruthy();
|
||||
expect(missionStore.listGoalIdsForMission(mission.id)).toEqual([goal.id]);
|
||||
expect(missionStore.listMissionIdsForGoal(goal.id)).toEqual([mission.id]);
|
||||
expect(onLinked).toHaveBeenCalledTimes(1);
|
||||
expect(onLinked).toHaveBeenCalledWith(link);
|
||||
|
||||
const row = db
|
||||
.prepare("SELECT missionId, goalId, createdAt FROM mission_goals WHERE missionId = ? AND goalId = ?")
|
||||
.get(mission.id, goal.id) as { missionId: string; goalId: string; createdAt: string } | undefined;
|
||||
expect(row).toEqual(link);
|
||||
});
|
||||
|
||||
it("re-linking the same mission and goal is idempotent", () => {
|
||||
const mission = missionStore.createMission({ title: "Mission Alpha" });
|
||||
const goal = goalStore.createGoal({ title: "Goal Alpha" });
|
||||
const onLinked = vi.fn();
|
||||
missionStore.on("mission:goal-linked", onLinked);
|
||||
|
||||
const first = missionStore.linkGoal(mission.id, goal.id);
|
||||
const second = missionStore.linkGoal(mission.id, goal.id);
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(onLinked).toHaveBeenCalledTimes(1);
|
||||
const countRow = db
|
||||
.prepare("SELECT COUNT(*) as count FROM mission_goals WHERE missionId = ? AND goalId = ?")
|
||||
.get(mission.id, goal.id) as { count: number };
|
||||
expect(countRow.count).toBe(1);
|
||||
});
|
||||
|
||||
it("unlinks mission-goal pairs and reports whether a row changed", () => {
|
||||
const mission = missionStore.createMission({ title: "Mission Alpha" });
|
||||
const goal = goalStore.createGoal({ title: "Goal Alpha" });
|
||||
missionStore.linkGoal(mission.id, goal.id);
|
||||
const onUnlinked = vi.fn();
|
||||
missionStore.on("mission:goal-unlinked", onUnlinked);
|
||||
|
||||
expect(missionStore.unlinkGoal(mission.id, goal.id)).toBe(true);
|
||||
expect(missionStore.unlinkGoal(mission.id, goal.id)).toBe(false);
|
||||
expect(missionStore.listGoalIdsForMission(mission.id)).toEqual([]);
|
||||
expect(missionStore.listMissionIdsForGoal(goal.id)).toEqual([]);
|
||||
expect(onUnlinked).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lists mission and goal ids in deterministic createdAt order", () => {
|
||||
const missionA = missionStore.createMission({ title: "Mission A" });
|
||||
const missionB = missionStore.createMission({ title: "Mission B" });
|
||||
const goalA = goalStore.createGoal({ title: "Goal A" });
|
||||
const goalB = goalStore.createGoal({ title: "Goal B" });
|
||||
|
||||
db.prepare("INSERT INTO mission_goals (missionId, goalId, createdAt) VALUES (?, ?, ?)")
|
||||
.run(missionA.id, goalA.id, "2026-01-01T00:00:00.000Z");
|
||||
db.prepare("INSERT INTO mission_goals (missionId, goalId, createdAt) VALUES (?, ?, ?)")
|
||||
.run(missionA.id, goalB.id, "2026-01-02T00:00:00.000Z");
|
||||
db.prepare("INSERT INTO mission_goals (missionId, goalId, createdAt) VALUES (?, ?, ?)")
|
||||
.run(missionB.id, goalA.id, "2026-01-03T00:00:00.000Z");
|
||||
|
||||
expect(missionStore.listGoalIdsForMission(missionA.id)).toEqual([goalA.id, goalB.id]);
|
||||
expect(missionStore.listGoalIdsForMission(missionB.id)).toEqual([goalA.id]);
|
||||
expect(missionStore.listGoalIdsForMission("M-NONE")).toEqual([]);
|
||||
expect(missionStore.listMissionIdsForGoal(goalA.id)).toEqual([missionA.id, missionB.id]);
|
||||
expect(missionStore.listMissionIdsForGoal(goalB.id)).toEqual([missionA.id]);
|
||||
expect(missionStore.listMissionIdsForGoal("G-NONE")).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws when linking an unknown mission or goal", () => {
|
||||
const mission = missionStore.createMission({ title: "Mission Alpha" });
|
||||
const goal = goalStore.createGoal({ title: "Goal Alpha" });
|
||||
|
||||
expect(() => missionStore.linkGoal("M-UNKNOWN", goal.id)).toThrow("Mission M-UNKNOWN not found");
|
||||
expect(() => missionStore.linkGoal(mission.id, "G-UNKNOWN")).toThrow("Goal G-UNKNOWN not found");
|
||||
});
|
||||
|
||||
it("cascades mission_goals rows when a goal or mission is deleted", () => {
|
||||
const missionA = missionStore.createMission({ title: "Mission A" });
|
||||
const missionB = missionStore.createMission({ title: "Mission B" });
|
||||
const goalA = goalStore.createGoal({ title: "Goal A" });
|
||||
const goalB = goalStore.createGoal({ title: "Goal B" });
|
||||
|
||||
missionStore.linkGoal(missionA.id, goalA.id);
|
||||
missionStore.linkGoal(missionA.id, goalB.id);
|
||||
missionStore.linkGoal(missionB.id, goalA.id);
|
||||
|
||||
db.prepare("DELETE FROM goals WHERE id = ?").run(goalA.id);
|
||||
expect(missionStore.listGoalIdsForMission(missionA.id)).toEqual([goalB.id]);
|
||||
expect(missionStore.listMissionIdsForGoal(goalA.id)).toEqual([]);
|
||||
|
||||
missionStore.deleteMission(missionA.id);
|
||||
const remaining = db.prepare("SELECT missionId, goalId FROM mission_goals ORDER BY missionId, goalId").all() as Array<{
|
||||
missionId: string;
|
||||
goalId: string;
|
||||
}>;
|
||||
expect(remaining).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3495,8 +3495,8 @@ describe("MissionStore", () => {
|
||||
// ── Loop State & Validator Run Schema Tests ───────────────────────────
|
||||
|
||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||
it("schema version is 40 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
it("schema version is 101 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
});
|
||||
|
||||
it("mission_features table has loop state columns", () => {
|
||||
|
||||
@@ -584,7 +584,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("secrets schema migrations", () => {
|
||||
const version = db
|
||||
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
|
||||
.get() as { value: string };
|
||||
expect(version.value).toBe("100");
|
||||
expect(version.value).toBe("101");
|
||||
} finally {
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -105,7 +105,7 @@ describe("secrets schema migrations", () => {
|
||||
const version = db
|
||||
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
|
||||
.get() as { value: string };
|
||||
expect(version.value).toBe("100");
|
||||
expect(version.value).toBe("101");
|
||||
} finally {
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -155,7 +155,7 @@ describe("secrets schema migrations", () => {
|
||||
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
|
||||
.get() as { value: string };
|
||||
|
||||
expect(projectVersion.value).toBe("100");
|
||||
expect(projectVersion.value).toBe("101");
|
||||
expect(centralVersion.value).toBe("13");
|
||||
} finally {
|
||||
projectDb.close();
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => {
|
||||
expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]),
|
||||
);
|
||||
|
||||
expect(store.getDatabase().getSchemaVersion()).toBe(100);
|
||||
expect(store.getDatabase().getSchemaVersion()).toBe(101);
|
||||
});
|
||||
|
||||
it("migrates a legacy v88 database and preserves task rows", async () => {
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
@@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 100;
|
||||
const SCHEMA_VERSION = 101;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -814,6 +814,16 @@ CREATE TABLE IF NOT EXISTS goals (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idxGoalsStatus ON goals(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mission_goals (
|
||||
missionId TEXT NOT NULL,
|
||||
goalId TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
PRIMARY KEY (missionId, goalId),
|
||||
FOREIGN KEY (missionId) REFERENCES missions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (goalId) REFERENCES goals(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idxMissionGoalsGoalId ON mission_goals(goalId);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS goal_citations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
goalId TEXT NOT NULL,
|
||||
@@ -3987,6 +3997,25 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 101) {
|
||||
this.applyMigration(101, () => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mission_goals (
|
||||
missionId TEXT NOT NULL,
|
||||
goalId TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
PRIMARY KEY (missionId, goalId),
|
||||
FOREIGN KEY (missionId) REFERENCES missions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (goalId) REFERENCES goals(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idxMissionGoalsGoalId
|
||||
ON mission_goals(goalId)
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
SlicePlanState,
|
||||
MissionContractAssertion,
|
||||
FeatureAssertionLink,
|
||||
MissionGoalLink,
|
||||
FixFeatureCreatedPayload,
|
||||
MilestoneValidationRollup,
|
||||
ContractAssertionCreateInput,
|
||||
@@ -167,6 +168,10 @@ export interface MissionStoreEvents {
|
||||
"mission:updated": [Mission];
|
||||
/** Emitted when a mission is deleted */
|
||||
"mission:deleted": [string];
|
||||
/** Emitted when a goal is linked to a mission */
|
||||
"mission:goal-linked": [MissionGoalLink];
|
||||
/** Emitted when a goal is unlinked from a mission */
|
||||
"mission:goal-unlinked": [MissionGoalLink];
|
||||
/** Emitted when a milestone is created */
|
||||
"milestone:created": [Milestone];
|
||||
/** Emitted when a milestone is updated */
|
||||
@@ -249,6 +254,13 @@ interface MilestoneRow {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Database row shape for the mission_contract_assertions table. */
|
||||
interface MissionGoalRow {
|
||||
missionId: string;
|
||||
goalId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Database row shape for the mission_contract_assertions table. */
|
||||
interface AssertionRow {
|
||||
id: string;
|
||||
@@ -439,6 +451,17 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a database row to a MissionGoalLink object.
|
||||
*/
|
||||
private rowToMissionGoalLink(row: MissionGoalRow): MissionGoalLink {
|
||||
return {
|
||||
missionId: row.missionId,
|
||||
goalId: row.goalId,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a database row to a MissionContractAssertion object.
|
||||
*/
|
||||
@@ -1233,6 +1256,94 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
return this.updateMission(id, { interviewState: state });
|
||||
}
|
||||
|
||||
linkGoal(missionId: string, goalId: string): MissionGoalLink {
|
||||
const result = this.db.transactionImmediate(() => {
|
||||
const missionExists = this.db
|
||||
.prepare("SELECT id FROM missions WHERE id = ?")
|
||||
.get(missionId) as { id: string } | undefined;
|
||||
if (!missionExists) {
|
||||
throw new Error(`Mission ${missionId} not found`);
|
||||
}
|
||||
|
||||
const goalExists = this.db
|
||||
.prepare("SELECT id FROM goals WHERE id = ?")
|
||||
.get(goalId) as { id: string } | undefined;
|
||||
if (!goalExists) {
|
||||
throw new Error(`Goal ${goalId} not found`);
|
||||
}
|
||||
|
||||
const existing = this.db
|
||||
.prepare("SELECT missionId, goalId, createdAt FROM mission_goals WHERE missionId = ? AND goalId = ?")
|
||||
.get(missionId, goalId) as MissionGoalRow | undefined;
|
||||
if (existing) {
|
||||
return { link: this.rowToMissionGoalLink(existing), changed: false };
|
||||
}
|
||||
|
||||
const createdAt = new Date().toISOString();
|
||||
this.db
|
||||
.prepare("INSERT OR IGNORE INTO mission_goals (missionId, goalId, createdAt) VALUES (?, ?, ?)")
|
||||
.run(missionId, goalId, createdAt);
|
||||
|
||||
const row = this.db
|
||||
.prepare("SELECT missionId, goalId, createdAt FROM mission_goals WHERE missionId = ? AND goalId = ?")
|
||||
.get(missionId, goalId) as MissionGoalRow | undefined;
|
||||
if (!row) {
|
||||
throw new Error(`Failed to link mission ${missionId} to goal ${goalId}`);
|
||||
}
|
||||
|
||||
return { link: this.rowToMissionGoalLink(row), changed: true };
|
||||
});
|
||||
|
||||
if (result.changed) {
|
||||
this.db.bumpLastModified();
|
||||
this.emit("mission:goal-linked", result.link);
|
||||
}
|
||||
|
||||
return result.link;
|
||||
}
|
||||
|
||||
unlinkGoal(missionId: string, goalId: string): boolean {
|
||||
const deleted = this.db.transactionImmediate(() => {
|
||||
const row = this.db
|
||||
.prepare("SELECT missionId, goalId, createdAt FROM mission_goals WHERE missionId = ? AND goalId = ?")
|
||||
.get(missionId, goalId) as MissionGoalRow | undefined;
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = this.db
|
||||
.prepare("DELETE FROM mission_goals WHERE missionId = ? AND goalId = ?")
|
||||
.run(missionId, goalId);
|
||||
if (result.changes < 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.rowToMissionGoalLink(row);
|
||||
});
|
||||
|
||||
if (!deleted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("mission:goal-unlinked", deleted);
|
||||
return true;
|
||||
}
|
||||
|
||||
listGoalIdsForMission(missionId: string): string[] {
|
||||
const rows = this.db
|
||||
.prepare("SELECT goalId FROM mission_goals WHERE missionId = ? ORDER BY createdAt ASC, goalId ASC")
|
||||
.all(missionId) as Array<{ goalId: string }>;
|
||||
return rows.map((row) => row.goalId);
|
||||
}
|
||||
|
||||
listMissionIdsForGoal(goalId: string): string[] {
|
||||
const rows = this.db
|
||||
.prepare("SELECT missionId FROM mission_goals WHERE goalId = ? ORDER BY createdAt ASC, missionId ASC")
|
||||
.all(goalId) as Array<{ missionId: string }>;
|
||||
return rows.map((row) => row.missionId);
|
||||
}
|
||||
|
||||
// ── Milestone Operations ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -118,6 +118,12 @@ export type MissionBranchStrategy = {
|
||||
branchName?: string;
|
||||
};
|
||||
|
||||
export interface MissionGoalLink {
|
||||
missionId: string;
|
||||
goalId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Mission {
|
||||
/** Unique identifier (e.g., "M-LZ7DN0-A2B5") */
|
||||
id: string;
|
||||
|
||||
@@ -744,7 +744,7 @@ describe("RoadmapStore", () => {
|
||||
|
||||
describe("schema version", () => {
|
||||
it("schema version is 100 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(100);
|
||||
expect(db.getSchemaVersion()).toBe(101);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user