docs(FN-1732): remove changeset for docs-only task
This commit is contained in:
@@ -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(32);
|
||||
expect(db.getSchemaVersion()).toBe(33);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(32);
|
||||
expect(db.getSchemaVersion()).toBe(33);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -142,7 +142,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(32);
|
||||
expect(db.getSchemaVersion()).toBe(33);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -749,7 +749,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(32);
|
||||
expect(db.getSchemaVersion()).toBe(33);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -774,11 +774,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(32);
|
||||
expect(db.getSchemaVersion()).toBe(33);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(32);
|
||||
expect(db.getSchemaVersion()).toBe(33);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -794,7 +794,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(32);
|
||||
expect(db.getSchemaVersion()).toBe(33);
|
||||
|
||||
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" }]);
|
||||
@@ -818,7 +818,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(32);
|
||||
expect(db.getSchemaVersion()).toBe(33);
|
||||
|
||||
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" }]);
|
||||
@@ -922,7 +922,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(32);
|
||||
expect(db.getSchemaVersion()).toBe(33);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1288,7 +1288,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(32);
|
||||
expect(db.getSchemaVersion()).toBe(33);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 32;
|
||||
const SCHEMA_VERSION = 33;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -1236,6 +1236,72 @@ export class Database {
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
// Insight persistence tables (FN-1877)
|
||||
// Normalized insight entities and insight-generation run records
|
||||
if (version < 33) {
|
||||
this.applyMigration(33, () => {
|
||||
// project_insights: normalized insight entities
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS project_insights (
|
||||
id TEXT PRIMARY KEY,
|
||||
projectId TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT,
|
||||
category TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
provenance TEXT,
|
||||
lastRunId TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// project_insight_runs: insight-generation run records
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS project_insight_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
projectId TEXT NOT NULL,
|
||||
trigger TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
error TEXT,
|
||||
insightsCreated INTEGER NOT NULL DEFAULT 0,
|
||||
insightsUpdated INTEGER NOT NULL DEFAULT 0,
|
||||
inputMetadata TEXT,
|
||||
outputMetadata TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
startedAt TEXT,
|
||||
completedAt TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Index for filtering insights by projectId
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idxProjectInsightsProjectId
|
||||
ON project_insights(projectId)
|
||||
`);
|
||||
|
||||
// Index for fingerprint-based upsert dedupe
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idxProjectInsightsFingerprint
|
||||
ON project_insights(projectId, fingerprint)
|
||||
`);
|
||||
|
||||
// Index for filtering insights by category
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idxProjectInsightsCategory
|
||||
ON project_insights(category)
|
||||
`);
|
||||
|
||||
// Index for filtering runs by projectId
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idxInsightRunsProjectId
|
||||
ON project_insight_runs(projectId)
|
||||
`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
856
packages/core/src/insight-store.test.ts
Normal file
856
packages/core/src/insight-store.test.ts
Normal file
@@ -0,0 +1,856 @@
|
||||
/**
|
||||
* InsightStore Tests
|
||||
*
|
||||
* Covers:
|
||||
* - Insight create/get/list/update/delete/upsert lifecycle
|
||||
* - Insight run create/list/update/upsert lifecycle
|
||||
* - Fingerprint-based upsert dedupe (no duplicate rows)
|
||||
* - Stable identity on upsert (id/createdAt preserved)
|
||||
* - Deterministic ordering under timestamp ties
|
||||
* - Migration: pre-33 DB upgrades to include insight tables
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Database, createDatabase, fromJson } from "./db.js";
|
||||
import { InsightStore, computeInsightFingerprint } from "./insight-store.js";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type {
|
||||
Insight,
|
||||
InsightRun,
|
||||
InsightCategory,
|
||||
InsightStatus,
|
||||
InsightProvenance,
|
||||
InsightRunTrigger,
|
||||
InsightRunStatus,
|
||||
} from "./insight-types.js";
|
||||
|
||||
// ── Test Fixtures ────────────────────────────────────────────────────
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "fn-insight-test-"));
|
||||
}
|
||||
|
||||
let kbDir: string;
|
||||
let db: Database;
|
||||
let store: InsightStore;
|
||||
|
||||
function createProvenance(overrides: Partial<InsightProvenance> = {}): InsightProvenance {
|
||||
return {
|
||||
trigger: "manual",
|
||||
description: "Test generation",
|
||||
relatedEntityIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
kbDir = makeTmpDir();
|
||||
db = createDatabase(kbDir);
|
||||
db.init();
|
||||
store = new InsightStore(db);
|
||||
});
|
||||
|
||||
// ── Insight CRUD ────────────────────────────────────────────────────
|
||||
|
||||
describe("InsightStore", () => {
|
||||
describe("createInsight", () => {
|
||||
it("creates an insight and returns it with assigned id and timestamps", () => {
|
||||
const input = {
|
||||
title: "Test Insight",
|
||||
category: "quality" as InsightCategory,
|
||||
provenance: createProvenance(),
|
||||
};
|
||||
|
||||
const insight = store.createInsight("test-project", input);
|
||||
|
||||
expect(insight.id).toMatch(/^INS-[A-Z0-9]+-[A-Z0-9]+$/);
|
||||
expect(insight.projectId).toBe("test-project");
|
||||
expect(insight.title).toBe("Test Insight");
|
||||
expect(insight.content).toBeNull();
|
||||
expect(insight.category).toBe("quality");
|
||||
expect(insight.status).toBe("generated");
|
||||
expect(insight.fingerprint).toBeTruthy();
|
||||
expect(insight.lastRunId).toBeNull();
|
||||
expect(insight.createdAt).toBeTruthy();
|
||||
expect(insight.updatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("accepts optional content and custom status", () => {
|
||||
const input = {
|
||||
title: "Insight with content",
|
||||
content: "Detailed description",
|
||||
category: "performance" as InsightCategory,
|
||||
status: "confirmed" as InsightStatus,
|
||||
provenance: createProvenance(),
|
||||
};
|
||||
|
||||
const insight = store.createInsight("proj", input);
|
||||
|
||||
expect(insight.content).toBe("Detailed description");
|
||||
expect(insight.status).toBe("confirmed");
|
||||
});
|
||||
|
||||
it("uses provided fingerprint when given", () => {
|
||||
const input = {
|
||||
title: "Custom fingerprint",
|
||||
category: "security" as InsightCategory,
|
||||
provenance: createProvenance(),
|
||||
fingerprint: "my-custom-fingerprint",
|
||||
};
|
||||
|
||||
const insight = store.createInsight("proj", input);
|
||||
expect(insight.fingerprint).toBe("my-custom-fingerprint");
|
||||
});
|
||||
|
||||
it("persists insight to the database", () => {
|
||||
const insight = store.createInsight("proj", {
|
||||
title: "Persisted",
|
||||
category: "architecture",
|
||||
provenance: createProvenance(),
|
||||
});
|
||||
|
||||
const fromDb = store.getInsight(insight.id);
|
||||
expect(fromDb).toEqual(insight);
|
||||
});
|
||||
|
||||
it("emits insight:created event", () => {
|
||||
const handler = vi.fn();
|
||||
store.on("insight:created", handler);
|
||||
|
||||
const insight = store.createInsight("proj", {
|
||||
title: "Event test",
|
||||
category: "ux",
|
||||
provenance: createProvenance(),
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(handler).toHaveBeenCalledWith(insight);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInsight", () => {
|
||||
it("returns the insight when found", () => {
|
||||
const created = store.createInsight("proj", {
|
||||
title: "To get",
|
||||
category: "testability",
|
||||
provenance: createProvenance(),
|
||||
});
|
||||
|
||||
const found = store.getInsight(created.id);
|
||||
expect(found).toEqual(created);
|
||||
});
|
||||
|
||||
it("returns undefined when not found", () => {
|
||||
const found = store.getInsight("INS-NOTFOUND");
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listInsights", () => {
|
||||
it("returns all insights for a project", () => {
|
||||
store.createInsight("proj", { title: "A", category: "quality", provenance: createProvenance() });
|
||||
store.createInsight("proj", { title: "B", category: "performance", provenance: createProvenance() });
|
||||
store.createInsight("other", { title: "C", category: "architecture", provenance: createProvenance() });
|
||||
|
||||
const list = store.listInsights({ projectId: "proj" });
|
||||
expect(list).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters by category", () => {
|
||||
store.createInsight("proj", { title: "A", category: "quality", provenance: createProvenance() });
|
||||
store.createInsight("proj", { title: "B", category: "performance", provenance: createProvenance() });
|
||||
|
||||
const list = store.listInsights({ projectId: "proj", category: "quality" });
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].title).toBe("A");
|
||||
});
|
||||
|
||||
it("filters by status", () => {
|
||||
store.createInsight("proj", { title: "A", category: "quality", status: "confirmed", provenance: createProvenance() });
|
||||
store.createInsight("proj", { title: "B", category: "quality", status: "generated", provenance: createProvenance() });
|
||||
|
||||
const list = store.listInsights({ projectId: "proj", status: "confirmed" });
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].title).toBe("A");
|
||||
});
|
||||
|
||||
it("supports pagination with limit and offset", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
store.createInsight("proj", { title: `Insight ${i}`, category: "quality", provenance: createProvenance() });
|
||||
}
|
||||
|
||||
const page1 = store.listInsights({ projectId: "proj", limit: 3, offset: 0 });
|
||||
const page2 = store.listInsights({ projectId: "proj", limit: 3, offset: 3 });
|
||||
|
||||
expect(page1).toHaveLength(3);
|
||||
expect(page2).toHaveLength(3);
|
||||
expect(page1[0].id).not.toEqual(page2[0].id);
|
||||
});
|
||||
|
||||
it("is ordered ascending by createdAt, then id (deterministic)", () => {
|
||||
// Create insights with explicit timestamps 1s apart to ensure distinct timestamps
|
||||
const now = new Date();
|
||||
const insertedIds: string[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const ts = new Date(now.getTime() + i * 1000).toISOString();
|
||||
const id = `INS-LIST-${i}`;
|
||||
insertedIds.push(id);
|
||||
store.getDatabase().prepare(`
|
||||
INSERT INTO project_insights (id, projectId, title, content, category, status, fingerprint, provenance, lastRunId, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
"proj",
|
||||
`Insight ${i}`,
|
||||
null,
|
||||
"quality",
|
||||
"generated",
|
||||
`fp-list-${i}`,
|
||||
null,
|
||||
null,
|
||||
ts,
|
||||
ts,
|
||||
);
|
||||
}
|
||||
|
||||
const list = store.listInsights({ projectId: "proj" });
|
||||
expect(list.map((i) => i.id)).toEqual(insertedIds);
|
||||
// Verify ascending order by createdAt
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
expect(list[i - 1].createdAt < list[i].createdAt).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateInsight", () => {
|
||||
it("updates mutable fields", () => {
|
||||
const original = store.createInsight("proj", {
|
||||
title: "Original",
|
||||
category: "quality",
|
||||
provenance: createProvenance(),
|
||||
});
|
||||
|
||||
const updated = store.updateInsight(original.id, {
|
||||
title: "Updated Title",
|
||||
content: "Updated content",
|
||||
status: "confirmed",
|
||||
});
|
||||
|
||||
expect(updated!.title).toBe("Updated Title");
|
||||
expect(updated!.content).toBe("Updated content");
|
||||
expect(updated!.status).toBe("confirmed");
|
||||
expect(updated!.id).toBe(original.id);
|
||||
expect(updated!.createdAt).toBe(original.createdAt);
|
||||
// updatedAt should be >= original.createdAt (updated after creation)
|
||||
expect(updated!.updatedAt >= original.createdAt).toBe(true);
|
||||
});
|
||||
|
||||
it("returns undefined for non-existent insight", () => {
|
||||
const result = store.updateInsight("INS-NOTFOUND", { title: "X" });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits insight:updated event", () => {
|
||||
const handler = vi.fn();
|
||||
store.on("insight:updated", handler);
|
||||
|
||||
const insight = store.createInsight("proj", {
|
||||
title: "To update",
|
||||
category: "reliability",
|
||||
provenance: createProvenance(),
|
||||
});
|
||||
|
||||
store.updateInsight(insight.id, { status: "stale" });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0][0].status).toBe("stale");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteInsight", () => {
|
||||
it("deletes an existing insight", () => {
|
||||
const insight = store.createInsight("proj", {
|
||||
title: "To delete",
|
||||
category: "dependency",
|
||||
provenance: createProvenance(),
|
||||
});
|
||||
|
||||
const deleted = store.deleteInsight(insight.id);
|
||||
expect(deleted).toBe(true);
|
||||
expect(store.getInsight(insight.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns false for non-existent insight", () => {
|
||||
const deleted = store.deleteInsight("INS-NOTFOUND");
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("emits insight:deleted event", () => {
|
||||
const handler = vi.fn();
|
||||
store.on("insight:deleted", handler);
|
||||
|
||||
const insight = store.createInsight("proj", {
|
||||
title: "To delete",
|
||||
category: "documentation",
|
||||
provenance: createProvenance(),
|
||||
});
|
||||
|
||||
store.deleteInsight(insight.id);
|
||||
expect(handler).toHaveBeenCalledWith(insight.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertInsight (dedupe)", () => {
|
||||
it("creates a new insight when no fingerprint match exists", () => {
|
||||
const result = store.upsertInsight("proj", {
|
||||
title: "New insight",
|
||||
category: "architecture",
|
||||
provenance: createProvenance(),
|
||||
fingerprint: "new-fp",
|
||||
});
|
||||
|
||||
expect(result.id).toMatch(/^INS-/);
|
||||
expect(result.fingerprint).toBe("new-fp");
|
||||
expect(store.listInsights({ projectId: "proj" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("updates existing insight when fingerprint matches (no duplicate)", () => {
|
||||
// First upsert — creates
|
||||
const created = store.upsertInsight("proj", {
|
||||
title: "Original title",
|
||||
category: "quality",
|
||||
provenance: createProvenance(),
|
||||
fingerprint: "same-fp",
|
||||
});
|
||||
|
||||
const countBefore = store.listInsights({ projectId: "proj" }).length;
|
||||
expect(countBefore).toBe(1);
|
||||
|
||||
// Second upsert with same fingerprint — updates (no duplicate)
|
||||
const updated = store.upsertInsight("proj", {
|
||||
title: "Updated title",
|
||||
content: "Added content",
|
||||
category: "quality",
|
||||
provenance: createProvenance(),
|
||||
fingerprint: "same-fp",
|
||||
});
|
||||
|
||||
expect(updated.id).toBe(created.id); // Same id
|
||||
expect(updated.title).toBe("Updated title");
|
||||
expect(updated.content).toBe("Added content");
|
||||
expect(updated.createdAt).toBe(created.createdAt); // Original createdAt preserved
|
||||
|
||||
const countAfter = store.listInsights({ projectId: "proj" }).length;
|
||||
expect(countAfter).toBe(1); // No duplicate created
|
||||
});
|
||||
|
||||
it("preserves stable identity on upsert (id and createdAt unchanged)", () => {
|
||||
const first = store.upsertInsight("proj", {
|
||||
title: "Stable identity test",
|
||||
category: "workflow",
|
||||
provenance: createProvenance(),
|
||||
fingerprint: "stable-fp",
|
||||
});
|
||||
|
||||
const second = store.upsertInsight("proj", {
|
||||
title: "Updated title",
|
||||
category: "workflow",
|
||||
provenance: createProvenance({ trigger: "schedule" }),
|
||||
fingerprint: "stable-fp",
|
||||
});
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.createdAt).toBe(first.createdAt);
|
||||
// updatedAt should be >= first.createdAt (updated after first creation)
|
||||
expect(second.updatedAt >= first.createdAt).toBe(true);
|
||||
});
|
||||
|
||||
it("upserting different fingerprints creates separate insights", () => {
|
||||
store.upsertInsight("proj", {
|
||||
title: "Insight A",
|
||||
category: "quality",
|
||||
provenance: createProvenance(),
|
||||
fingerprint: "fp-a",
|
||||
});
|
||||
|
||||
store.upsertInsight("proj", {
|
||||
title: "Insight B",
|
||||
category: "quality",
|
||||
provenance: createProvenance(),
|
||||
fingerprint: "fp-b",
|
||||
});
|
||||
|
||||
const list = store.listInsights({ projectId: "proj" });
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list.map((i) => i.fingerprint)).toContain("fp-a");
|
||||
expect(list.map((i) => i.fingerprint)).toContain("fp-b");
|
||||
});
|
||||
|
||||
it("upserting same fingerprint in different projects creates separate insights", () => {
|
||||
store.upsertInsight("proj-a", {
|
||||
title: "Shared title",
|
||||
category: "performance",
|
||||
provenance: createProvenance(),
|
||||
fingerprint: "cross-project-fp",
|
||||
});
|
||||
|
||||
store.upsertInsight("proj-b", {
|
||||
title: "Shared title",
|
||||
category: "performance",
|
||||
provenance: createProvenance(),
|
||||
fingerprint: "cross-project-fp",
|
||||
});
|
||||
|
||||
const listA = store.listInsights({ projectId: "proj-a" });
|
||||
const listB = store.listInsights({ projectId: "proj-b" });
|
||||
|
||||
expect(listA).toHaveLength(1);
|
||||
expect(listB).toHaveLength(1);
|
||||
expect(listA[0].id).not.toEqual(listB[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countInsights", () => {
|
||||
it("counts all insights for a project", () => {
|
||||
store.createInsight("proj", { title: "A", category: "quality", provenance: createProvenance() });
|
||||
store.createInsight("proj", { title: "B", category: "performance", provenance: createProvenance() });
|
||||
store.createInsight("other", { title: "C", category: "architecture", provenance: createProvenance() });
|
||||
|
||||
expect(store.countInsights({ projectId: "proj" })).toBe(2);
|
||||
});
|
||||
|
||||
it("counts with filters", () => {
|
||||
store.createInsight("proj", { title: "A", category: "quality", status: "confirmed", provenance: createProvenance() });
|
||||
store.createInsight("proj", { title: "B", category: "quality", status: "generated", provenance: createProvenance() });
|
||||
|
||||
expect(store.countInsights({ projectId: "proj", category: "quality" })).toBe(2);
|
||||
expect(store.countInsights({ projectId: "proj", status: "confirmed" })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deterministic ordering", () => {
|
||||
it("ordering is stable across repeated reads", () => {
|
||||
// Create insights with explicit timestamps 1s apart to ensure distinct timestamps
|
||||
const now = new Date();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const ts = new Date(now.getTime() + i * 1000).toISOString();
|
||||
store.getDatabase().prepare(`
|
||||
INSERT INTO project_insights (id, projectId, title, content, category, status, fingerprint, provenance, lastRunId, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
`INS-STABLE-${i}`,
|
||||
"proj",
|
||||
`Insight ${i}`,
|
||||
null,
|
||||
"quality",
|
||||
"generated",
|
||||
`fp-stable-${i}`,
|
||||
null,
|
||||
null,
|
||||
ts,
|
||||
ts,
|
||||
);
|
||||
}
|
||||
|
||||
// Ordering is stable: same reads across multiple calls
|
||||
const read1 = store.listInsights({ projectId: "proj" }).map((i) => i.id);
|
||||
const read2 = store.listInsights({ projectId: "proj" }).map((i) => i.id);
|
||||
const read3 = store.listInsights({ projectId: "proj" }).map((i) => i.id);
|
||||
|
||||
expect(read1).toEqual(read2);
|
||||
expect(read2).toEqual(read3);
|
||||
// Verify the expected IDs are present
|
||||
expect(read1).toEqual([
|
||||
"INS-STABLE-0", "INS-STABLE-1", "INS-STABLE-2", "INS-STABLE-3", "INS-STABLE-4",
|
||||
"INS-STABLE-5", "INS-STABLE-6", "INS-STABLE-7", "INS-STABLE-8", "INS-STABLE-9",
|
||||
]);
|
||||
});
|
||||
|
||||
it("results are ascending (oldest first) by createdAt, then id", () => {
|
||||
// Create insights with explicit timestamps using SQL to avoid millisecond collisions
|
||||
const now = new Date();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const ts = new Date(now.getTime() + i * 1000).toISOString();
|
||||
store.getDatabase().prepare(`
|
||||
INSERT INTO project_insights (id, projectId, title, content, category, status, fingerprint, provenance, lastRunId, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
`INS-ORDER-${i}`,
|
||||
"proj",
|
||||
`Insight ${i}`,
|
||||
null,
|
||||
"quality",
|
||||
"generated",
|
||||
`fp-order-${i}`,
|
||||
null,
|
||||
null,
|
||||
ts,
|
||||
ts,
|
||||
);
|
||||
}
|
||||
|
||||
const list = store.listInsights({ projectId: "proj" });
|
||||
expect(list).toHaveLength(5);
|
||||
// Verify IDs match what we inserted (auto-incremented order 0..4)
|
||||
expect(list.map((i) => i.id)).toEqual([
|
||||
"INS-ORDER-0",
|
||||
"INS-ORDER-1",
|
||||
"INS-ORDER-2",
|
||||
"INS-ORDER-3",
|
||||
"INS-ORDER-4",
|
||||
]);
|
||||
// Verify ascending order by createdAt
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
expect(list[i - 1].createdAt < list[i].createdAt).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeInsightFingerprint", () => {
|
||||
it("produces consistent fingerprints for same input", () => {
|
||||
const fp1 = computeInsightFingerprint("Test Insight", "quality");
|
||||
const fp2 = computeInsightFingerprint("Test Insight", "quality");
|
||||
expect(fp1).toBe(fp2);
|
||||
});
|
||||
|
||||
it("produces consistent fingerprints regardless of case", () => {
|
||||
const fp1 = computeInsightFingerprint("Test Insight", "quality");
|
||||
const fp2 = computeInsightFingerprint("test insight", "quality");
|
||||
expect(fp1).toBe(fp2);
|
||||
});
|
||||
|
||||
it("different titles produce different fingerprints", () => {
|
||||
const fp1 = computeInsightFingerprint("Title A", "quality");
|
||||
const fp2 = computeInsightFingerprint("Title B", "quality");
|
||||
expect(fp1).not.toBe(fp2);
|
||||
});
|
||||
|
||||
it("different categories produce different fingerprints", () => {
|
||||
const fp1 = computeInsightFingerprint("Same Title", "quality");
|
||||
const fp2 = computeInsightFingerprint("Same Title", "performance");
|
||||
expect(fp1).not.toBe(fp2);
|
||||
});
|
||||
|
||||
it("trims whitespace before hashing", () => {
|
||||
const fp1 = computeInsightFingerprint(" Test ", "quality");
|
||||
const fp2 = computeInsightFingerprint("Test", "quality");
|
||||
expect(fp1).toBe(fp2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Insight Run CRUD ────────────────────────────────────────────────
|
||||
|
||||
describe("InsightStore Run CRUD", () => {
|
||||
describe("createRun", () => {
|
||||
it("creates a run with pending status", () => {
|
||||
const run = store.createRun("proj", { trigger: "manual" });
|
||||
|
||||
expect(run.id).toMatch(/^INSR-/);
|
||||
expect(run.projectId).toBe("proj");
|
||||
expect(run.trigger).toBe("manual");
|
||||
expect(run.status).toBe("pending");
|
||||
expect(run.insightsCreated).toBe(0);
|
||||
expect(run.insightsUpdated).toBe(0);
|
||||
expect(run.createdAt).toBeTruthy();
|
||||
expect(run.startedAt).toBeNull();
|
||||
expect(run.completedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("persists run to the database", () => {
|
||||
const created = store.createRun("proj", { trigger: "schedule" });
|
||||
const fromDb = store.getRun(created.id);
|
||||
expect(fromDb).toEqual(created);
|
||||
});
|
||||
|
||||
it("emits run:created event", () => {
|
||||
const handler = vi.fn();
|
||||
store.on("run:created", handler);
|
||||
|
||||
const run = store.createRun("proj", { trigger: "api" });
|
||||
expect(handler).toHaveBeenCalledWith(run);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRun", () => {
|
||||
it("returns run when found", () => {
|
||||
const created = store.createRun("proj", { trigger: "manual" });
|
||||
expect(store.getRun(created.id)).toEqual(created);
|
||||
});
|
||||
|
||||
it("returns undefined when not found", () => {
|
||||
expect(store.getRun("INSR-NOTFOUND")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listRuns", () => {
|
||||
it("returns runs for a project", () => {
|
||||
store.createRun("proj", { trigger: "manual" });
|
||||
store.createRun("proj", { trigger: "schedule" });
|
||||
store.createRun("other", { trigger: "manual" });
|
||||
|
||||
const list = store.listRuns({ projectId: "proj" });
|
||||
expect(list).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters by status", () => {
|
||||
store.createRun("proj", { trigger: "manual" }); // pending
|
||||
const running = store.createRun("proj", { trigger: "schedule" });
|
||||
store.updateRun(running.id, { status: "running" });
|
||||
|
||||
const pending = store.listRuns({ projectId: "proj", status: "pending" });
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].status).toBe("pending");
|
||||
});
|
||||
|
||||
it("filters by trigger", () => {
|
||||
store.createRun("proj", { trigger: "manual" });
|
||||
store.createRun("proj", { trigger: "schedule" });
|
||||
|
||||
const manual = store.listRuns({ projectId: "proj", trigger: "manual" });
|
||||
expect(manual).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("supports pagination", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
store.createRun("proj", { trigger: "manual" });
|
||||
}
|
||||
|
||||
const page1 = store.listRuns({ projectId: "proj", limit: 3, offset: 0 });
|
||||
expect(page1).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("is ordered descending by createdAt (newest first)", () => {
|
||||
// Create runs with explicit descending timestamps to ensure deterministic ordering
|
||||
const now = new Date();
|
||||
for (let i = 4; i >= 0; i--) {
|
||||
const ts = new Date(now.getTime() + i * 1000).toISOString();
|
||||
store.getDatabase().prepare(`
|
||||
INSERT INTO project_insight_runs (id, projectId, trigger, status, summary, error, insightsCreated, insightsUpdated, inputMetadata, outputMetadata, createdAt, startedAt, completedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
`INSR-ORDER-${i}`,
|
||||
"proj",
|
||||
"manual",
|
||||
"pending",
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
ts,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
const list = store.listRuns({ projectId: "proj" });
|
||||
expect(list).toHaveLength(5);
|
||||
// Descending by createdAt: newest first (ts=4, ts=3, ts=2, ts=1, ts=0)
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
const prev = list[i - 1];
|
||||
const curr = list[i];
|
||||
expect(prev.createdAt > curr.createdAt).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateRun", () => {
|
||||
it("updates mutable fields", () => {
|
||||
const run = store.createRun("proj", { trigger: "manual" });
|
||||
|
||||
const updated = store.updateRun(run.id, {
|
||||
status: "running",
|
||||
startedAt: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(updated!.status).toBe("running");
|
||||
expect(updated!.startedAt).toBe("2025-01-01T00:00:00.000Z");
|
||||
expect(updated!.id).toBe(run.id);
|
||||
});
|
||||
|
||||
it("auto-sets completedAt when transitioning to terminal state", () => {
|
||||
const run = store.createRun("proj", { trigger: "schedule" });
|
||||
|
||||
const updated = store.updateRun(run.id, {
|
||||
status: "completed",
|
||||
summary: "Done",
|
||||
insightsCreated: 5,
|
||||
insightsUpdated: 2,
|
||||
});
|
||||
|
||||
expect(updated!.status).toBe("completed");
|
||||
expect(updated!.completedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not override completedAt if already provided", () => {
|
||||
const run = store.createRun("proj", { trigger: "manual" });
|
||||
const fixed = "2025-06-01T12:00:00.000Z";
|
||||
|
||||
const updated = store.updateRun(run.id, {
|
||||
status: "failed",
|
||||
completedAt: fixed,
|
||||
error: "boom",
|
||||
});
|
||||
|
||||
expect(updated!.completedAt).toBe(fixed);
|
||||
});
|
||||
|
||||
it("returns undefined for non-existent run", () => {
|
||||
const result = store.updateRun("INSR-NOTFOUND", { status: "running" });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits run:updated event on status change", () => {
|
||||
const handler = vi.fn();
|
||||
store.on("run:updated", handler);
|
||||
|
||||
const run = store.createRun("proj", { trigger: "manual" });
|
||||
store.updateRun(run.id, { status: "running" });
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0][0].status).toBe("running");
|
||||
});
|
||||
|
||||
it("emits run:completed event when reaching terminal state", () => {
|
||||
const handler = vi.fn();
|
||||
store.on("run:completed", handler);
|
||||
|
||||
const run = store.createRun("proj", { trigger: "schedule" });
|
||||
store.updateRun(run.id, { status: "completed" });
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0][0].id).toBe(run.id);
|
||||
expect(handler.mock.calls[0][0].status).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertRun", () => {
|
||||
it("creates new run when no pending/running run exists", () => {
|
||||
const run = store.upsertRun("proj", "schedule", { trigger: "schedule" });
|
||||
expect(run.id).toMatch(/^INSR-/);
|
||||
expect(run.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("returns existing pending/running run instead of creating duplicate", () => {
|
||||
const first = store.createRun("proj", { trigger: "schedule" });
|
||||
|
||||
const second = store.upsertRun("proj", "schedule", { trigger: "schedule" });
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(store.listRuns({ projectId: "proj", trigger: "schedule" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("creates new run when existing run is terminal", () => {
|
||||
const first = store.createRun("proj", { trigger: "schedule" });
|
||||
store.updateRun(first.id, { status: "completed" });
|
||||
|
||||
const second = store.upsertRun("proj", "schedule", { trigger: "schedule" });
|
||||
|
||||
expect(second.id).not.toBe(first.id);
|
||||
expect(store.listRuns({ projectId: "proj" })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countRuns", () => {
|
||||
it("counts runs with optional filters", () => {
|
||||
store.createRun("proj", { trigger: "manual" });
|
||||
store.createRun("proj", { trigger: "schedule" });
|
||||
store.createRun("other", { trigger: "manual" });
|
||||
|
||||
expect(store.countRuns({ projectId: "proj" })).toBe(2);
|
||||
expect(store.countRuns({ projectId: "proj", trigger: "manual" })).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Migration Test ───────────────────────────────────────────────────
|
||||
|
||||
describe("Migration: pre-33 DB upgrade", () => {
|
||||
it("creates insight tables when upgrading from schema version 32", () => {
|
||||
const legacyDir = mkdtempSync(join(tmpdir(), "fn-mig-test-"));
|
||||
|
||||
try {
|
||||
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
||||
const db1 = createDatabase(legacyDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(33);
|
||||
db1.close();
|
||||
|
||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||
// to simulate a pre-33 database
|
||||
const db2 = createDatabase(legacyDir);
|
||||
db2.init();
|
||||
db2.prepare("UPDATE __meta SET value = '32' WHERE key = 'schemaVersion'").run();
|
||||
// Drop insight tables/indexes to fully simulate pre-33 state
|
||||
db2.prepare("DROP TABLE IF EXISTS project_insight_runs").run();
|
||||
db2.prepare("DROP TABLE IF EXISTS project_insights").run();
|
||||
db2.prepare("DROP INDEX IF EXISTS idxProjectInsightsProjectId").run();
|
||||
db2.prepare("DROP INDEX IF EXISTS idxProjectInsightsFingerprint").run();
|
||||
db2.prepare("DROP INDEX IF EXISTS idxProjectInsightsCategory").run();
|
||||
db2.prepare("DROP INDEX IF EXISTS idxInsightRunsProjectId").run();
|
||||
db2.close();
|
||||
|
||||
// Step 3: Verify pre-33 state (after downgrade, before re-init)
|
||||
// Note: we check the version BEFORE calling init() on db3
|
||||
// because init() would immediately run migration 33.
|
||||
// We verify pre-33 state by re-opening without calling init() on the new instance,
|
||||
// then calling init() and verifying it upgrades.
|
||||
const db3 = createDatabase(legacyDir);
|
||||
// Read version without running migrations
|
||||
const versionBefore = db3.getSchemaVersion();
|
||||
expect(versionBefore).toBe(32);
|
||||
// Verify insight tables are absent in the pre-33 state
|
||||
const tablesBefore = db3.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_%'"
|
||||
).all() as { name: string }[];
|
||||
const tableNamesBefore = tablesBefore.map((t) => t.name);
|
||||
expect(tableNamesBefore).not.toContain("project_insights");
|
||||
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
||||
// Now run init — this triggers the v32→v33 migration
|
||||
db3.init();
|
||||
expect(db3.getSchemaVersion()).toBe(33);
|
||||
|
||||
// Step 4: Verify insight tables exist after migration
|
||||
const tablesAfter = db3.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_%'"
|
||||
).all() as { name: string }[];
|
||||
const tableNamesAfter = tablesAfter.map((t) => t.name);
|
||||
expect(tableNamesAfter).toContain("project_insights");
|
||||
expect(tableNamesAfter).toContain("project_insight_runs");
|
||||
|
||||
// Verify indexes exist
|
||||
const indexes = db3.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'"
|
||||
).all() as { name: string }[];
|
||||
const indexNames = indexes.map((i) => i.name);
|
||||
expect(indexNames).toContain("idxProjectInsightsProjectId");
|
||||
expect(indexNames).toContain("idxProjectInsightsFingerprint");
|
||||
expect(indexNames).toContain("idxInsightRunsProjectId");
|
||||
|
||||
db3.close();
|
||||
} finally {
|
||||
rmSync(legacyDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("migration is idempotent — running twice does not fail", () => {
|
||||
const testDir = mkdtempSync(join(tmpdir(), "fn-idempotent-test-"));
|
||||
|
||||
try {
|
||||
const db1 = createDatabase(testDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(33);
|
||||
db1.close();
|
||||
|
||||
const db2 = createDatabase(testDir);
|
||||
expect(() => db2.init()).not.toThrow();
|
||||
expect(db2.getSchemaVersion()).toBe(33);
|
||||
db2.close();
|
||||
} finally {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
666
packages/core/src/insight-store.ts
Normal file
666
packages/core/src/insight-store.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
/**
|
||||
* InsightStore — Project-level insight persistence and run tracking.
|
||||
*
|
||||
* Manages normalized insight entities and insight-generation run records
|
||||
* in SQLite, with deterministic ordering and fingerprint-based upsert dedupe.
|
||||
*
|
||||
* ## Ordering Contract
|
||||
*
|
||||
* All list operations return results in **ascending** order by default.
|
||||
* When multiple rows share the same primary sort key (e.g., `createdAt`),
|
||||
* ties are broken deterministically by `id` ascending (lexicographic).
|
||||
*
|
||||
* This is enforced both in SQL ORDER BY clauses and in-memory sorts
|
||||
* to guarantee stable iteration across repeated reads.
|
||||
*
|
||||
* ## Deduplication Contract
|
||||
*
|
||||
* `upsertInsight()` deduplicates by (projectId, fingerprint).
|
||||
* When a fingerprint match is found, the existing row's mutable fields are
|
||||
* updated and its original `id` / `createdAt` are preserved — no new row
|
||||
* is created. Use `createInsight()` to force creation regardless of fingerprint.
|
||||
*
|
||||
* ## Naming Convention
|
||||
*
|
||||
* Table names use `project_insights` / `project_insight_runs` (snake_case)
|
||||
* to match the established SQLite convention in this codebase.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Database } from "./db.js";
|
||||
import { toJson, toJsonNullable, fromJson } from "./db.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
Insight,
|
||||
InsightCreateInput,
|
||||
InsightUpdateInput,
|
||||
InsightUpsertInput,
|
||||
InsightListOptions,
|
||||
InsightCategory,
|
||||
InsightStatus,
|
||||
InsightProvenance,
|
||||
InsightRun,
|
||||
InsightRunCreateInput,
|
||||
InsightRunUpdateInput,
|
||||
InsightRunListOptions,
|
||||
InsightRunStatus,
|
||||
InsightRunTrigger,
|
||||
InsightRunInputMetadata,
|
||||
InsightRunOutputMetadata,
|
||||
} from "./insight-types.js";
|
||||
import type { InsightStoreEvents } from "./insight-types.js";
|
||||
|
||||
// ── ID Generators ────────────────────────────────────────────────────
|
||||
|
||||
function generateInsightId(): string {
|
||||
const timestamp = Date.now().toString(36).toUpperCase();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `INS-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
function generateRunId(): string {
|
||||
const timestamp = Date.now().toString(36).toUpperCase();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `INSR-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
// ── Fingerprint Helper ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute a canonical fingerprint for an insight.
|
||||
*
|
||||
* The fingerprint is derived from normalized (lowercased, trimmed) title
|
||||
* and category to produce a consistent dedupe key regardless of
|
||||
* minor wording variations.
|
||||
*
|
||||
* @param title - The insight title
|
||||
* @param category - The insight category
|
||||
* @returns A deterministic fingerprint string
|
||||
*/
|
||||
export function computeInsightFingerprint(title: string, category: InsightCategory): string {
|
||||
// Normalize: lowercase, trim, collapse internal whitespace
|
||||
const normalizedTitle = title.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
const normalizedCategory = category.toLowerCase().trim();
|
||||
const raw = `${normalizedCategory}:${normalizedTitle}`;
|
||||
// Use a simple hash for the fingerprint — deterministic and short
|
||||
let hash = 0;
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const char = raw.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
// Return as unsigned hex string
|
||||
return Math.abs(hash).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
// ── InsightStore Class ───────────────────────────────────────────────
|
||||
|
||||
export class InsightStore extends EventEmitter<InsightStoreEvents> {
|
||||
constructor(private db: Database) {
|
||||
super();
|
||||
this.setMaxListeners(50);
|
||||
}
|
||||
|
||||
/** Expose the database for testing purposes. */
|
||||
getDatabase(): Database {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
// ── Insight CRUD ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new insight.
|
||||
*
|
||||
* Does NOT check for fingerprint duplicates — use `upsertInsight()`
|
||||
* when dedupe-by-fingerprint is desired.
|
||||
*
|
||||
* @param projectId - Project this insight belongs to
|
||||
* @param input - Insight creation input
|
||||
* @returns The newly created insight
|
||||
*/
|
||||
createInsight(projectId: string, input: InsightCreateInput): Insight {
|
||||
const now = new Date().toISOString();
|
||||
const id = generateInsightId();
|
||||
const fingerprint = input.fingerprint ?? computeInsightFingerprint(input.title, input.category);
|
||||
const status = input.status ?? "generated";
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO project_insights (
|
||||
id, projectId, title, content, category, status,
|
||||
fingerprint, provenance, lastRunId, createdAt, updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
projectId,
|
||||
input.title,
|
||||
input.content ?? null,
|
||||
input.category,
|
||||
status,
|
||||
fingerprint,
|
||||
toJsonNullable(input.provenance) ?? null,
|
||||
null,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
|
||||
const insight: Insight = {
|
||||
id,
|
||||
projectId,
|
||||
title: input.title,
|
||||
content: input.content ?? null,
|
||||
category: input.category,
|
||||
status,
|
||||
fingerprint,
|
||||
provenance: input.provenance,
|
||||
lastRunId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.emit("insight:created", insight);
|
||||
return insight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single insight by ID.
|
||||
*
|
||||
* @param id - The insight ID
|
||||
* @returns The insight, or undefined if not found
|
||||
*/
|
||||
getInsight(id: string): Insight | undefined {
|
||||
const row = this.db.prepare("SELECT * FROM project_insights WHERE id = ?").get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return row ? this.rowToInsight(row) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* List insights with optional filtering and pagination.
|
||||
*
|
||||
* Results are ordered ascending by (createdAt, id) for deterministic iteration.
|
||||
*
|
||||
* @param options - Filter and pagination options
|
||||
* @returns Matching insights, ordered ascending by createdAt then id
|
||||
*/
|
||||
listInsights(options: InsightListOptions = {}): Insight[] {
|
||||
const conditions: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
if (options.projectId !== undefined) {
|
||||
conditions.push("projectId = ?");
|
||||
params.push(options.projectId);
|
||||
}
|
||||
if (options.category !== undefined) {
|
||||
conditions.push("category = ?");
|
||||
params.push(options.category);
|
||||
}
|
||||
if (options.status !== undefined) {
|
||||
conditions.push("status = ?");
|
||||
params.push(options.status);
|
||||
}
|
||||
if (options.runId !== undefined) {
|
||||
conditions.push("lastRunId = ?");
|
||||
params.push(options.runId);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const limitClause = options.limit !== undefined ? `LIMIT ${options.limit}` : "";
|
||||
const offsetClause = options.offset !== undefined ? `OFFSET ${options.offset}` : "";
|
||||
|
||||
// Deterministic ordering: createdAt ASC, id ASC (lexicographic tie-breaker)
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM project_insights
|
||||
${whereClause}
|
||||
ORDER BY createdAt ASC, id ASC
|
||||
${limitClause}
|
||||
${offsetClause}
|
||||
`).all(...params) as Record<string, unknown>[];
|
||||
|
||||
return rows.map((row) => this.rowToInsight(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing insight.
|
||||
*
|
||||
* @param id - The insight ID to update
|
||||
* @param input - Fields to update
|
||||
* @returns The updated insight, or undefined if not found
|
||||
*/
|
||||
updateInsight(id: string, input: InsightUpdateInput): Insight | undefined {
|
||||
const existing = this.getInsight(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const sets: string[] = ["updatedAt = ?"];
|
||||
const params: (string | null)[] = [now];
|
||||
|
||||
if (input.title !== undefined) {
|
||||
sets.push("title = ?");
|
||||
params.push(input.title);
|
||||
}
|
||||
if (input.content !== undefined) {
|
||||
sets.push("content = ?");
|
||||
params.push(input.content);
|
||||
}
|
||||
if (input.category !== undefined) {
|
||||
sets.push("category = ?");
|
||||
params.push(input.category);
|
||||
}
|
||||
if (input.status !== undefined) {
|
||||
sets.push("status = ?");
|
||||
params.push(input.status);
|
||||
}
|
||||
if (input.provenance !== undefined) {
|
||||
sets.push("provenance = ?");
|
||||
params.push(toJsonNullable(input.provenance));
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
this.db.prepare(`UPDATE project_insights SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
// Re-read to get the full updated record
|
||||
const updated = this.getInsight(id)!;
|
||||
this.emit("insight:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an insight by ID.
|
||||
*
|
||||
* @param id - The insight ID to delete
|
||||
* @returns true if deleted, false if not found
|
||||
*/
|
||||
deleteInsight(id: string): boolean {
|
||||
const existing = this.getInsight(id);
|
||||
if (!existing) return false;
|
||||
|
||||
this.db.prepare("DELETE FROM project_insights WHERE id = ?").run(id);
|
||||
this.db.bumpLastModified();
|
||||
this.emit("insight:deleted", id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert an insight by (projectId, fingerprint).
|
||||
*
|
||||
* - If an insight with the same projectId + fingerprint exists, update its
|
||||
* mutable fields (title, content, provenance, lastRunId, updatedAt) and
|
||||
* preserve the original `id` and `createdAt`.
|
||||
* - If no match exists, create a new insight.
|
||||
*
|
||||
* This enables idempotent insight generation where re-running the same
|
||||
* analysis updates the existing insight rather than creating duplicates.
|
||||
*
|
||||
* @param projectId - Project scope
|
||||
* @param input - Upsert input (fingerprint required for dedupe)
|
||||
* @returns The created or updated insight
|
||||
*/
|
||||
upsertInsight(projectId: string, input: InsightUpsertInput): Insight {
|
||||
const now = new Date().toISOString();
|
||||
const fingerprint = input.fingerprint;
|
||||
|
||||
// Check for existing insight with same projectId + fingerprint
|
||||
const existingRow = this.db.prepare(`
|
||||
SELECT * FROM project_insights WHERE projectId = ? AND fingerprint = ?
|
||||
`).get(projectId, fingerprint) as Record<string, unknown> | undefined;
|
||||
|
||||
if (existingRow) {
|
||||
// Update existing row in place — preserve id and createdAt
|
||||
const sets: string[] = [
|
||||
"title = ?",
|
||||
"content = ?",
|
||||
"category = ?",
|
||||
"status = ?",
|
||||
"provenance = ?",
|
||||
"lastRunId = ?",
|
||||
"updatedAt = ?",
|
||||
];
|
||||
const params: (string | null)[] = [
|
||||
input.title,
|
||||
input.content ?? null,
|
||||
input.category,
|
||||
input.status ?? "confirmed",
|
||||
toJsonNullable(input.provenance),
|
||||
input.provenance.metadata?.runId as string | null ?? null,
|
||||
now,
|
||||
];
|
||||
|
||||
const id = existingRow.id as string;
|
||||
this.db.prepare(`UPDATE project_insights SET ${sets.join(", ")} WHERE id = ?`).run(...params, id);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
const updated = this.getInsight(id)!;
|
||||
this.emit("insight:updated", updated);
|
||||
return updated;
|
||||
} else {
|
||||
// Create new insight
|
||||
return this.createInsight(projectId, {
|
||||
...input,
|
||||
status: input.status ?? "confirmed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of insights matching the given filter.
|
||||
*/
|
||||
countInsights(options: Omit<InsightListOptions, "limit" | "offset"> = {}): number {
|
||||
const conditions: string[] = [];
|
||||
const params: string[] = [];
|
||||
|
||||
if (options.projectId !== undefined) {
|
||||
conditions.push("projectId = ?");
|
||||
params.push(options.projectId);
|
||||
}
|
||||
if (options.category !== undefined) {
|
||||
conditions.push("category = ?");
|
||||
params.push(options.category);
|
||||
}
|
||||
if (options.status !== undefined) {
|
||||
conditions.push("status = ?");
|
||||
params.push(options.status);
|
||||
}
|
||||
if (options.runId !== undefined) {
|
||||
conditions.push("lastRunId = ?");
|
||||
params.push(options.runId);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const row = this.db.prepare(`
|
||||
SELECT COUNT(*) as count FROM project_insights ${whereClause}
|
||||
`).get(...params) as { count: number } | undefined;
|
||||
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
// ── Insight Run CRUD ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new insight generation run.
|
||||
*
|
||||
* @param projectId - Project this run belongs to
|
||||
* @param input - Run creation input
|
||||
* @returns The newly created run
|
||||
*/
|
||||
createRun(projectId: string, input: InsightRunCreateInput): InsightRun {
|
||||
const now = new Date().toISOString();
|
||||
const id = generateRunId();
|
||||
const inputMetadata = input.inputMetadata ?? {};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO project_insight_runs (
|
||||
id, projectId, trigger, status, summary, error,
|
||||
insightsCreated, insightsUpdated,
|
||||
inputMetadata, outputMetadata,
|
||||
createdAt, startedAt, completedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
projectId,
|
||||
input.trigger,
|
||||
"pending",
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
toJsonNullable(inputMetadata) ?? null,
|
||||
null,
|
||||
now,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
|
||||
const run: InsightRun = {
|
||||
id,
|
||||
projectId,
|
||||
trigger: input.trigger,
|
||||
status: "pending",
|
||||
summary: null,
|
||||
error: null,
|
||||
insightsCreated: 0,
|
||||
insightsUpdated: 0,
|
||||
inputMetadata,
|
||||
outputMetadata: {},
|
||||
createdAt: now,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
};
|
||||
|
||||
this.emit("run:created", run);
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single run by ID.
|
||||
*/
|
||||
getRun(id: string): InsightRun | undefined {
|
||||
const row = this.db.prepare("SELECT * FROM project_insight_runs WHERE id = ?").get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return row ? this.rowToRun(row) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* List runs with optional filtering and pagination.
|
||||
*
|
||||
* Results are ordered ascending by (createdAt DESC, id DESC) for newest-first
|
||||
* default ordering. Use options with explicit ordering to override.
|
||||
*
|
||||
* @param options - Filter and pagination options
|
||||
* @returns Matching runs
|
||||
*/
|
||||
listRuns(options: InsightRunListOptions = {}): InsightRun[] {
|
||||
const conditions: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
if (options.projectId !== undefined) {
|
||||
conditions.push("projectId = ?");
|
||||
params.push(options.projectId);
|
||||
}
|
||||
if (options.status !== undefined) {
|
||||
conditions.push("status = ?");
|
||||
params.push(options.status);
|
||||
}
|
||||
if (options.trigger !== undefined) {
|
||||
conditions.push("trigger = ?");
|
||||
params.push(options.trigger);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const limitClause = options.limit !== undefined ? `LIMIT ${options.limit}` : "";
|
||||
const offsetClause = options.offset !== undefined ? `OFFSET ${options.offset}` : "";
|
||||
|
||||
// Deterministic ordering: newest first by default (createdAt DESC, id DESC)
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM project_insight_runs
|
||||
${whereClause}
|
||||
ORDER BY createdAt DESC, id DESC
|
||||
${limitClause}
|
||||
${offsetClause}
|
||||
`).all(...params) as Record<string, unknown>[];
|
||||
|
||||
return rows.map((row) => this.rowToRun(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing run.
|
||||
*
|
||||
* When `status` transitions to a terminal state (`completed`, `failed`,
|
||||
* `cancelled`), `completedAt` is set automatically if not already provided.
|
||||
*
|
||||
* @param id - The run ID to update
|
||||
* @param input - Fields to update
|
||||
* @returns The updated run, or undefined if not found
|
||||
*/
|
||||
updateRun(id: string, input: InsightRunUpdateInput): InsightRun | undefined {
|
||||
const existing = this.getRun(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const isTerminal = input.status !== undefined && ["completed", "failed", "cancelled"].includes(input.status);
|
||||
const autoComplete = isTerminal && input.completedAt === undefined && existing.completedAt === null;
|
||||
|
||||
const sets: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
|
||||
if (input.status !== undefined) {
|
||||
sets.push("status = ?");
|
||||
params.push(input.status);
|
||||
}
|
||||
if (input.summary !== undefined) {
|
||||
sets.push("summary = ?");
|
||||
params.push(input.summary);
|
||||
}
|
||||
if (input.error !== undefined) {
|
||||
sets.push("error = ?");
|
||||
params.push(input.error);
|
||||
}
|
||||
if (input.insightsCreated !== undefined) {
|
||||
sets.push("insightsCreated = ?");
|
||||
params.push(input.insightsCreated);
|
||||
}
|
||||
if (input.insightsUpdated !== undefined) {
|
||||
sets.push("insightsUpdated = ?");
|
||||
params.push(input.insightsUpdated);
|
||||
}
|
||||
if (input.outputMetadata !== undefined) {
|
||||
sets.push("outputMetadata = ?");
|
||||
params.push(toJsonNullable(input.outputMetadata));
|
||||
}
|
||||
if (input.startedAt !== undefined) {
|
||||
sets.push("startedAt = ?");
|
||||
params.push(input.startedAt);
|
||||
}
|
||||
if (input.completedAt !== undefined) {
|
||||
sets.push("completedAt = ?");
|
||||
params.push(input.completedAt);
|
||||
}
|
||||
|
||||
if (sets.length === 0) return existing;
|
||||
|
||||
// Auto-set completedAt for terminal transitions
|
||||
if (autoComplete) {
|
||||
sets.push("completedAt = ?");
|
||||
params.push(now);
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
this.db.prepare(`UPDATE project_insight_runs SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
const updated = this.getRun(id)!;
|
||||
|
||||
if (isTerminal) {
|
||||
this.emit("run:completed", updated);
|
||||
}
|
||||
this.emit("run:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a run by (projectId, trigger, createdAt) — used when a pipeline
|
||||
* needs to resume or update a specific run by fingerprint-like key.
|
||||
*
|
||||
* For most cases, `createRun()` + `updateRun()` is sufficient.
|
||||
* This method exists for pipelines that need idempotent run creation.
|
||||
*
|
||||
* @param projectId - Project scope
|
||||
* @param trigger - Trigger type to match
|
||||
* @param input - Run data
|
||||
* @returns The created or existing run
|
||||
*/
|
||||
upsertRun(projectId: string, trigger: InsightRunTrigger, input: InsightRunCreateInput): InsightRun {
|
||||
// Find most recent pending/running run for this project + trigger
|
||||
const existingRow = this.db.prepare(`
|
||||
SELECT * FROM project_insight_runs
|
||||
WHERE projectId = ? AND trigger = ? AND status IN ('pending', 'running')
|
||||
ORDER BY createdAt DESC, id DESC
|
||||
LIMIT 1
|
||||
`).get(projectId, trigger) as Record<string, unknown> | undefined;
|
||||
|
||||
if (existingRow) {
|
||||
return this.getRun(existingRow.id as string)!;
|
||||
}
|
||||
|
||||
return this.createRun(projectId, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of runs matching the given filter.
|
||||
*/
|
||||
countRuns(options: Omit<InsightRunListOptions, "limit" | "offset"> = {}): number {
|
||||
const conditions: string[] = [];
|
||||
const params: string[] = [];
|
||||
|
||||
if (options.projectId !== undefined) {
|
||||
conditions.push("projectId = ?");
|
||||
params.push(options.projectId);
|
||||
}
|
||||
if (options.status !== undefined) {
|
||||
conditions.push("status = ?");
|
||||
params.push(options.status);
|
||||
}
|
||||
if (options.trigger !== undefined) {
|
||||
conditions.push("trigger = ?");
|
||||
params.push(options.trigger);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const row = this.db.prepare(`
|
||||
SELECT COUNT(*) as count FROM project_insight_runs ${whereClause}
|
||||
`).get(...params) as { count: number } | undefined;
|
||||
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
// ── Row → Entity Converters ─────────────────────────────────────────
|
||||
|
||||
private rowToInsight(row: Record<string, unknown>): Insight {
|
||||
return {
|
||||
id: row.id as string,
|
||||
projectId: row.projectId as string,
|
||||
title: row.title as string,
|
||||
content: row.content as string | null,
|
||||
category: row.category as InsightCategory,
|
||||
status: row.status as InsightStatus,
|
||||
fingerprint: row.fingerprint as string,
|
||||
provenance: (() => {
|
||||
const p = fromJson<InsightProvenance>(row.provenance as string | null);
|
||||
return p ?? { trigger: "unknown" };
|
||||
})(),
|
||||
lastRunId: row.lastRunId as string | null,
|
||||
createdAt: row.createdAt as string,
|
||||
updatedAt: row.updatedAt as string,
|
||||
};
|
||||
}
|
||||
|
||||
private rowToRun(row: Record<string, unknown>): InsightRun {
|
||||
return {
|
||||
id: row.id as string,
|
||||
projectId: row.projectId as string,
|
||||
trigger: row.trigger as InsightRunTrigger,
|
||||
status: row.status as InsightRunStatus,
|
||||
summary: row.summary as string | null,
|
||||
error: row.error as string | null,
|
||||
insightsCreated: row.insightsCreated as number,
|
||||
insightsUpdated: row.insightsUpdated as number,
|
||||
inputMetadata: (() => {
|
||||
const m = fromJson<InsightRunInputMetadata>(row.inputMetadata as string | null);
|
||||
return m ?? {};
|
||||
})(),
|
||||
outputMetadata: (() => {
|
||||
const m = fromJson<InsightRunOutputMetadata>(row.outputMetadata as string | null);
|
||||
return m ?? {};
|
||||
})(),
|
||||
createdAt: row.createdAt as string,
|
||||
startedAt: row.startedAt as string | null,
|
||||
completedAt: row.completedAt as string | null,
|
||||
};
|
||||
}
|
||||
}
|
||||
477
packages/core/src/insight-types.ts
Normal file
477
packages/core/src/insight-types.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Insight Domain Types
|
||||
*
|
||||
* Normalized domain contracts for project-level insight persistence and
|
||||
* insight-generation run tracking.
|
||||
*
|
||||
* Design principles:
|
||||
* - Insights are **project-scoped** — each insight belongs to exactly one project
|
||||
* - **Deduplication** via canonical fingerprint: same fingerprint = same logical insight
|
||||
* - **Stable identity**: upserts preserve the original `id` and `createdAt`
|
||||
* - **Deterministic ordering**: list operations use multi-column tie-breakers
|
||||
* (see ordering contract below)
|
||||
*
|
||||
* ## Ordering Contract
|
||||
*
|
||||
* All list operations return results in **ascending** order by default.
|
||||
* When multiple rows share the same primary sort key (e.g., `createdAt`),
|
||||
* the tie is broken deterministically by:
|
||||
*
|
||||
* 1. `id` ascending (lexicographic, case-sensitive)
|
||||
*
|
||||
* This two-level ordering is enforced in both SQL ORDER BY clauses and
|
||||
* in-memory sorts to guarantee stable iteration across repeated reads.
|
||||
* Timestamp-only ordering is intentionally avoided because SQLite timestamps
|
||||
* have millisecond resolution, making ties possible in batch operations.
|
||||
*
|
||||
* ## Naming Convention
|
||||
*
|
||||
* Table names use `project_insights` / `project_insight_runs` (snake_case)
|
||||
* to match the established SQLite convention in this codebase.
|
||||
* Type names use PascalCase (e.g., `Insight`, `InsightRun`).
|
||||
*/
|
||||
|
||||
// ── Insight Category ─────────────────────────────────────────────────
|
||||
|
||||
/** High-level category for classifying an insight. */
|
||||
export type InsightCategory =
|
||||
| "quality" // Code quality, patterns, debt
|
||||
| "performance" // Performance, efficiency, bottlenecks
|
||||
| "architecture" // Structural decisions, coupling, design
|
||||
| "security" // Security vulnerabilities, risks
|
||||
| "reliability" // Error handling, stability, resilience
|
||||
| "ux" // User experience, accessibility, ergonomics
|
||||
| "testability" // Testing gaps, coverage, test design
|
||||
| "documentation" // Docs, comments, clarity
|
||||
| "dependency" // Third-party deps, versions, updates
|
||||
| "workflow" // Process, tooling, developer experience
|
||||
| "other"; // Uncategorized / general
|
||||
|
||||
// ── Insight Lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lifecycle state of an insight.
|
||||
*
|
||||
* State transitions:
|
||||
* generated → confirmed → stale
|
||||
* ↓
|
||||
* dismissed
|
||||
*
|
||||
* A "stale" insight has been superseded or is no longer relevant.
|
||||
* A "dismissed" insight was manually rejected by a reviewer.
|
||||
*/
|
||||
export type InsightStatus = "generated" | "confirmed" | "stale" | "dismissed";
|
||||
|
||||
// ── Provenance Metadata ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Provenance metadata capturing where/how an insight was derived.
|
||||
* Stored as a JSON column in the database.
|
||||
*/
|
||||
export interface InsightProvenance {
|
||||
/**
|
||||
* What triggered this insight to be generated.
|
||||
* Examples: "schedule", "manual", "task_completion", "merge_event"
|
||||
*/
|
||||
trigger: string;
|
||||
|
||||
/** Human-readable description of the generation context. */
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* Identifiers of related entities that were in scope when the insight
|
||||
* was generated. For example, task IDs that were analyzed.
|
||||
*/
|
||||
relatedEntityIds?: string[];
|
||||
|
||||
/**
|
||||
* Token usage estimate for the generation call.
|
||||
* Stored as an approximate snapshot, not for billing purposes.
|
||||
*/
|
||||
tokenEstimate?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Arbitrary metadata specific to the generation method.
|
||||
* Examples: model ID, temperature, processing duration.
|
||||
*/
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Core Insight Entity ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A persisted project insight.
|
||||
*
|
||||
* An insight represents a derived observation or recommendation about
|
||||
* the project, generated by an AI analysis pipeline and stored for
|
||||
* later review, action, or archival.
|
||||
*
|
||||
* Identity is determined by `id`; dedup logic uses `fingerprint`.
|
||||
*/
|
||||
export interface Insight {
|
||||
/**
|
||||
* Unique identifier (e.g., "INS-xxx").
|
||||
* Assigned by the store on creation; stable across upserts.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Project this insight belongs to.
|
||||
* Corresponds to the project directory / .fusion/ path.
|
||||
*/
|
||||
projectId: string;
|
||||
|
||||
/** Short title summarizing the insight (1–120 chars recommended). */
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* Full body of the insight. May contain markdown formatting.
|
||||
* Nullable to support partial insights (title-only during generation).
|
||||
*/
|
||||
content: string | null;
|
||||
|
||||
/**
|
||||
* Classification category.
|
||||
*/
|
||||
category: InsightCategory;
|
||||
|
||||
/**
|
||||
* Current lifecycle state.
|
||||
*/
|
||||
status: InsightStatus;
|
||||
|
||||
/**
|
||||
* Canonical fingerprint for deduplication.
|
||||
*
|
||||
* Two insights with the same fingerprint and projectId are considered
|
||||
* the same logical insight. When an upsert finds a matching fingerprint,
|
||||
* the existing row is updated and its `id` / `createdAt` are preserved.
|
||||
*
|
||||
* Construction: a deterministic hash of (normalized title + category).
|
||||
* Consumers should use `computeInsightFingerprint()` from insight-store.ts.
|
||||
*/
|
||||
fingerprint: string;
|
||||
|
||||
/**
|
||||
* Provenance metadata capturing the generation context.
|
||||
*/
|
||||
provenance: InsightProvenance;
|
||||
|
||||
/**
|
||||
* ID of the most recent generation run that produced or updated this insight.
|
||||
* References `project_insight_runs.id`.
|
||||
*/
|
||||
lastRunId: string | null;
|
||||
|
||||
/**
|
||||
* When the insight was first generated.
|
||||
* Set on creation and preserved on upsert.
|
||||
*/
|
||||
createdAt: string;
|
||||
|
||||
/**
|
||||
* When the insight was last modified (any field change).
|
||||
*/
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Insight Creation Input ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Input for creating a new insight.
|
||||
* `fingerprint` is required for upsertable insights.
|
||||
*/
|
||||
export interface InsightCreateInput {
|
||||
title: string;
|
||||
content?: string | null;
|
||||
category: InsightCategory;
|
||||
provenance: InsightProvenance;
|
||||
fingerprint?: string;
|
||||
status?: InsightStatus;
|
||||
}
|
||||
|
||||
// ── Insight Update Input ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Input for updating an existing insight.
|
||||
* All fields are optional — only provided fields are updated.
|
||||
*/
|
||||
export interface InsightUpdateInput {
|
||||
title?: string;
|
||||
content?: string | null;
|
||||
category?: InsightCategory;
|
||||
status?: InsightStatus;
|
||||
provenance?: InsightProvenance;
|
||||
}
|
||||
|
||||
// ── Insight Upsert Input ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Input for upserting an insight by fingerprint.
|
||||
*
|
||||
* If an existing insight with the same (projectId, fingerprint) exists,
|
||||
* the mutable fields are updated and the original `id` / `createdAt` are
|
||||
* preserved (no duplicate row is created).
|
||||
*
|
||||
* If no match exists, a new insight is created with a fresh `id`.
|
||||
*/
|
||||
export interface InsightUpsertInput extends InsightCreateInput {
|
||||
/**
|
||||
* Required for upsert — must be provided to enable dedupe matching.
|
||||
*/
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
// ── Insight List Options ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filtering and pagination options for insight listing.
|
||||
*/
|
||||
export interface InsightListOptions {
|
||||
/** Restrict to a specific project. */
|
||||
projectId?: string;
|
||||
|
||||
/** Restrict to a specific category. */
|
||||
category?: InsightCategory;
|
||||
|
||||
/** Restrict to a specific status. */
|
||||
status?: InsightStatus;
|
||||
|
||||
/** Restrict to insights linked to a specific run. */
|
||||
runId?: string;
|
||||
|
||||
/** Maximum number of rows to return. Default: no limit. */
|
||||
limit?: number;
|
||||
|
||||
/** Number of rows to skip. Default: 0. */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// ── Insight Generation Run Entity ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Status of a single insight-generation run.
|
||||
*
|
||||
* Runs are **immutable** once terminal (completed, failed, cancelled).
|
||||
* Only `pending` and `running` runs can transition state.
|
||||
*/
|
||||
export type InsightRunStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||
|
||||
/**
|
||||
* What triggered a generation run.
|
||||
*/
|
||||
export type InsightRunTrigger = "schedule" | "manual" | "task_completion" | "merge_event" | "api";
|
||||
|
||||
/**
|
||||
* A single execution of the insight-generation pipeline.
|
||||
*
|
||||
* Runs track the full lifecycle of an analysis pass — from scheduling
|
||||
* through input processing to output persistence.
|
||||
*/
|
||||
export interface InsightRun {
|
||||
/**
|
||||
* Unique identifier for this run (e.g., "INSR-xxx").
|
||||
* Assigned by the store on creation.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Project this run belongs to.
|
||||
*/
|
||||
projectId: string;
|
||||
|
||||
/**
|
||||
* What initiated this run.
|
||||
*/
|
||||
trigger: InsightRunTrigger;
|
||||
|
||||
/**
|
||||
* Current execution status.
|
||||
*/
|
||||
status: InsightRunStatus;
|
||||
|
||||
/**
|
||||
* Human-readable summary of the run outcome.
|
||||
* Set by the pipeline on completion or failure.
|
||||
*/
|
||||
summary: string | null;
|
||||
|
||||
/**
|
||||
* Error message if the run failed.
|
||||
*/
|
||||
error: string | null;
|
||||
|
||||
/**
|
||||
* Number of insights that were created by this run.
|
||||
*/
|
||||
insightsCreated: number;
|
||||
|
||||
/**
|
||||
* Number of existing insights that were updated (fingerprint-matched) by this run.
|
||||
*/
|
||||
insightsUpdated: number;
|
||||
|
||||
/**
|
||||
* Input metadata for the run — what was analyzed.
|
||||
* Stored as a JSON snapshot for audit/replay.
|
||||
*/
|
||||
inputMetadata: InsightRunInputMetadata;
|
||||
|
||||
/**
|
||||
* Output metadata for the run — what was produced.
|
||||
* Stored as a JSON snapshot.
|
||||
*/
|
||||
outputMetadata: InsightRunOutputMetadata;
|
||||
|
||||
/**
|
||||
* When the run was queued / created.
|
||||
*/
|
||||
createdAt: string;
|
||||
|
||||
/**
|
||||
* When the run started processing.
|
||||
*/
|
||||
startedAt: string | null;
|
||||
|
||||
/**
|
||||
* When the run reached a terminal state.
|
||||
*/
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
// ── Run Input / Output Metadata ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Input metadata snapshot for a run.
|
||||
*/
|
||||
export interface InsightRunInputMetadata {
|
||||
/**
|
||||
* IDs of tasks that were in scope for this analysis.
|
||||
* Empty array if no task-specific scope.
|
||||
*/
|
||||
taskIds?: string[];
|
||||
|
||||
/**
|
||||
* Branch or ref that was analyzed (if applicable).
|
||||
*/
|
||||
branch?: string;
|
||||
|
||||
/**
|
||||
* Commit SHA at which analysis was performed.
|
||||
*/
|
||||
commitSha?: string;
|
||||
|
||||
/**
|
||||
* Any additional context passed to the generation prompt.
|
||||
*/
|
||||
context?: string;
|
||||
|
||||
/**
|
||||
* Arbitrary input metadata (model used, prompt tokens, etc.).
|
||||
*/
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output metadata snapshot for a run.
|
||||
*/
|
||||
export interface InsightRunOutputMetadata {
|
||||
/**
|
||||
* IDs of insights created by this run.
|
||||
*/
|
||||
insightIds?: string[];
|
||||
|
||||
/**
|
||||
* IDs of existing insights updated (fingerprint-matched) by this run.
|
||||
*/
|
||||
updatedInsightIds?: string[];
|
||||
|
||||
/**
|
||||
* IDs of insights that were marked stale or dismissed by this run.
|
||||
*/
|
||||
supersededInsightIds?: string[];
|
||||
|
||||
/**
|
||||
* Number of generation prompt tokens used.
|
||||
*/
|
||||
promptTokens?: number;
|
||||
|
||||
/**
|
||||
* Number of completion tokens generated.
|
||||
*/
|
||||
completionTokens?: number;
|
||||
|
||||
/**
|
||||
* Arbitrary output metadata.
|
||||
*/
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Run Creation Input ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Input for creating a new insight run.
|
||||
*/
|
||||
export interface InsightRunCreateInput {
|
||||
trigger: InsightRunTrigger;
|
||||
inputMetadata?: InsightRunInputMetadata;
|
||||
}
|
||||
|
||||
// ── Run Update Input ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Input for updating a run record.
|
||||
* Used by the pipeline to report progress and final outcomes.
|
||||
*/
|
||||
export interface InsightRunUpdateInput {
|
||||
status?: InsightRunStatus;
|
||||
summary?: string | null;
|
||||
error?: string | null;
|
||||
insightsCreated?: number;
|
||||
insightsUpdated?: number;
|
||||
outputMetadata?: InsightRunOutputMetadata;
|
||||
startedAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
}
|
||||
|
||||
// ── Run List Options ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filtering and pagination options for run listing.
|
||||
*/
|
||||
export interface InsightRunListOptions {
|
||||
/** Restrict to a specific project. */
|
||||
projectId?: string;
|
||||
|
||||
/** Restrict to runs with a specific status. */
|
||||
status?: InsightRunStatus;
|
||||
|
||||
/** Restrict to runs triggered by a specific trigger type. */
|
||||
trigger?: InsightRunTrigger;
|
||||
|
||||
/** Maximum number of rows to return. Default: no limit. */
|
||||
limit?: number;
|
||||
|
||||
/** Number of rows to skip. Default: 0. */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// ── Store Event Types ────────────────────────────────────────────────
|
||||
|
||||
export interface InsightStoreEvents {
|
||||
/** Emitted when an insight is created */
|
||||
"insight:created": [Insight];
|
||||
/** Emitted when an insight is updated */
|
||||
"insight:updated": [Insight];
|
||||
/** Emitted when an insight is deleted */
|
||||
"insight:deleted": [string];
|
||||
/** Emitted when a run is created */
|
||||
"run:created": [InsightRun];
|
||||
/** Emitted when a run is updated */
|
||||
"run:updated": [InsightRun];
|
||||
/** Emitted when a run reaches a terminal state */
|
||||
"run:completed": [InsightRun];
|
||||
}
|
||||
@@ -2544,7 +2544,7 @@ describe("MissionStore", () => {
|
||||
|
||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||
it("schema version is 32 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(32);
|
||||
expect(db.getSchemaVersion()).toBe(33);
|
||||
});
|
||||
|
||||
it("mission_features table has loop state columns", () => {
|
||||
|
||||
@@ -739,7 +739,7 @@ describe("RoadmapStore", () => {
|
||||
|
||||
describe("schema version", () => {
|
||||
it("schema version is 32 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(32);
|
||||
expect(db.getSchemaVersion()).toBe(33);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 28", () => {
|
||||
expect(db.getSchemaVersion()).toBe(32);
|
||||
expect(db.getSchemaVersion()).toBe(33);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user