feat(FN-2575): merge fusion/fn-2575
This commit is contained in:
@@ -131,7 +131,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -154,7 +154,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -761,7 +761,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -786,11 +786,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -825,7 +825,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("priority");
|
||||
@@ -866,7 +866,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -935,7 +935,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -976,7 +976,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
|
||||
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" }]);
|
||||
@@ -1000,7 +1000,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
|
||||
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" }]);
|
||||
@@ -1104,7 +1104,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1473,7 +1473,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -776,7 +776,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(45);
|
||||
expect(db1.getSchemaVersion()).toBe(46);
|
||||
db1.close();
|
||||
|
||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||
@@ -811,7 +811,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(45);
|
||||
expect(db3.getSchemaVersion()).toBe(46);
|
||||
|
||||
// Step 4: Verify insight tables exist after migration
|
||||
const tablesAfter = db3.prepare(
|
||||
@@ -842,12 +842,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
try {
|
||||
const db1 = createDatabase(testDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(45);
|
||||
expect(db1.getSchemaVersion()).toBe(46);
|
||||
db1.close();
|
||||
|
||||
const db2 = createDatabase(testDir);
|
||||
expect(() => db2.init()).not.toThrow();
|
||||
expect(db2.getSchemaVersion()).toBe(45);
|
||||
expect(db2.getSchemaVersion()).toBe(46);
|
||||
db2.close();
|
||||
} finally {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
|
||||
@@ -2626,7 +2626,7 @@ describe("MissionStore", () => {
|
||||
|
||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||
it("schema version is 40 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
});
|
||||
|
||||
it("mission_features table has loop state columns", () => {
|
||||
|
||||
@@ -739,7 +739,7 @@ describe("RoadmapStore", () => {
|
||||
|
||||
describe("schema version", () => {
|
||||
it("schema version is 40 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(45);
|
||||
expect(db.getSchemaVersion()).toBe(46);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
263
packages/core/src/__tests__/todo-store.test.ts
Normal file
263
packages/core/src/__tests__/todo-store.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDatabase, type Database } from "../db.js";
|
||||
import { TodoStore } from "../todo-store.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "fn-todo-store-"));
|
||||
}
|
||||
|
||||
let fusionDir: string;
|
||||
let db: Database;
|
||||
let store: TodoStore;
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(fusionDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fusionDir = makeTmpDir();
|
||||
db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
store = new TodoStore(db);
|
||||
});
|
||||
|
||||
describe("TodoStore", () => {
|
||||
describe("list CRUD", () => {
|
||||
it("createList returns a list with generated id and timestamps", () => {
|
||||
const list = store.createList("proj-a", { title: "Inbox" });
|
||||
|
||||
expect(list.id).toMatch(/^TDL-[A-Z0-9]+-[A-Z0-9]+$/);
|
||||
expect(list.projectId).toBe("proj-a");
|
||||
expect(list.title).toBe("Inbox");
|
||||
expect(list.createdAt).toBeTruthy();
|
||||
expect(list.updatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("getList returns list by id and undefined when missing", () => {
|
||||
const list = store.createList("proj-a", { title: "Backlog" });
|
||||
|
||||
expect(store.getList(list.id)).toEqual(list);
|
||||
expect(store.getList("TDL-MISSING")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("listLists returns lists ordered by createdAt and scoped by project", () => {
|
||||
const now = new Date();
|
||||
db.prepare(
|
||||
"INSERT INTO todo_lists (id, projectId, title, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("TDL-OLD", "proj-a", "Older", new Date(now.getTime() - 10_000).toISOString(), new Date(now.getTime() - 10_000).toISOString());
|
||||
db.prepare(
|
||||
"INSERT INTO todo_lists (id, projectId, title, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("TDL-NEW", "proj-a", "Newer", now.toISOString(), now.toISOString());
|
||||
store.createList("proj-b", { title: "Other project" });
|
||||
|
||||
const lists = store.listLists("proj-a");
|
||||
expect(lists.map((l) => l.id)).toEqual(["TDL-OLD", "TDL-NEW"]);
|
||||
});
|
||||
|
||||
it("updateList updates title and updatedAt; returns undefined when missing", () => {
|
||||
const list = store.createList("proj-a", { title: "Before" });
|
||||
const updated = store.updateList(list.id, { title: "After" });
|
||||
|
||||
expect(updated).toBeDefined();
|
||||
expect(updated?.title).toBe("After");
|
||||
expect(updated?.updatedAt >= list.updatedAt).toBe(true);
|
||||
expect(store.updateList("TDL-MISSING", { title: "x" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deleteList removes list, returns true/false, and cascades items", () => {
|
||||
const list = store.createList("proj-a", { title: "Delete me" });
|
||||
const item = store.createItem(list.id, { text: "child" });
|
||||
|
||||
expect(store.deleteList(list.id)).toBe(true);
|
||||
expect(store.getList(list.id)).toBeUndefined();
|
||||
expect(store.getItem(item.id)).toBeUndefined();
|
||||
expect(store.deleteList(list.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("item CRUD", () => {
|
||||
it("createItem auto-increments sortOrder and accepts explicit sortOrder", () => {
|
||||
const list = store.createList("proj-a", { title: "L" });
|
||||
const first = store.createItem(list.id, { text: "first" });
|
||||
const second = store.createItem(list.id, { text: "second" });
|
||||
const explicit = store.createItem(list.id, { text: "explicit", sortOrder: 10 });
|
||||
|
||||
expect(first.sortOrder).toBe(0);
|
||||
expect(second.sortOrder).toBe(1);
|
||||
expect(explicit.sortOrder).toBe(10);
|
||||
});
|
||||
|
||||
it("getItem retrieves an item by id", () => {
|
||||
const list = store.createList("proj-a", { title: "L" });
|
||||
const item = store.createItem(list.id, { text: "fetch me" });
|
||||
|
||||
expect(store.getItem(item.id)).toEqual(item);
|
||||
expect(store.getItem("TDI-MISSING")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("listItems returns items ordered by sortOrder then createdAt", () => {
|
||||
const list = store.createList("proj-a", { title: "L" });
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
`INSERT INTO todo_items (id, listId, text, completed, completedAt, sortOrder, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run("TDI-B", list.id, "b", 0, null, 0, now, now);
|
||||
db.prepare(
|
||||
`INSERT INTO todo_items (id, listId, text, completed, completedAt, sortOrder, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run("TDI-A", list.id, "a", 0, null, 0, new Date(Date.now() - 1000).toISOString(), new Date(Date.now() - 1000).toISOString());
|
||||
db.prepare(
|
||||
`INSERT INTO todo_items (id, listId, text, completed, completedAt, sortOrder, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run("TDI-C", list.id, "c", 0, null, 1, now, now);
|
||||
|
||||
const items = store.listItems(list.id);
|
||||
expect(items.map((i) => i.id)).toEqual(["TDI-A", "TDI-B", "TDI-C"]);
|
||||
});
|
||||
|
||||
it("updateItem updates text and bumps updatedAt", () => {
|
||||
const list = store.createList("proj-a", { title: "L" });
|
||||
const item = store.createItem(list.id, { text: "before" });
|
||||
|
||||
const updated = store.updateItem(item.id, { text: "after" });
|
||||
expect(updated?.text).toBe("after");
|
||||
expect(updated?.updatedAt >= item.updatedAt).toBe(true);
|
||||
});
|
||||
|
||||
it("toggleItem flips completion and completedAt", () => {
|
||||
const list = store.createList("proj-a", { title: "L" });
|
||||
const item = store.createItem(list.id, { text: "toggle" });
|
||||
|
||||
const completed = store.toggleItem(item.id)!;
|
||||
expect(completed.completed).toBe(true);
|
||||
expect(completed.completedAt).toBeTruthy();
|
||||
|
||||
const reopened = store.toggleItem(item.id)!;
|
||||
expect(reopened.completed).toBe(false);
|
||||
expect(reopened.completedAt).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ completed: true, expectedCompletedAt: "set" },
|
||||
{ completed: false, expectedCompletedAt: "cleared" },
|
||||
])("updateItem handles completed=$completed by setting/clearing completedAt", ({ completed, expectedCompletedAt }) => {
|
||||
const list = store.createList("proj-a", { title: "L" });
|
||||
const item = store.createItem(list.id, { text: "status" });
|
||||
if (!completed) {
|
||||
store.updateItem(item.id, { completed: true });
|
||||
}
|
||||
|
||||
const updated = store.updateItem(item.id, { completed })!;
|
||||
expect(updated.completed).toBe(completed);
|
||||
if (expectedCompletedAt === "set") {
|
||||
expect(updated.completedAt).toBeTruthy();
|
||||
} else {
|
||||
expect(updated.completedAt).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("deleteItem removes item and returns true/false", () => {
|
||||
const list = store.createList("proj-a", { title: "L" });
|
||||
const item = store.createItem(list.id, { text: "x" });
|
||||
|
||||
expect(store.deleteItem(item.id)).toBe(true);
|
||||
expect(store.getItem(item.id)).toBeUndefined();
|
||||
expect(store.deleteItem(item.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("reorderItems reassigns sortOrder and validates list membership", () => {
|
||||
const list = store.createList("proj-a", { title: "L" });
|
||||
const i1 = store.createItem(list.id, { text: "1" });
|
||||
const i2 = store.createItem(list.id, { text: "2" });
|
||||
const i3 = store.createItem(list.id, { text: "3" });
|
||||
|
||||
const reordered = store.reorderItems(list.id, [i3.id, i1.id, i2.id]);
|
||||
expect(reordered.map((i) => [i.id, i.sortOrder])).toEqual([
|
||||
[i3.id, 0],
|
||||
[i1.id, 1],
|
||||
[i2.id, 2],
|
||||
]);
|
||||
|
||||
const other = store.createList("proj-a", { title: "Other" });
|
||||
const otherItem = store.createItem(other.id, { text: "other" });
|
||||
expect(() => store.reorderItems(list.id, [i1.id, i2.id, otherItem.id])).toThrow(/does not belong to list/);
|
||||
expect(() => store.reorderItems(list.id, [i1.id, i2.id])).toThrow(/must include all items/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composite queries", () => {
|
||||
it("getListsWithItems returns all lists with populated items", () => {
|
||||
const l1 = store.createList("proj-a", { title: "A" });
|
||||
const l2 = store.createList("proj-a", { title: "B" });
|
||||
const i1 = store.createItem(l1.id, { text: "a1" });
|
||||
const i2 = store.createItem(l1.id, { text: "a2" });
|
||||
const i3 = store.createItem(l2.id, { text: "b1" });
|
||||
|
||||
const lists = store.getListsWithItems("proj-a");
|
||||
expect(lists).toHaveLength(2);
|
||||
expect(lists.find((l) => l.id === l1.id)?.items.map((i) => i.id)).toEqual([i1.id, i2.id]);
|
||||
expect(lists.find((l) => l.id === l2.id)?.items.map((i) => i.id)).toEqual([i3.id]);
|
||||
});
|
||||
|
||||
it("getListsWithItems is scoped by projectId", () => {
|
||||
const listA = store.createList("proj-a", { title: "A" });
|
||||
store.createItem(listA.id, { text: "a1" });
|
||||
const listB = store.createList("proj-b", { title: "B" });
|
||||
store.createItem(listB.id, { text: "b1" });
|
||||
|
||||
const lists = store.getListsWithItems("proj-a");
|
||||
expect(lists).toHaveLength(1);
|
||||
expect(lists[0].id).toBe(listA.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("event emissions", () => {
|
||||
it("emits list events with expected payloads", () => {
|
||||
const createdHandler = vi.fn();
|
||||
const updatedHandler = vi.fn();
|
||||
const deletedHandler = vi.fn();
|
||||
store.on("list:created", createdHandler);
|
||||
store.on("list:updated", updatedHandler);
|
||||
store.on("list:deleted", deletedHandler);
|
||||
|
||||
const list = store.createList("proj-a", { title: "Events" });
|
||||
const updated = store.updateList(list.id, { title: "Events 2" })!;
|
||||
store.deleteList(list.id);
|
||||
|
||||
expect(createdHandler).toHaveBeenCalledWith(list);
|
||||
expect(updatedHandler).toHaveBeenCalledWith(updated);
|
||||
expect(deletedHandler).toHaveBeenCalledWith(list.id);
|
||||
});
|
||||
|
||||
it("emits item and reorder events", () => {
|
||||
const list = store.createList("proj-a", { title: "Events" });
|
||||
const createdHandler = vi.fn();
|
||||
const updatedHandler = vi.fn();
|
||||
const deletedHandler = vi.fn();
|
||||
const reorderedHandler = vi.fn();
|
||||
|
||||
store.on("item:created", createdHandler);
|
||||
store.on("item:updated", updatedHandler);
|
||||
store.on("item:deleted", deletedHandler);
|
||||
store.on("items:reordered", reorderedHandler);
|
||||
|
||||
const a = store.createItem(list.id, { text: "A" });
|
||||
const b = store.createItem(list.id, { text: "B" });
|
||||
const updated = store.updateItem(a.id, { text: "A+" })!;
|
||||
store.reorderItems(list.id, [b.id, a.id]);
|
||||
store.deleteItem(a.id);
|
||||
|
||||
expect(createdHandler).toHaveBeenCalledTimes(2);
|
||||
expect(updatedHandler).toHaveBeenCalledWith(updated);
|
||||
expect(reorderedHandler).toHaveBeenCalledWith({
|
||||
listId: list.id,
|
||||
items: expect.any(Array),
|
||||
});
|
||||
expect(deletedHandler).toHaveBeenCalledWith(a.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -86,7 +86,7 @@ export function probeFts5(db: DatabaseSync): boolean {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 45;
|
||||
const SCHEMA_VERSION = 46;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -619,6 +619,33 @@ CREATE INDEX IF NOT EXISTS idxProjectInsightsCategory
|
||||
-- Index for filtering runs by projectId
|
||||
CREATE INDEX IF NOT EXISTS idxInsightRunsProjectId
|
||||
ON project_insight_runs(projectId);
|
||||
|
||||
-- Todo list persistence tables (FN-2575)
|
||||
-- Project-scoped todo lists and ordered checklist items
|
||||
|
||||
CREATE TABLE IF NOT EXISTS todo_lists (
|
||||
id TEXT PRIMARY KEY,
|
||||
projectId TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS todo_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
listId TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
completedAt TEXT,
|
||||
sortOrder INTEGER NOT NULL DEFAULT 0,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
FOREIGN KEY (listId) REFERENCES todo_lists(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idxTodoListsProjectId ON todo_lists(projectId);
|
||||
CREATE INDEX IF NOT EXISTS idxTodoItemsListId ON todo_items(listId);
|
||||
CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder);
|
||||
`;
|
||||
|
||||
// ── Database Class ───────────────────────────────────────────────────
|
||||
@@ -1753,6 +1780,38 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 46) {
|
||||
this.applyMigration(46, () => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS todo_lists (
|
||||
id TEXT PRIMARY KEY,
|
||||
projectId TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS todo_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
listId TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
completedAt TEXT,
|
||||
sortOrder INTEGER NOT NULL DEFAULT 0,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
FOREIGN KEY (listId) REFERENCES todo_lists(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.exec("CREATE INDEX IF NOT EXISTS idxTodoListsProjectId ON todo_lists(projectId)");
|
||||
this.db.exec("CREATE INDEX IF NOT EXISTS idxTodoItemsListId ON todo_items(listId)");
|
||||
this.db.exec("CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder)");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export {
|
||||
BUILTIN_AGENT_PROMPTS,
|
||||
@@ -532,6 +532,8 @@ export type {
|
||||
InsightRunListOptions,
|
||||
InsightStoreEvents,
|
||||
} from "./insight-types.js";
|
||||
export { TodoStore } from "./todo-store.js";
|
||||
export type { TodoStoreEvents } from "./todo-store.js";
|
||||
|
||||
// ── Agent Companies Types ──────────────────────────────────
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MissionStore } from "./mission-store.js";
|
||||
import { PluginStore } from "./plugin-store.js";
|
||||
import { RoadmapStore } from "./roadmap-store.js";
|
||||
import { InsightStore } from "./insight-store.js";
|
||||
import { TodoStore } from "./todo-store.js";
|
||||
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
|
||||
import { CentralCore } from "./central-core.js";
|
||||
import { getTaskMergeBlocker } from "./task-merge.js";
|
||||
@@ -359,6 +360,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
private roadmapStore: RoadmapStore | null = null;
|
||||
/** Cached InsightStore instance */
|
||||
private insightStore: InsightStore | null = null;
|
||||
/** Cached TodoStore instance */
|
||||
private todoStore: TodoStore | null = null;
|
||||
|
||||
constructor(private rootDir: string, globalSettingsDir?: string) {
|
||||
super();
|
||||
@@ -5666,6 +5669,17 @@ ${notificationsSection}`;
|
||||
return this.insightStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the TodoStore instance for project-scoped todo list operations.
|
||||
* Lazily initializes the TodoStore on first access.
|
||||
*/
|
||||
getTodoStore(): TodoStore {
|
||||
if (!this.todoStore) {
|
||||
this.todoStore = new TodoStore(this.db);
|
||||
}
|
||||
return this.todoStore;
|
||||
}
|
||||
|
||||
// ── Backward Compatibility (Multi-Project Support) ────────────────────────
|
||||
|
||||
}
|
||||
|
||||
297
packages/core/src/todo-store.ts
Normal file
297
packages/core/src/todo-store.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Database } from "./db.js";
|
||||
import type {
|
||||
TodoList,
|
||||
TodoItem,
|
||||
TodoListCreateInput,
|
||||
TodoListUpdateInput,
|
||||
TodoItemCreateInput,
|
||||
TodoItemUpdateInput,
|
||||
TodoListWithItems,
|
||||
} from "./types.js";
|
||||
|
||||
export interface TodoStoreEvents {
|
||||
"list:created": [TodoList];
|
||||
"list:updated": [TodoList];
|
||||
"list:deleted": [string];
|
||||
"item:created": [TodoItem];
|
||||
"item:updated": [TodoItem];
|
||||
"item:deleted": [string];
|
||||
"items:reordered": [{ listId: string; items: TodoItem[] }];
|
||||
}
|
||||
|
||||
interface TodoListRow {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TodoItemRow {
|
||||
id: string;
|
||||
listId: string;
|
||||
text: string;
|
||||
completed: number;
|
||||
completedAt: string | null;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export class TodoStore extends EventEmitter<TodoStoreEvents> {
|
||||
constructor(private db: Database) {
|
||||
super();
|
||||
this.setMaxListeners(50);
|
||||
}
|
||||
|
||||
getDatabase(): Database {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
private generateListId(): string {
|
||||
const timestamp = Date.now().toString(36).toUpperCase();
|
||||
const random = Math.random().toString(36).slice(2, 6).toUpperCase();
|
||||
return `TDL-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
private generateItemId(): string {
|
||||
const timestamp = Date.now().toString(36).toUpperCase();
|
||||
const random = Math.random().toString(36).slice(2, 6).toUpperCase();
|
||||
return `TDI-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
private rowToTodoList(row: TodoListRow): TodoList {
|
||||
return {
|
||||
id: row.id,
|
||||
projectId: row.projectId,
|
||||
title: row.title,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private rowToTodoItem(row: TodoItemRow): TodoItem {
|
||||
return {
|
||||
id: row.id,
|
||||
listId: row.listId,
|
||||
text: row.text,
|
||||
completed: row.completed === 1,
|
||||
completedAt: row.completedAt,
|
||||
sortOrder: row.sortOrder,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
createList(projectId: string, input: TodoListCreateInput): TodoList {
|
||||
const now = new Date().toISOString();
|
||||
const list: TodoList = {
|
||||
id: this.generateListId(),
|
||||
projectId,
|
||||
title: input.title,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db.prepare(
|
||||
"INSERT INTO todo_lists (id, projectId, title, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
||||
).run(list.id, list.projectId, list.title, list.createdAt, list.updatedAt);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("list:created", list);
|
||||
return list;
|
||||
}
|
||||
|
||||
getList(id: string): TodoList | undefined {
|
||||
const row = this.db.prepare("SELECT * FROM todo_lists WHERE id = ?").get(id) as TodoListRow | undefined;
|
||||
return row ? this.rowToTodoList(row) : undefined;
|
||||
}
|
||||
|
||||
listLists(projectId: string): TodoList[] {
|
||||
const rows = this.db.prepare(
|
||||
"SELECT * FROM todo_lists WHERE projectId = ? ORDER BY createdAt ASC, id ASC"
|
||||
).all(projectId) as unknown as TodoListRow[];
|
||||
return rows.map((row) => this.rowToTodoList(row));
|
||||
}
|
||||
|
||||
updateList(id: string, input: TodoListUpdateInput): TodoList | undefined {
|
||||
const existing = this.getList(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const title = input.title ?? existing.title;
|
||||
|
||||
this.db.prepare("UPDATE todo_lists SET title = ?, updatedAt = ? WHERE id = ?").run(title, now, id);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
const updated = this.getList(id)!;
|
||||
this.emit("list:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
deleteList(id: string): boolean {
|
||||
const result = this.db.prepare("DELETE FROM todo_lists WHERE id = ?").run(id) as { changes?: number };
|
||||
if ((result.changes ?? 0) < 1) return false;
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("list:deleted", id);
|
||||
return true;
|
||||
}
|
||||
|
||||
createItem(listId: string, input: TodoItemCreateInput): TodoItem {
|
||||
const list = this.getList(listId);
|
||||
if (!list) {
|
||||
throw new Error(`Todo list ${listId} not found`);
|
||||
}
|
||||
|
||||
const nextSortOrder = (() => {
|
||||
if (input.sortOrder !== undefined) return input.sortOrder;
|
||||
const row = this.db.prepare("SELECT MAX(sortOrder) AS maxSortOrder FROM todo_items WHERE listId = ?").get(listId) as
|
||||
| { maxSortOrder: number | null }
|
||||
| undefined;
|
||||
return (row?.maxSortOrder ?? -1) + 1;
|
||||
})();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const item: TodoItem = {
|
||||
id: this.generateItemId(),
|
||||
listId,
|
||||
text: input.text,
|
||||
completed: false,
|
||||
completedAt: null,
|
||||
sortOrder: nextSortOrder,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db.prepare(
|
||||
`INSERT INTO todo_items
|
||||
(id, listId, text, completed, completedAt, sortOrder, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(item.id, item.listId, item.text, 0, null, item.sortOrder, item.createdAt, item.updatedAt);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("item:created", item);
|
||||
return item;
|
||||
}
|
||||
|
||||
getItem(id: string): TodoItem | undefined {
|
||||
const row = this.db.prepare("SELECT * FROM todo_items WHERE id = ?").get(id) as TodoItemRow | undefined;
|
||||
return row ? this.rowToTodoItem(row) : undefined;
|
||||
}
|
||||
|
||||
listItems(listId: string): TodoItem[] {
|
||||
const rows = this.db.prepare(
|
||||
"SELECT * FROM todo_items WHERE listId = ? ORDER BY sortOrder ASC, createdAt ASC, id ASC"
|
||||
).all(listId) as unknown as TodoItemRow[];
|
||||
return rows.map((row) => this.rowToTodoItem(row));
|
||||
}
|
||||
|
||||
updateItem(id: string, input: TodoItemUpdateInput): TodoItem | undefined {
|
||||
const existing = this.getItem(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const sets: string[] = ["updatedAt = ?"];
|
||||
const params: Array<string | number | null> = [now];
|
||||
|
||||
if (input.text !== undefined) {
|
||||
sets.push("text = ?");
|
||||
params.push(input.text);
|
||||
}
|
||||
|
||||
if (input.sortOrder !== undefined) {
|
||||
sets.push("sortOrder = ?");
|
||||
params.push(input.sortOrder);
|
||||
}
|
||||
|
||||
if (input.completed !== undefined) {
|
||||
sets.push("completed = ?");
|
||||
params.push(input.completed ? 1 : 0);
|
||||
sets.push("completedAt = ?");
|
||||
params.push(input.completed ? now : null);
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
this.db.prepare(`UPDATE todo_items SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
const updated = this.getItem(id)!;
|
||||
this.emit("item:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
deleteItem(id: string): boolean {
|
||||
const result = this.db.prepare("DELETE FROM todo_items WHERE id = ?").run(id) as { changes?: number };
|
||||
if ((result.changes ?? 0) < 1) return false;
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("item:deleted", id);
|
||||
return true;
|
||||
}
|
||||
|
||||
toggleItem(id: string): TodoItem | undefined {
|
||||
const existing = this.getItem(id);
|
||||
if (!existing) return undefined;
|
||||
return this.updateItem(id, { completed: !existing.completed });
|
||||
}
|
||||
|
||||
reorderItems(listId: string, itemIds: string[]): TodoItem[] {
|
||||
const items = this.listItems(listId);
|
||||
const existingIds = items.map((item) => item.id);
|
||||
|
||||
if (new Set(itemIds).size !== itemIds.length) {
|
||||
throw new Error("Cannot reorder items: duplicate item IDs provided");
|
||||
}
|
||||
|
||||
if (existingIds.length !== itemIds.length) {
|
||||
throw new Error("Cannot reorder items: provided IDs must include all items in the list");
|
||||
}
|
||||
|
||||
const existingIdSet = new Set(existingIds);
|
||||
for (const itemId of itemIds) {
|
||||
if (!existingIdSet.has(itemId)) {
|
||||
throw new Error(`Cannot reorder items: item ${itemId} does not belong to list ${listId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
this.db.transaction(() => {
|
||||
for (let index = 0; index < itemIds.length; index++) {
|
||||
this.db
|
||||
.prepare("UPDATE todo_items SET sortOrder = ?, updatedAt = ? WHERE id = ? AND listId = ?")
|
||||
.run(index, now, itemIds[index], listId);
|
||||
}
|
||||
});
|
||||
|
||||
this.db.bumpLastModified();
|
||||
const reordered = this.listItems(listId);
|
||||
this.emit("items:reordered", { listId, items: reordered });
|
||||
return reordered;
|
||||
}
|
||||
|
||||
getListsWithItems(projectId: string): TodoListWithItems[] {
|
||||
const lists = this.listLists(projectId);
|
||||
if (lists.length === 0) return [];
|
||||
|
||||
const rows = this.db.prepare(
|
||||
`SELECT * FROM todo_items
|
||||
WHERE listId IN (SELECT id FROM todo_lists WHERE projectId = ?)
|
||||
ORDER BY listId ASC, sortOrder ASC, createdAt ASC, id ASC`
|
||||
).all(projectId) as unknown as TodoItemRow[];
|
||||
|
||||
const itemsByListId = new Map<string, TodoItem[]>();
|
||||
for (const row of rows) {
|
||||
const item = this.rowToTodoItem(row);
|
||||
const listItems = itemsByListId.get(item.listId) ?? [];
|
||||
listItems.push(item);
|
||||
itemsByListId.set(item.listId, listItems);
|
||||
}
|
||||
|
||||
return lists.map((list) => ({
|
||||
...list,
|
||||
items: itemsByListId.get(list.id) ?? [],
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -925,6 +925,50 @@ export interface TaskCreateInput {
|
||||
executionMode?: ExecutionMode;
|
||||
}
|
||||
|
||||
// ── Todo List Types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface TodoList {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TodoItem {
|
||||
id: string;
|
||||
listId: string;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface TodoListCreateInput {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TodoListUpdateInput {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface TodoItemCreateInput {
|
||||
text: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface TodoItemUpdateInput {
|
||||
text?: string;
|
||||
completed?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface TodoListWithItems extends TodoList {
|
||||
items: TodoItem[];
|
||||
}
|
||||
|
||||
// ── Settings Scope Types ────────────────────────────────────────────────
|
||||
//
|
||||
// Settings are split into two scopes:
|
||||
|
||||
Reference in New Issue
Block a user