fix(kbdb): 開通 PATCH /kbdb/records/:id proxy + 補三元組 library 補標測試

leo 2026-08-11:貼 wiki 卡「這裏就有 3 個三元組,如果三元組是 0 一定是有錯」——
三元組沒有不見(新式 1,633 筆/舊式 636 筆都還在),問題是地圖看不到:新式三元組
只有 171 筆填了 library slot,其餘因寫入時 template 還沒有該 slot 而被靜默丟棄
(record-crud.ts createRecord 只替「已宣告」的 slot 建值,不是替 caller 傳的
values 建值——順序錯了值就消失,不報錯)。

本次改動:
- cypher-executor/src/routes/kbdb-proxy.ts:補 PATCH /kbdb/records/:recordId。
  基本盤(kbdb/src/routes/records.ts)早有這個端點(mira-dissolve T2.1 的
  updateRecord),但這條 proxy 之前只轉發 GET/POST /kbdb/records,外部(工作流/
  CLI/補標腳本)打不到——能力在,通道沒開,補標三元組只能繞去改表(違 D38)。
  純轉發、無業務邏輯,比照既有 PATCH /kbdb/entries/:id 慣例。
- kbdb/tests/triplet-library-backfill.test.ts:真 node:sqlite 驗三件事——
  ①源頭順序(ensure-slot 必須在 write 之前,事後補救不了已寫的那筆,重現+
  驗證正解)②存量補標(地圖輸出前後對照)③不會重複做(同批跑兩次,第二次
  touch 0 筆)。順手記錄一個相關但不在本次範圍的小落差:liveTripletCountsByLibrary
  的 'general' fallback 桶與 recomputeLibraryMap 的精確比對語意沒對齊。
- cypher-executor/tests/kbdb-records-patch-proxy.test.ts:新端點的租戶閘/
  參數驗證/轉發/404 透傳。

全數綠燈:kbdb 181/181、cypher-executor 新增 8 案全過(既有 14 案失敗為
pre-existing,已用 git stash 比對確認與本次改動無關)。

kbdb-graph-plugin(實際的三元組寫入端 createTriplet/ingestEnvelope)需要同款
修正(library 補進 TRIPLET_SLOTS + 從 source_uri 推導),但該 repo 現行
0 份 active SDD,其 sdd-guard 明確要求人決定要不要開啟——本次不越界代為決定,
留在報告中交回。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-11 16:48:31 +08:00
parent 507620e313
commit b6ef0f07dc
3 changed files with 308 additions and 0 deletions
+21
View File
@@ -119,6 +119,27 @@ kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// PATCH /kbdb/records/:recordId — 翻某筆 record 的 slot 值({ values:{slot:content} })。
// 補上基本盤既有能力(kbdb/src/routes/records.ts 的 PATCH /records/:recordIdmira-dissolve T2.1
// 缺的對外通道——2026-08-11 leo 三元組 library 補標核實:base 早有這個端點,但這條 proxy
// 之前只轉發 GET/POST,插件/工作流打不到,補標三元組只能繞去改表(違 D38)。單純轉發,無業務邏輯。
// by-id 沿用既有慣例(require-key,不額外做 owner 比對——與本檔 GET .../:recordId、
// PATCH /kbdb/entries/:id 同款)。
kbdbProxyRouter.patch('/kbdb/records/:recordId', async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
const body = await c.req.json().catch(() => null);
if (!body || typeof body.values !== 'object' || body.values === null) {
return c.json({ error: 'values 必填({slot名: 內容}' }, 400);
}
const { base, headers } = kbdbBase(c.env);
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param('recordId'))}`, {
method: 'PATCH',
headers,
body: JSON.stringify({ values: body.values }),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// ── search(限本租戶範圍內)────────────────────────────────────────────────────
// GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。
@@ -0,0 +1,89 @@
/**
* PATCH /kbdb/records/:recordId proxy 測試(2026-08-11,三元組 library 補標需求核實)
*
* 背景:基本盤 kbdb/src/routes/records.ts 早有 PATCH /records/:recordIdmira-dissolve T2.1
* updateRecord 已支援「補一個 record 原本沒有的 slot 值」的 idempotent grow)。但這條 cypher
* proxykbdb-proxy.ts)之前只轉發 GET/POST /kbdb/records,沒開 PATCH——外部(工作流/CLI/
* 任何走 X-Arcrun-API-Key 的呼叫者)打不到,等於基本盤能力在,通道沒開。
*
* 驗證 IO 接線(聚合真身在 KBDB 基本盤,這裡只測轉發,比照 kbdb-map-proxy.test.ts 慣例):
* 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB
* 2. body 沒有 values → 400,不轉發
* 3. 轉發:PATCH /kbdb/records/:id → base PATCH /records/:idbody 只帶 { values }
* 4. base 404record 不存在)→ 原樣透傳,不假裝成功
*
* KBDB 打 fetchMock 假 hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnect——測試絕不外連。
*/
import { SELF, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
describe('PATCH /kbdb/records/:recordId — 租戶閘', () => {
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values: { library: 'kb' } }),
});
expect(res.status).toBe(401);
});
});
describe('PATCH /kbdb/records/:recordId — 參數驗證', () => {
it('body 沒有 values → 400,不轉發', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
method: 'PATCH',
headers: KEY,
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
});
describe('PATCH /kbdb/records/:recordId — 轉發', () => {
it('轉發 base PATCH /records/:idbody 只帶 values(不夾帶其他欄位)', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({
path: '/records/rec_1',
method: 'PATCH',
body: JSON.stringify({ values: { library: 'gitea:Leo/kb' } }),
})
.reply(200, {
success: true,
record: { record_id: 'rec_1', template_id: 'tpl-triplet', values: { library: 'gitea:Leo/kb' } },
});
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
method: 'PATCH',
headers: KEY,
body: JSON.stringify({ values: { library: 'gitea:Leo/kb' } }),
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; record: { values: Record<string, string> } };
expect(data.success).toBe(true);
expect(data.record.values.library).toBe('gitea:Leo/kb');
});
it('base 404record 不存在)→ 原樣透傳,不假裝成功', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/records/nope', method: 'PATCH' })
.reply(404, { success: false, error: 'not found' });
const res = await SELF.fetch('http://localhost/kbdb/records/nope', {
method: 'PATCH',
headers: KEY,
body: JSON.stringify({ values: { library: 'kb' } }),
});
expect(res.status).toBe(404);
const data = (await res.json()) as { success: boolean };
expect(data.success).toBe(false);
});
});