diff --git a/tests/bulk-delete.test.ts b/tests/bulk-delete.test.ts new file mode 100644 index 00000000..6e52eca7 --- /dev/null +++ b/tests/bulk-delete.test.ts @@ -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", + }); + }); +}); diff --git a/web/src/hooks/useMemoriesExplorer.ts b/web/src/hooks/useMemoriesExplorer.ts index 25891376..65991f28 100644 --- a/web/src/hooks/useMemoriesExplorer.ts +++ b/web/src/hooks/useMemoriesExplorer.ts @@ -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"; @@ -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(); @@ -460,4 +458,4 @@ export function useMemoriesExplorer() { runDeduplication, runMigration, }; -} +} \ No newline at end of file diff --git a/web/src/lib/bulk-delete.ts b/web/src/lib/bulk-delete.ts new file mode 100644 index 00000000..3172dc16 --- /dev/null +++ b/web/src/lib/bulk-delete.ts @@ -0,0 +1,51 @@ +export type BulkDeleteApiResult = { + success: boolean; + error?: string; +}; + +type BulkDeleteRequest = ( + endpoint: string, + options: RequestInit +) => Promise; + +export type BulkDeleteOutcome = { + success: boolean; + deletedIds: string[]; + error?: string; +}; + +export async function deleteSelectedMemories( + ids: string[], + request: BulkDeleteRequest +): Promise { + 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 }; +}