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:
gsxdsm
2026-06-02 14:41:41 -07:00
parent 8d9bbc1906
commit 30a09e3422
18 changed files with 391 additions and 41 deletions

View File

@@ -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();
});

View File

@@ -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 }>;

View File

@@ -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);
});
});

View File

@@ -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

View File

@@ -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 () => {

View 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([]);
});
});

View File

@@ -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", () => {

View File

@@ -584,7 +584,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(100);
expect(db.getSchemaVersion()).toBe(101);
});
});
});

View File

@@ -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();

View File

@@ -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 () => {

View File

@@ -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(

View File

@@ -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)
`);
});
}
}
/**

View File

@@ -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 ───────────────────────────────────────────
/**

View File

@@ -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;