feat(FN-5140): merge fusion/fn-5140

This commit is contained in:
gsxdsm
2026-05-19 10:29:42 -07:00
parent ba1604835b
commit abfbbcda1e
4 changed files with 153 additions and 31 deletions

View File

@@ -280,18 +280,115 @@ describe("TaskStore task documents", () => {
);
});
it("preserves task documents when a task is soft-deleted", async () => {
const task = await store.createTask({ description: "Soft delete docs task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
describe("FN-5140: soft-deleted task document visibility", () => {
it("excludes documents for soft-deleted parents from getAllDocuments with and without search", async () => {
const liveTask = await store.createTask({
title: "Live task for FN-5140",
description: "Live task for getAllDocuments coverage",
});
const deletedTask = await store.createTask({
title: "Deleted task for FN-5140",
description: "Deleted task for getAllDocuments coverage",
});
await store.deleteTask(task.id);
await store.upsertTaskDocument(liveTask.id, { key: "plan", content: "shared visibility token" });
await store.upsertTaskDocument(deletedTask.id, { key: "notes", content: "shared visibility token" });
await store.deleteTask(deletedTask.id);
const documents = await store.getTaskDocuments(task.id);
expect(documents).toHaveLength(1);
for (const options of [undefined, { searchQuery: "shared visibility token" }]) {
const documents = await store.getAllDocuments(options);
expect(documents).toHaveLength(1);
expect(documents[0]?.taskId).toBe(liveTask.id);
expect(documents[0]?.key).toBe("plan");
}
});
const document = await store.getTaskDocument(task.id, "plan");
expect(document?.content).toBe("v1");
}, 15_000);
it("keeps task_documents rows stored after the parent task is soft-deleted", async () => {
const task = await store.createTask({ description: "Stored but hidden doc task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
await store.deleteTask(task.id);
const row = db
.prepare("SELECT COUNT(*) as count FROM task_documents WHERE taskId = ?")
.get(task.id) as { count: number };
expect(row.count).toBe(1);
});
it("returns [] from getTaskDocuments for a soft-deleted parent", async () => {
const task = await store.createTask({ description: "Soft-deleted list doc task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
await store.deleteTask(task.id);
await expect(store.getTaskDocuments(task.id)).resolves.toEqual([]);
});
it("returns null from getTaskDocument for a soft-deleted parent", async () => {
const task = await store.createTask({ description: "Soft-deleted get doc task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
await store.deleteTask(task.id);
await expect(store.getTaskDocument(task.id, "plan")).resolves.toBeNull();
});
it("returns [] from getTaskDocumentRevisions for a soft-deleted parent", async () => {
const task = await store.createTask({ description: "Soft-deleted revision doc task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v2" });
await store.deleteTask(task.id);
await expect(store.getTaskDocumentRevisions(task.id, "plan")).resolves.toEqual([]);
});
it("still refuses upsertTaskDocument for a soft-deleted parent", async () => {
const task = await store.createTask({ description: "Soft-deleted upsert doc task" });
await store.deleteTask(task.id);
await expect(
store.upsertTaskDocument(task.id, { key: "plan", content: "v1" }),
).rejects.toThrow(`Task ${task.id} not found`);
});
it("still allows deleteTaskDocument for a soft-deleted parent forensic cleanup", async () => {
const task = await store.createTask({ description: "Soft-deleted delete doc task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v2" });
await store.deleteTask(task.id);
await expect(store.deleteTaskDocument(task.id, "plan")).resolves.toBeUndefined();
const row = db
.prepare("SELECT COUNT(*) as count FROM task_documents WHERE taskId = ?")
.get(task.id) as { count: number };
expect(row.count).toBe(0);
});
it("leaves live task document reads unaffected when another task is soft-deleted", async () => {
const liveTask = await store.createTask({ description: "Live control doc task" });
const deletedTask = await store.createTask({ description: "Deleted sibling doc task" });
await store.upsertTaskDocument(liveTask.id, { key: "plan", content: "v1" });
await store.upsertTaskDocument(liveTask.id, { key: "plan", content: "v2" });
await store.upsertTaskDocument(deletedTask.id, { key: "notes", content: "hidden" });
await store.deleteTask(deletedTask.id);
const allDocuments = await store.getAllDocuments();
expect(allDocuments.map((document) => document.taskId)).toEqual([liveTask.id]);
const taskDocuments = await store.getTaskDocuments(liveTask.id);
expect(taskDocuments).toHaveLength(1);
expect(taskDocuments[0]?.key).toBe("plan");
const taskDocument = await store.getTaskDocument(liveTask.id, "plan");
expect(taskDocument?.content).toBe("v2");
const revisions = await store.getTaskDocumentRevisions(liveTask.id, "plan");
expect(revisions.map((revision) => revision.revision)).toEqual([1]);
});
});
it("accepts valid key edge cases and rejects invalid ones", async () => {
const task = await store.createTask({ description: "Key edge case task" });

View File

@@ -7352,10 +7352,21 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return task;
}
private hasActiveTask(taskId: string): boolean {
const row = this.db.prepare(`SELECT id FROM tasks WHERE id = ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`).get(taskId) as
| { id: string }
| undefined;
return Boolean(row);
}
/**
* List all current task documents for a task, ordered by key.
*/
async getTaskDocuments(taskId: string): Promise<TaskDocument[]> {
if (!this.hasActiveTask(taskId)) {
return [];
}
const rows = this.db
.prepare("SELECT * FROM task_documents WHERE taskId = ? ORDER BY key")
.all(taskId) as unknown as TaskDocumentRow[];
@@ -7378,12 +7389,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
SELECT td.*, t.title as taskTitle, t.description as taskDescription, t.column as taskColumn
FROM task_documents td
JOIN tasks t ON td.taskId = t.id
WHERE t.${TaskStore.ACTIVE_TASKS_WHERE}
`;
const params: (string | number)[] = [];
if (options?.searchQuery && options.searchQuery.trim() !== "") {
const query = `%${options.searchQuery.trim()}%`;
sql += ` WHERE td.key LIKE ? OR td.content LIKE ? OR t.title LIKE ?`;
sql += ` AND (td.key LIKE ? OR td.content LIKE ? OR t.title LIKE ?)`;
params.push(query, query, query);
}
@@ -7406,6 +7418,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* Get the current revision of a specific task document.
*/
async getTaskDocument(taskId: string, key: string): Promise<TaskDocument | null> {
if (!this.hasActiveTask(taskId)) {
return null;
}
const row = this.db
.prepare("SELECT * FROM task_documents WHERE taskId = ? AND key = ?")
.get(taskId, key) as unknown as TaskDocumentRow | undefined;
@@ -7517,6 +7533,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
key: string,
options?: { limit?: number },
): Promise<TaskDocumentRevision[]> {
if (!this.hasActiveTask(taskId)) {
return [];
}
const hasLimit = options?.limit !== undefined;
const rows = hasLimit
? (this.db
@@ -7535,6 +7555,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
/**
* Delete a task document and all archived revisions for its key.
* Read paths gate on the parent task's active state, but deletes remain allowed
* for forensic cleanup against soft-deleted parents.
*/
async deleteTaskDocument(taskId: string, key: string): Promise<void> {
const existing = this.db
@@ -7560,8 +7582,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
this.db.bumpLastModified();
const task = await this.getTask(taskId);
this.emit("task:updated", task);
const task = this.readTaskFromDb(taskId, { includeDeleted: true });
if (task && task.deletedAt == null) {
this.emit("task:updated", task);
}
}
private getTaskPrInfos(task: Task): import("./types.js").PrInfo[] {