feat(FN-5566): add soft-delete cleanup sweep for blocker residue
Added soft-delete reliability sweeps and guardrails to prevent blocker residue from persisting across delete operations, including column drift detection, deleted row sweep guards, and in-progress delete reconciliation, with comprehensive test coverage and documentation updates to the soft-delete ve Fusion-Task-Id: FN-5566 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> Fusion-Task-Id: FN-5566
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||
|
||||
describe("TaskStore deleteTask blocker residue rewrite (FN-5566)", () => {
|
||||
const harness = createTaskStoreTestHarness();
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.beforeEach();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.afterEach();
|
||||
});
|
||||
|
||||
it("clears dependencies + blockedBy + status and appends auto-unblocked log when blocker is referenced by both", async () => {
|
||||
const store = harness.store();
|
||||
const blocker = await store.createTask({ column: "todo", description: "blocker" });
|
||||
const dependent = await store.createTask({ column: "todo", description: "dependent", dependencies: [blocker.id] });
|
||||
await store.updateTask(dependent.id, { blockedBy: blocker.id, status: "queued" });
|
||||
|
||||
await store.deleteTask(blocker.id, { removeDependencyReferences: true });
|
||||
const updated = await store.getTask(dependent.id);
|
||||
|
||||
expect(updated.dependencies).not.toContain(blocker.id);
|
||||
expect(updated.blockedBy).toBeUndefined();
|
||||
expect(updated.status).toBeUndefined();
|
||||
expect(updated.log.some((entry) => entry.action === `Auto-unblocked: blocker ${blocker.id} was soft-deleted`)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears blockedBy-only residue while preserving dependencies", async () => {
|
||||
const store = harness.store();
|
||||
const blocker = await store.createTask({ column: "todo", description: "blocker" });
|
||||
const other = await store.createTask({ column: "todo", description: "other" });
|
||||
const dependent = await store.createTask({ column: "todo", description: "dependent", dependencies: [other.id] });
|
||||
await store.updateTask(dependent.id, { blockedBy: blocker.id, status: "queued" });
|
||||
|
||||
await store.deleteTask(blocker.id, { removeDependencyReferences: true });
|
||||
const updated = await store.getTask(dependent.id);
|
||||
|
||||
expect(updated.dependencies).toEqual([other.id]);
|
||||
expect(updated.blockedBy).toBeUndefined();
|
||||
expect(updated.status).toBeUndefined();
|
||||
expect(updated.log.some((entry) => entry.action === `Auto-unblocked: blocker ${blocker.id} was soft-deleted`)).toBe(true);
|
||||
});
|
||||
|
||||
it("filters dependency without adding auto-unblocked log when blockedBy is already null", async () => {
|
||||
const store = harness.store();
|
||||
const blocker = await store.createTask({ column: "todo", description: "blocker" });
|
||||
const dependent = await store.createTask({ column: "todo", description: "dependent", dependencies: [blocker.id] });
|
||||
|
||||
await store.deleteTask(blocker.id, { removeDependencyReferences: true });
|
||||
const updated = await store.getTask(dependent.id);
|
||||
|
||||
expect(updated.dependencies).toEqual([]);
|
||||
expect(updated.blockedBy).toBeUndefined();
|
||||
expect(updated.log.some((entry) => entry.action === `Auto-unblocked: blocker ${blocker.id} was soft-deleted`)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves unrelated tasks untouched", async () => {
|
||||
const store = harness.store();
|
||||
const blocker = await store.createTask({ column: "todo", description: "blocker" });
|
||||
const unrelated = await store.createTask({ column: "todo", description: "unrelated", dependencies: [] });
|
||||
|
||||
await store.deleteTask(blocker.id, { removeDependencyReferences: true });
|
||||
const after = await store.getTask(unrelated.id);
|
||||
|
||||
expect(after.blockedBy).toBeUndefined();
|
||||
expect(after.dependencies).toEqual([]);
|
||||
expect(after.log.some((entry) => entry.action.includes("Auto-unblocked"))).toBe(false);
|
||||
});
|
||||
|
||||
it("never rewrites already soft-deleted dependents", async () => {
|
||||
const store = harness.store();
|
||||
const blocker = await store.createTask({ column: "todo", description: "blocker" });
|
||||
const dependent = await store.createTask({ column: "todo", description: "dependent", dependencies: [blocker.id] });
|
||||
await store.updateTask(dependent.id, { blockedBy: blocker.id, status: "queued" });
|
||||
|
||||
await store.deleteTask(dependent.id);
|
||||
const deletedDependentBefore = await store.getTask(dependent.id, { includeDeleted: true });
|
||||
|
||||
await store.deleteTask(blocker.id, { removeDependencyReferences: true });
|
||||
const deletedDependentAfter = await store.getTask(dependent.id, { includeDeleted: true });
|
||||
|
||||
expect(deletedDependentAfter.updatedAt).toBe(deletedDependentBefore.updatedAt);
|
||||
expect(deletedDependentAfter.blockedBy).toBe(blocker.id);
|
||||
});
|
||||
|
||||
it("is idempotent and does not emit extra dependent updates on re-delete", async () => {
|
||||
const store = harness.store();
|
||||
const blocker = await store.createTask({ column: "todo", description: "blocker" });
|
||||
const dependent = await store.createTask({ column: "todo", description: "dependent", dependencies: [blocker.id] });
|
||||
await store.updateTask(dependent.id, { blockedBy: blocker.id, status: "queued" });
|
||||
|
||||
const updatedEvents: string[] = [];
|
||||
store.on("task:updated", (task) => {
|
||||
if (task.id === dependent.id) updatedEvents.push(task.id);
|
||||
});
|
||||
|
||||
await store.deleteTask(blocker.id, { removeDependencyReferences: true });
|
||||
const afterFirst = await store.getTask(dependent.id);
|
||||
await store.deleteTask(blocker.id, { removeDependencyReferences: true });
|
||||
const afterSecond = await store.getTask(dependent.id);
|
||||
|
||||
expect(afterSecond.updatedAt).toBe(afterFirst.updatedAt);
|
||||
expect(updatedEvents.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -6824,9 +6824,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
|
||||
let rewrittenDependents: Task[] = [];
|
||||
let rewrittenBlockedByResidueDependents: Task[] = [];
|
||||
let rewrittenLineageChildren: Task[] = [];
|
||||
this.db.transaction(() => {
|
||||
rewrittenDependents = this.rewriteDependentsForRemoval(id, dependentIds);
|
||||
rewrittenBlockedByResidueDependents = this.rewriteBlockedByResidueDependentsForRemoval(id, new Set(dependentIds));
|
||||
rewrittenLineageChildren = this.rewriteLineageChildrenForRemoval(id, lineageChildIds);
|
||||
const deletedAt = new Date().toISOString();
|
||||
const allowResurrection = options?.allowResurrection === true ? 1 : 0;
|
||||
@@ -6873,6 +6875,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
for (const dependentTask of rewrittenDependents) {
|
||||
this.emit("task:updated", dependentTask);
|
||||
}
|
||||
for (const dependentTask of rewrittenBlockedByResidueDependents) {
|
||||
this.emit("task:updated", dependentTask);
|
||||
}
|
||||
for (const lineageChild of rewrittenLineageChildren) {
|
||||
this.emit("task:updated", lineageChild);
|
||||
}
|
||||
@@ -6901,18 +6906,34 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
if (!dependentTask) continue;
|
||||
|
||||
const nextDependencies = dependentTask.dependencies.filter((dependencyId) => dependencyId !== taskId);
|
||||
if (nextDependencies.length === dependentTask.dependencies.length) {
|
||||
const clearsBlockedBy = dependentTask.blockedBy === taskId;
|
||||
if (nextDependencies.length === dependentTask.dependencies.length && !clearsBlockedBy) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedDependent = {
|
||||
const updatedLog = clearsBlockedBy
|
||||
? [
|
||||
...(dependentTask.log ?? []),
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
action: `Auto-unblocked: blocker ${taskId} was soft-deleted`,
|
||||
},
|
||||
]
|
||||
: dependentTask.log;
|
||||
const updatedDependent: Task = {
|
||||
...dependentTask,
|
||||
dependencies: nextDependencies,
|
||||
blockedBy: clearsBlockedBy ? undefined : dependentTask.blockedBy,
|
||||
status: clearsBlockedBy ? undefined : dependentTask.status,
|
||||
log: updatedLog,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.db.prepare("UPDATE tasks SET dependencies = ?, updatedAt = ? WHERE id = ?").run(
|
||||
this.db.prepare("UPDATE tasks SET dependencies = ?, blockedBy = ?, status = ?, log = ?, updatedAt = ? WHERE id = ?").run(
|
||||
toJson(updatedDependent.dependencies),
|
||||
updatedDependent.blockedBy ?? null,
|
||||
updatedDependent.status ?? null,
|
||||
toJson(updatedDependent.log ?? []),
|
||||
updatedDependent.updatedAt,
|
||||
updatedDependent.id,
|
||||
);
|
||||
@@ -6925,6 +6946,46 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return rewrittenDependents;
|
||||
}
|
||||
|
||||
private rewriteBlockedByResidueDependentsForRemoval(taskId: string, excludedDependentIds: Set<string>): Task[] {
|
||||
const rewrittenDependents: Task[] = [];
|
||||
const candidates = this.db
|
||||
.prepare(`SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE} AND blockedBy = ?`)
|
||||
.all(taskId) as Array<{ id: string }>;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (excludedDependentIds.has(candidate.id)) continue;
|
||||
const dependentTask = this.readTaskFromDb(candidate.id);
|
||||
if (!dependentTask || dependentTask.blockedBy !== taskId) continue;
|
||||
|
||||
const updatedDependent: Task = {
|
||||
...dependentTask,
|
||||
blockedBy: undefined,
|
||||
status: undefined,
|
||||
log: [
|
||||
...(dependentTask.log ?? []),
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
action: `Auto-unblocked: blocker ${taskId} was soft-deleted`,
|
||||
},
|
||||
],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.db.prepare("UPDATE tasks SET blockedBy = NULL, status = NULL, log = ?, updatedAt = ? WHERE id = ?").run(
|
||||
toJson(updatedDependent.log ?? []),
|
||||
updatedDependent.updatedAt,
|
||||
updatedDependent.id,
|
||||
);
|
||||
|
||||
if (this.isWatching) {
|
||||
this.taskCache.set(updatedDependent.id, updatedDependent);
|
||||
}
|
||||
rewrittenDependents.push(updatedDependent);
|
||||
}
|
||||
|
||||
return rewrittenDependents;
|
||||
}
|
||||
|
||||
private rewriteLineageChildrenForRemoval(parentId: string, childIds: string[]): Task[] {
|
||||
const rewrittenChildren: Task[] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user