merge: kbdb DELETE 誠實回報向量刪除結果(arcrun-rag#46)

總管複核:改法與同檔 /entries/deprecate-by-library 一致(同步 await 再回應),
不動 schema、不加表(守 D38),附 84 行測試。
舊版 fire-and-forget 會把向量刪除失敗靜默吞掉——呼叫端看到「刪除成功」
但語意搜尋還留著殘影,這正是 #46 回報的症狀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-10 15:50:36 +08:00
2 changed files with 103 additions and 4 deletions
+19 -4
View File
@@ -380,11 +380,26 @@ entryRoutes.patch('/:id', async (c) => {
});
// DELETE /entries/:id
//
// 🔴 2026-08-10arcrun-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 });
});
+84
View File
@@ -0,0 +1,84 @@
// arcrun-rag#46「刪掉的知識搜尋還撈得到」第 4 點:中途失敗要看得出來。
//
// DELETE /entries/:id 舊版把向量刪除包成 fire-and-forgetwaitUntil(...).catch(()=>{}))——
// 呼叫端完全看不到向量清除是否成功。本測試鎖住新行為:同步 await+回應帶 vector_deleted
// 且無論向量刪除成功或失敗,D1 本體都要真的被刪掉(不因向量失敗而擋下本體刪除)。
//
// 測試手法同 search-deprecated-filter.test.tsfake 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<T>() { return { results: [] as T[] }; },
async first<T>() { 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<string, unknown> = {}) {
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);
});
});