Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6715402bcc |
@@ -380,11 +380,26 @@ entryRoutes.patch('/:id', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /entries/:id
|
// 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) => {
|
entryRoutes.delete('/:id', async (c) => {
|
||||||
// 模組開 → 連帶刪向量(避免孤兒向量)。失敗不致命。
|
const id = c.req.param('id');
|
||||||
|
let vector_deleted: boolean | null = null; // 模組未開=不適用,維持 null 誠實表達「這件事沒發生過」
|
||||||
if (embedEnabled(c.env)) {
|
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'));
|
await deleteEntry(c.env.DB, id);
|
||||||
return c.json({ success: true });
|
return c.json({ success: true, vector_deleted });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user