Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions tests/bulk-delete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test";
import { deleteSelectedMemories } from "../web/src/lib/bulk-delete";

type Call = {
endpoint: string;
options: RequestInit;
};

describe("deleteSelectedMemories", () => {
test("deletes prompt and memory groups and reports all deleted ids", async () => {
const calls: Call[] = [];
const result = await deleteSelectedMemories(
["prompt_1", "memory_1", "memory_2"],
async (endpoint, options) => {
calls.push({ endpoint, options });
return { success: true };
}
);

expect(result).toEqual({
success: true,
deletedIds: ["prompt_1", "memory_1", "memory_2"],
});
expect(calls.map((call) => call.endpoint)).toEqual([
"/api/prompts/bulk-delete",
"/api/memories/bulk-delete",
]);
expect(JSON.parse(String(calls[0]!.options.body))).toEqual({
ids: ["prompt_1"],
cascade: true,
});
expect(JSON.parse(String(calls[1]!.options.body))).toEqual({
ids: ["memory_1", "memory_2"],
cascade: true,
});
});

test("stops after the first failed group instead of reporting success", async () => {
const calls: string[] = [];
const result = await deleteSelectedMemories(
["prompt_1", "memory_1"],
async (endpoint) => {
calls.push(endpoint);
return { success: false, error: "prompt delete failed" };
}
);

expect(result).toEqual({
success: false,
deletedIds: [],
error: "prompt delete failed",
});
expect(calls).toEqual(["/api/prompts/bulk-delete"]);
});

test("preserves successful prompt deletions when the memory group fails", async () => {
const result = await deleteSelectedMemories(
["prompt_1", "memory_1"],
async (endpoint) =>
endpoint === "/api/prompts/bulk-delete"
? { success: true }
: { success: false, error: "memory delete failed" }
);

expect(result).toEqual({
success: false,
deletedIds: ["prompt_1"],
error: "memory delete failed",
});
});
});
32 changes: 15 additions & 17 deletions web/src/hooks/useMemoriesExplorer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useRef, useState, type FormEvent } from "react";
import { toast } from "sonner";
import { fetchAPI } from "$lib/api";
import { deleteSelectedMemories } from "$lib/bulk-delete";
import { t } from "$lib/i18n";
import type { MemoryItem, TagInfo } from "$lib/types";

Expand Down Expand Up @@ -314,23 +315,20 @@ export function useMemoriesExplorer() {
async function bulkDelete() {
if (selectedIds.size === 0) return;
if (!confirm(t("confirm-bulk-delete", { count: selectedIds.size }))) return;
const ids = Array.from(selectedIds);
const promptIds = ids.filter((id) => id.startsWith("prompt_"));
const memoryIds = ids.filter((id) => !id.startsWith("prompt_"));
if (promptIds.length > 0) {
await fetchAPI("/api/prompts/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: promptIds, cascade: true }),
});
}
if (memoryIds.length > 0) {
await fetchAPI("/api/memories/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: memoryIds, cascade: true }),
});

const result = await deleteSelectedMemories(Array.from(selectedIds), fetchAPI);
if (!result.success) {
if (result.deletedIds.length > 0) {
const next = new Set(selectedIds);
for (const id of result.deletedIds) next.delete(id);
setSelected(next);
await loadMemories();
await loadStats();
}
toast.error(result.error || t("toast-bulk-delete-failed"));
return;
}

toast.success(t("toast-bulk-delete-success"));
setSelected(new Set());
await loadMemories();
Expand Down Expand Up @@ -460,4 +458,4 @@ export function useMemoriesExplorer() {
runDeduplication,
runMigration,
};
}
}
51 changes: 51 additions & 0 deletions web/src/lib/bulk-delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export type BulkDeleteApiResult = {
success: boolean;
error?: string;
};

type BulkDeleteRequest = (
endpoint: string,
options: RequestInit
) => Promise<BulkDeleteApiResult>;

export type BulkDeleteOutcome = {
success: boolean;
deletedIds: string[];
error?: string;
};

export async function deleteSelectedMemories(
ids: string[],
request: BulkDeleteRequest
): Promise<BulkDeleteOutcome> {
const promptIds = ids.filter((id) => id.startsWith("prompt_"));
const memoryIds = ids.filter((id) => !id.startsWith("prompt_"));
const deletedIds: string[] = [];

const groups = [
{ endpoint: "/api/prompts/bulk-delete", ids: promptIds },
{ endpoint: "/api/memories/bulk-delete", ids: memoryIds },
];

for (const group of groups) {
if (group.ids.length === 0) continue;

const result = await request(group.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: group.ids, cascade: true }),
});

if (!result.success) {
return {
success: false,
deletedIds,
error: result.error,
};
}

deletedIds.push(...group.ids);
}

return { success: true, deletedIds };
}