From 6715402bcc33856aee7c238c844010a46c88ee55 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Mon, 10 Aug 2026 15:41:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(kbdb):=20DELETE=20/entries/:id=20=E5=90=91?= =?UTF-8?q?=E9=87=8F=E5=88=AA=E9=99=A4=E6=94=B9=E5=90=8C=E6=AD=A5=EF=BC=8B?= =?UTF-8?q?=E8=AA=A0=E5=AF=A6=E5=9B=9E=E5=A0=B1=EF=BC=88arcrun-rag#46?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 舊版把向量刪除包成 fire-and-forget(waitUntil(...).catch(()=>{}))——失敗被靜默 吞掉,呼叫端(rag_takedown_direct workflow/未來的 portal 刪除 UI)永遠不知道 向量沒清乾淨。與同檔 /entries/deprecate-by-library(同步 await + 回報 vectors_deleted)的做法不一致,補齊同款誠實回報:回應加 vector_deleted (true/false/null),D1 本體刪除不因向量失敗而被擋下。 補上這支端點原本零覆蓋的測試(entries-delete.test.ts,3 案:模組未開/ 向量刪除成功/向量刪除失敗)。 隨附 live e2e 驗證(youlin stage,4 筆測試知識,含批次 3 筆):刪除後 keyword/semantic 混合檢索與 rag_chat AI 問答皆不再讀到已刪內容,控制組 (未刪的既有知識)不受影響——確認端到端「刪除即在所有查詢路徑消失」成立, 細節見 Leo/arcrun-rag#46 留言。 --- kbdb/src/routes/entries.ts | 23 +++++++-- kbdb/tests/entries-delete.test.ts | 84 +++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 kbdb/tests/entries-delete.test.ts diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts index fcface1..a980049 100644 --- a/kbdb/src/routes/entries.ts +++ b/kbdb/src/routes/entries.ts @@ -380,11 +380,26 @@ entryRoutes.patch('/:id', async (c) => { }); // DELETE /entries/:id +// +// 🔴 2026-08-10(arcrun-rag#46「刪掉的知識搜尋還撈得到」第 4 點:中途失敗要看得出來): +// 舊版把向量刪除包成 fire-and-forget(`waitUntil(...).catch(()=>{})`)——失敗被靜默吞掉, +// 呼叫端(rag_takedown_direct workflow/未來的 portal 刪除 UI)永遠不知道向量沒清乾淨, +// 使用者看到「刪除成功」,但語意搜尋可能還留著殘影,直到下次搜尋命中它才被自癒清掉 +// (search 路徑的 orphan 清理是事後補救,不是保證)。 +// 改法:與同檔 `/entries/deprecate-by-library`(見上)同款——**同步 await 再回應**, +// 誠實回報 `vector_deleted`(true=清了/false=清失敗,D1 仍照刪/null=模組未開,不適用)。 +// D1 刪除永遠執行到底(entry 本體一定會消失),差別只在向量那一步呼叫端看不看得見失敗。 entryRoutes.delete('/:id', async (c) => { - // 模組開 → 連帶刪向量(避免孤兒向量)。失敗不致命。 + const id = c.req.param('id'); + let vector_deleted: boolean | null = null; // 模組未開=不適用,維持 null 誠實表達「這件事沒發生過」 if (embedEnabled(c.env)) { - c.executionCtx.waitUntil(c.env.VECTORIZE!.deleteByIds([c.req.param('id')]).then(() => {}).catch(() => {})); + try { + await c.env.VECTORIZE!.deleteByIds([id]); + vector_deleted = true; + } catch { + vector_deleted = false; // 誠實回 false,不假裝清乾淨了;D1 本體仍照刪,不因向量失敗而擋下 + } } - await deleteEntry(c.env.DB, c.req.param('id')); - return c.json({ success: true }); + await deleteEntry(c.env.DB, id); + return c.json({ success: true, vector_deleted }); }); diff --git a/kbdb/tests/entries-delete.test.ts b/kbdb/tests/entries-delete.test.ts new file mode 100644 index 0000000..34d2708 --- /dev/null +++ b/kbdb/tests/entries-delete.test.ts @@ -0,0 +1,84 @@ +// arcrun-rag#46「刪掉的知識搜尋還撈得到」第 4 點:中途失敗要看得出來。 +// +// DELETE /entries/:id 舊版把向量刪除包成 fire-and-forget(waitUntil(...).catch(()=>{}))—— +// 呼叫端完全看不到向量清除是否成功。本測試鎖住新行為:同步 await+回應帶 vector_deleted, +// 且無論向量刪除成功或失敗,D1 本體都要真的被刪掉(不因向量失敗而擋下本體刪除)。 +// +// 測試手法同 search-deprecated-filter.test.ts:fake D1 捕捉 SQL 形狀;mock VECTORIZE 可控 +// deleteByIds 成功/失敗,驗證 route 層如何把結果誠實透傳給呼叫端。 +import { describe, it, expect } from 'vitest'; +import { Hono } from 'hono'; +import { entryRoutes } from '../src/routes/entries'; +import type { Bindings } from '../src/types'; + +interface Captured { sql: string; params: unknown[] } + +function makeCaptureDB(captured: Captured[]) { + const prepare = (sql: string) => { + const rec: Captured = { sql, params: [] }; + captured.push(rec); + const stmt = { + bind(...args: unknown[]) { rec.params = args; return stmt; }, + async all() { return { results: [] as T[] }; }, + async first() { return null as unknown as T; }, + async run() { return { success: true }; }, + }; + return stmt; + }; + return { prepare } as unknown as D1Database; +} + +function makeApp(captured: Captured[], extraEnv: Record = {}) { + const app = new Hono<{ Bindings: Bindings }>(); + app.route('/entries', entryRoutes); + const env = { DB: makeCaptureDB(captured), ENVIRONMENT: 'test', ...extraEnv } as unknown as Bindings; + return { app, env }; +} + +describe('arcrun-rag#46 — DELETE /entries/:id 失敗可見性', () => { + it('embed 模組未開 → vector_deleted:null(不適用,非「清成功了」的謊)+ D1 仍照刪', async () => { + const captured: Captured[] = []; + const { app, env } = makeApp(captured); // 無 VECTORIZE/AI + const res = await app.request('/entries/e123', { method: 'DELETE' }, env); + expect(res.status).toBe(200); + const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null }; + expect(body.success).toBe(true); + expect(body.vector_deleted).toBe(null); + expect(captured.some((c) => c.sql.includes('DELETE FROM entries WHERE id = ?'))).toBe(true); + }); + + it('模組開+向量刪除成功 → vector_deleted:true(同步等到結果才回應,不是猜的)', async () => { + const captured: Captured[] = []; + const deletedIds: string[][] = []; + const { app, env } = makeApp(captured, { + VECTORIZE: { + async deleteByIds(ids: string[]) { deletedIds.push(ids); return { count: ids.length }; }, + }, + AI: { async run() { return { data: [[0.1]] }; } }, + }); + const res = await app.request('/entries/e123', { method: 'DELETE' }, env); + const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null }; + expect(body.success).toBe(true); + expect(body.vector_deleted).toBe(true); + expect(deletedIds).toEqual([['e123']]); // 真的呼叫了、帶對 id,不是没做就回真 + }); + + it('🔴 模組開+向量刪除失敗 → vector_deleted:false 誠實回報,且 D1 本體仍真的刪掉', async () => { + const captured: Captured[] = []; + const { app, env } = makeApp(captured, { + VECTORIZE: { + async deleteByIds() { throw new Error('Vectorize 503(模擬故障)'); }, + }, + AI: { async run() { return { data: [[0.1]] }; } }, + }); + const res = await app.request('/entries/e123', { method: 'DELETE' }, env); + // 舊版這裡的失敗會被 waitUntil(...).catch(()=>{}) 吞掉、caller 永遠看不到; + // 新版:HTTP 仍是 200(D1 本體真的刪了,這件事沒有失敗),但誠實標出向量那一步失敗了。 + expect(res.status).toBe(200); + const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null }; + expect(body.success).toBe(true); + expect(body.vector_deleted).toBe(false); + // D1 本體不因向量失敗而被擋下——刪除的「本體一定會消失」承諾不打折扣。 + expect(captured.some((c) => c.sql.includes('DELETE FROM entries WHERE id = ?'))).toBe(true); + }); +});