feat(FN-5140): merge fusion/fn-5140
This commit is contained in:
@@ -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" });
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user