From 5b22d569c9b30b08777624346841e29a85c989fc Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 15 Aug 2026 14:43:42 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(kbdb):=20createRecord=20=E7=9A=84=20slo?= =?UTF-8?q?t=20=E5=8F=AF=E4=BB=A5=E6=8C=87=E5=90=91=E6=97=A2=E6=9C=89=20en?= =?UTF-8?q?try=E2=80=94=E2=80=94=E5=A4=96=E9=8D=B5=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=A2=AB=E5=AF=A6=E4=BD=9C=E6=88=90=E8=A4=87=E8=A3=BD=EF=BC=88?= =?UTF-8?q?Arcrun#128=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 2026-08-15 的心智模型:「blocks 是一個大水池,template/slots 組成虛擬表和 fields,最後都指向水池的一條 entry⋯⋯連到三元組就是外鍵」。 而 `entry_values` 的約束只有 `UNIQUE(record_id, slot_name)`、`entry_id` 上沒有任何 unique ⇒ 一條 entry 本來就能被無限多筆 record 的無限多個 slot 參照, **儲存層早就是外鍵語意,壞的只有寫入路徑**:createRecord 對每個 slot 值都無條件 createEntry ⇒ 每設一次外鍵就把被參照的資料複製一份。那不是外鍵,那是複製。 為什麼是地基而不是省空間:複製讓資料量隨「有幾個 App 參照它」線性膨脹,而且兩份 從此各自漂移 ⇒ 遲早要有人來清 ⇒ 直接違背這個模型的產品承諾「加一個 App 只要建一份 template,不必遷移、不需要工程師」(llm-wiki-schema.md)。#129(wiki template)、 #130(三元組正規化)、#60(alias 餵了沒用)三票都卡在它後面。 做了什麼(不動 schema、不動舊資料、不加表) · CreateRecordInput 多一個 entry_ids:{slot: 既有 entry id},與 values 並存 —— 給字串照舊新建(舊呼叫端一個字不用改),給 id 就只插一列 entry_values · POST /records 接受只給 entry_ids(舊版這裡回 400),並驗兩個 map 的型別 · 寫入前先把被參照的 entry 讀出來,一次擋掉三種錯,且**檢查全在第一筆 INSERT 之前** ⇒ 失敗=一列都沒寫(base 沒有交易,這是唯一保證得了的原子性) ① id 不存在(FK 只會回一句 SQLITE_CONSTRAINT,說不出是哪個 slot) ② 🔴 跨租戶:新路徑讓呼叫端能自己指定 entry_id,不擋就等於開一扇 「把別人的 entry 掛進自己的 record 再讀回內容」的門(rules 02 §6.1 同精神) ③ 指到 template 沒有的 slot → 報錯,不學 values 那條靜默略過 (外鍵無聲消失是最難查的失敗:呼叫端以為建好了,template= 查詢卻永遠撈不到) 驗(tests/record-entry-ref.test.ts,17 條,真 SQLite 套 0001_base.sql 原檔—— 「列數變不變」是 capture-DB 驗不到的東西,必須有真的表在數) · 五個 slot 全指既有 entry:entries 5 → 5(差 0),entry_values 0 → 5 對照組同樣五段給字串:entries 5 → 10(差 5)=原本的複製行為 · 一條 entry 同時被兩筆 record、且被同一筆 record 的兩個 slot 指到 → 讀回都正確 · 改水池那一份 → 兩筆 record 都看到新內容(副本做不到,證明真的是同一條) · searchByTemplate('wiki','leo') 撈得到,五個 slot 值正確 · 舊路徑逐項不變:3 筆新 entry、entry_type='value'、owner_id 帶歸屬、 template 沒有的 slot 照舊只 echo 不存;POST 舊 body 仍 200 kbdb 全套 215 → 230 綠(新增 15 條,既有一條都沒動) Co-Authored-By: Claude Opus 5 --- kbdb/src/actions/record-crud.ts | 113 +++++++++- kbdb/src/routes/records.ts | 20 +- kbdb/tests/record-entry-ref.test.ts | 337 ++++++++++++++++++++++++++++ 3 files changed, 463 insertions(+), 7 deletions(-) create mode 100644 kbdb/tests/record-entry-ref.test.ts diff --git a/kbdb/src/actions/record-crud.ts b/kbdb/src/actions/record-crud.ts index 86cdbef..b9279b6 100644 --- a/kbdb/src/actions/record-crud.ts +++ b/kbdb/src/actions/record-crud.ts @@ -56,11 +56,87 @@ export async function updateTemplate(db: D1Database, id: string, patch: { descri export interface CreateRecordInput { template: string; // template id or name - values: Record; // slot_name -> content + values?: Record; // slot_name -> content(**新建**一筆 entry 當這個 slot 的值) + /** + * slot_name -> **既有** entry 的 id:把水池(entries)裡那條既有 entry 直接掛到這個 slot 上, + * 不新建、不複製(Arcrun#128)。 + * + * 🔴 為什麼要有這條路(不是優化,是「外鍵」本來就該有的樣子): + * leo 2026-08-15 的心智模型——「blocks 是一個大水池,template/slots 組成虛擬表和 fields, + * 最後都指向水池的一條 entry⋯⋯連到三元組就是外鍵」。而 `entry_values` 的約束 + * (`migrations/0001_base.sql`)只有 `UNIQUE(record_id, slot_name)`, + * **`entry_id` 上沒有任何 unique** ⇒ 一條 entry 本來就能被無限多筆 record 的無限多個 + * slot 參照,**儲存層早就是外鍵語意**。壞的只有寫入路徑:本函式舊版對每個 slot 值 + * **無條件 createEntry** ⇒ 每設一次外鍵就把被參照的資料複製一份。 + * 那不是外鍵,那是複製。 + * + * 後果不只是多佔列數:兩份從此**各自漂移**(改一邊,另一邊還是舊的), + * 且資料量隨「有幾個 App 參照它」線性膨脹 ⇒ 遲早要有人來清、去重、修對不上的兩份 + * ⇒ 直接違背這個模型的產品承諾「加一個 App 只要建一份 template,不必遷移、不需要工程師」 + * (`InkStoneCo/system-dev/docs/4-guides/llm-wiki-schema.md`)。 + * + * **與 values 並存**:給字串 → 照舊新建(舊呼叫端一個字都不用改);給 id → 參照既有。 + */ + entry_ids?: Record; owner_id?: string | null; record_id?: string; } +/** 被參照 entry 的 id -> 它現在的 content(回傳值要帶真內容,不是空殼)。 */ +type ReferencedContent = Map; + +/** + * 讀出被參照的既有 entry,並在**寫入任何一列之前**把該擋的擋掉。 + * + * 為什麼要先讀(不是多此一舉,靠 FK 報錯不夠): + * 1. **id 不存在**要給看得懂的錯(FK 違反在 D1 只回一句 SQLITE_CONSTRAINT, + * 呼叫端不知道是哪個 slot、哪個 id); + * 2. **跨租戶必須擋**——這條新路徑讓呼叫端可以自己指定 entry_id,若不檢查歸屬, + * 任何人都能把別人的 entry 掛進自己的 record,再從 `GET /records/:id` 讀回它的內容 + * ⇒ 等於開一扇繞過租戶邊界的門(同 `.claude/rules/02-forbidden.md` §6.1 的精神: + * 資料面的歸屬要與寫入端同源); + * 3. 回傳值要帶被參照 entry 的**現有內容**(呼叫端拿到的 values 才是那條真的 entry)。 + * + * 所有檢查都在第一筆 INSERT 之前跑完 ⇒ 失敗就是「一列都沒寫」,不留半筆殘骸 + * (base 沒有交易可用,這是這裡唯一保證得了的原子性形式)。 + * + * 批次以 90 個 id 一組:D1 綁定參數上限 100,沿用 searchByTemplate 既有慣例。 + */ +async function loadReferencedEntries( + db: D1Database, + entryIds: Record, + recordOwnerId: string | null, +): Promise { + const ids = [...new Set(Object.values(entryIds))]; + if (ids.length === 0) return new Map(); + + const rows: { id: string; content: string | null; owner_id: string | null }[] = []; + for (let i = 0; i < ids.length; i += 90) { + const chunk = ids.slice(i, i + 90); + const res = await db + .prepare(`SELECT id, content, owner_id FROM entries WHERE id IN (${chunk.map(() => '?').join(',')})`) + .bind(...chunk) + .all<{ id: string; content: string | null; owner_id: string | null }>(); + rows.push(...(res.results ?? [])); + } + + const found = new Map(rows.map((r) => [r.id, r])); + const missing = ids.filter((id) => !found.has(id)); + if (missing.length > 0) throw new Error(`entry not found: ${missing.join(', ')}`); + + // 歸屬不同 → 擋。owner_id 為 null 的 entry 視為無主/共用(既有資料多半如此),放行。 + if (recordOwnerId != null) { + const foreign = rows.filter((r) => r.owner_id != null && r.owner_id !== recordOwnerId); + if (foreign.length > 0) { + throw new Error( + `entry owner mismatch: ${foreign.map((r) => `${r.id}(${r.owner_id})`).join(', ')} != ${recordOwnerId}`, + ); + } + } + + return new Map(rows.map((r) => [r.id, r.content])); +} + export interface RecordResult { record_id: string; template_id: string; @@ -79,11 +155,36 @@ export async function createRecord(db: D1Database, input: CreateRecordInput): Pr if (!tpl) throw new Error(`template not found: ${input.template}`); const slots: string[] = JSON.parse(tpl.slots_json); const recordId = input.record_id ?? uid('rec'); + const values = input.values ?? {}; + const entryIds = input.entry_ids ?? {}; + const refSlots = Object.keys(entryIds); + + // 同一個 slot 不准同時給字串又給 id:兩者的意思相反(複製一份 vs 指向既有), + // 猜哪一個都可能默默寫錯一份資料 ⇒ 當場報錯,不猜。 + const both = refSlots.filter((s) => s in values); + if (both.length > 0) throw new Error(`slot given both value and entry_id: ${both.join(', ')}`); + + // entry_ids 指到 template 沒有的 slot → 報錯(**不學 values 那條「靜默略過」**)。 + // 理由:外鍵設了卻無聲消失是最難查的失敗——呼叫端會以為關聯建好了, + // 而 `template=` 查詢永遠撈不到它(查詢走 entry_values)。讓它當場講話。 + const unknown = refSlots.filter((s) => !slots.includes(s)); + if (unknown.length > 0) throw new Error(`slot not in template: ${unknown.join(', ')}`); + + // 全部檢查(存在/歸屬)先跑完再寫,失敗=一列都沒寫。 + const referenced = await loadReferencedEntries(db, entryIds, input.owner_id ?? null); for (const slot of slots) { - if (!(slot in input.values)) continue; + // 參照既有 entry:只插一列關聯,**不碰 entries**(這就是外鍵)。 + if (slot in entryIds) { + await db + .prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`) + .bind(uid('ev'), recordId, tpl.id, slot, entryIds[slot]) + .run(); + continue; + } + if (!(slot in values)) continue; const entry = await createEntry(db, { - content: input.values[slot], + content: values[slot], entry_type: 'value', owner_id: input.owner_id ?? null, }); @@ -92,7 +193,11 @@ export async function createRecord(db: D1Database, input: CreateRecordInput): Pr .bind(uid('ev'), recordId, tpl.id, slot, entry.id) .run(); } - return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null }; + + // 回傳值:舊路徑照舊原樣回 input.values(一字不變),參照來的 slot 補上那條既有 entry 的現有內容。 + const out: Record = { ...values }; + for (const [slot, entryId] of Object.entries(entryIds)) out[slot] = referenced.get(entryId) ?? ''; + return { record_id: recordId, template_id: tpl.id, values: out, owner_id: input.owner_id ?? null }; } // Update an existing record's slot values (mira-dissolve T2.1, issue #6). diff --git a/kbdb/src/routes/records.ts b/kbdb/src/routes/records.ts index 1758200..4f52d87 100644 --- a/kbdb/src/routes/records.ts +++ b/kbdb/src/routes/records.ts @@ -5,11 +5,25 @@ import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord } export const recordRoutes = new Hono<{ Bindings: Bindings }>(); -// POST /records — { template, values:{slot:content}, owner_id? } +// POST /records — { template, values:{slot:content}?, entry_ids:{slot:既有entry_id}?, owner_id? } +// +// values = 給字串 → **新建**一筆 entry 當這個 slot 的值(原本就有的路,行為不變) +// entry_ids = 給既有 entry 的 id → **指向**水池裡那條 entry,不複製(Arcrun#128 的外鍵) +// 兩者可混用;至少要有一個。**只給 entry_ids 是合法的**——例如一則 wiki 的五段在 KBDB 裡 +// 本來就已經是五筆既有 entry,建 record 只是替它們取名字,不該再生出任何新 entry。 +const isStringMap = (v: unknown): boolean => + !!v && typeof v === 'object' && !Array.isArray(v) && Object.values(v as object).every((x) => typeof x === 'string'); + recordRoutes.post('/', async (c) => { const body = await c.req.json().catch(() => null); - if (!body || !body.template || !body.values) { - return c.json({ success: false, error: 'template and values required' }, 400); + if (!body || !body.template || (!body.values && !body.entry_ids)) { + return c.json({ success: false, error: 'template and values (or entry_ids) required' }, 400); + } + if (body.values !== undefined && !isStringMap(body.values)) { + return c.json({ success: false, error: 'values must be an object of {slot: string}' }, 400); + } + if (body.entry_ids !== undefined && !isStringMap(body.entry_ids)) { + return c.json({ success: false, error: 'entry_ids must be an object of {slot: entry_id}' }, 400); } try { const rec = await createRecord(c.env.DB, body); diff --git a/kbdb/tests/record-entry-ref.test.ts b/kbdb/tests/record-entry-ref.test.ts new file mode 100644 index 0000000..d14b296 --- /dev/null +++ b/kbdb/tests/record-entry-ref.test.ts @@ -0,0 +1,337 @@ +// Arcrun#128 — createRecord 的 slot 值可以是「既有 entry 的 id」=外鍵,不是複製。 +// +// 病根(`record-crud.ts` 舊版):每個 slot 值都無條件 createEntry ⇒ 每設一次外鍵就把被參照的 +// 資料複製一份。而 `entry_values` 的約束只有 `UNIQUE(record_id, slot_name)`、`entry_id` 沒有 +// 任何 unique ⇒ **儲存層本來就允許共用,壞的只有寫入路徑**。 +// +// 測試策略:**真 SQLite**(node:sqlite,同 library-map.test.ts / library-backfill.test.ts 手法) +// 套 migrations/0001_base.sql 原檔——因為本票的驗收標準是「entries 的**列數**變不變」, +// 那是 capture-DB(只驗 SQL 形狀)根本驗不到的東西,必須有真的表在數。 +import { describe, it, expect } from 'vitest'; +import { DatabaseSync } from 'node:sqlite'; +import { readFileSync } from 'node:fs'; +import { Hono } from 'hono'; +import { recordRoutes } from '../src/routes/records'; +import { createRecord, createTemplate, deleteRecord, getRecord, searchByTemplate } from '../src/actions/record-crud'; +import { createEntry, getEntry, updateEntry } from '../src/actions/entry-crud'; +import type { Bindings } from '../src/types'; + +/** slot_name -> entry id。 */ +type SlotIds = Record; + +// ── node:sqlite → D1 介面最小 adapter(同 library-map.test.ts 手法)────────────── +function makeSqliteD1(): { db: D1Database; raw: DatabaseSync } { + const raw = new DatabaseSync(':memory:'); + raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)套 migration 原檔 + raw.exec('PRAGMA foreign_keys = ON'); // kbdb-sql-ok:測試治具——本地也打開 FK,才測得到「刪掉別人還指著的 entry」會怎樣 + function stmt(sql: string, params: unknown[]) { + const s = { + bind(...args: unknown[]) { return stmt(sql, args); }, + async all() { return { results: raw.prepare(sql).all(...(params as never[])) as T[] }; }, // kbdb-sql-ok:測試治具 + async first() { return (raw.prepare(sql).get(...(params as never[])) ?? null) as T | null; }, // kbdb-sql-ok:測試治具 + async run() { raw.prepare(sql).run(...(params as never[])); return { success: true }; }, // kbdb-sql-ok:測試治具 + }; + return s; + } + return { db: { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database, raw }; +} + +const countEntries = (raw: DatabaseSync): number => + (raw.prepare('SELECT COUNT(*) AS n FROM entries').get() as { n: number }).n; // kbdb-sql-ok:測試治具 +const countEntryValues = (raw: DatabaseSync): number => + (raw.prepare('SELECT COUNT(*) AS n FROM entry_values').get() as { n: number }).n; // kbdb-sql-ok:測試治具 +const entryIdOfSlot = (raw: DatabaseSync, recordId: string, slot: string): string | undefined => + (raw.prepare('SELECT entry_id FROM entry_values WHERE record_id = ? AND slot_name = ?').get(recordId, slot) as // kbdb-sql-ok:測試治具 + | { entry_id: string } + | undefined)?.entry_id; + +const WIKI_SLOTS = ['title', 'gloss', 'points', 'entities', 'relations']; + +/** 模擬「ingest 產出的一則 wiki 卡:五段本來就已經是五筆既有 entry」。 */ +async function seedWikiSections(db: D1Database, owner: string | null = 'leo'): Promise { + const out: SlotIds = {}; + const sections: Record = { + title: '# KBDB', + gloss: '## 一句話定義\n三張表的萬用資料層', + points: '## 要點\n永不加表', + entities: '## 關鍵實體\nKBDB / template / slot', + relations: '## 關聯\nKBDB 是 arcrun 的資料層', + }; + for (const [slot, content] of Object.entries(sections)) { + const e = await createEntry(db, { content, entry_type: 'block', owner_id: owner }); + out[slot] = e.id; + } + return out; +} + +describe('Arcrun#128 驗收① — 用既有 entry 的 id 建 record,水池列數不增加', () => { + it('五個 slot 全部指向既有 entry → entries 總筆數前後相同,只多五筆關聯列', async () => { + const { db, raw } = makeSqliteD1(); + await createTemplate(db, { name: 'wiki', slots: WIKI_SLOTS, created_by: 'system' }); + const ids = await seedWikiSections(db); + + const entriesBefore = countEntries(raw); + const evBefore = countEntryValues(raw); + + const rec = await createRecord(db, { template: 'wiki', entry_ids: ids, owner_id: 'leo' }); + + const entriesAfter = countEntries(raw); + const evAfter = countEntryValues(raw); + console.log( + `[#128 驗收①] entries: ${entriesBefore} → ${entriesAfter}(差 ${entriesAfter - entriesBefore});` + + `entry_values: ${evBefore} → ${evAfter}(差 ${evAfter - evBefore})`, + ); + + expect(entriesAfter).toBe(entriesBefore); // 🔴 本票的核心:一筆新 entry 都沒生 + expect(evAfter - evBefore).toBe(5); + expect(rec.record_id).toMatch(/^rec_/); + }); + + it('對照組(舊路徑):同樣五個 slot 給字串 → entries 增加五筆(這就是「複製」)', async () => { + const { db, raw } = makeSqliteD1(); + await createTemplate(db, { name: 'wiki', slots: WIKI_SLOTS, created_by: 'system' }); + await seedWikiSections(db); + + const before = countEntries(raw); + await createRecord(db, { + template: 'wiki', + values: { title: '# KBDB', gloss: 'g', points: 'p', entities: 'e', relations: 'r' }, + owner_id: 'leo', + }); + const after = countEntries(raw); + console.log(`[#128 對照組] 舊路徑 entries: ${before} → ${after}(差 ${after - before})`); + expect(after - before).toBe(5); + }); +}); + +describe('Arcrun#128 驗收② — 同一條 entry 被多筆 record 的多個 slot 指到', () => { + it('一條 entry 同時被兩筆 record、且被同一筆 record 的兩個 slot 指到 → 讀回都正確,水池仍只有一份', async () => { + const { db, raw } = makeSqliteD1(); + await createTemplate(db, { name: 'wiki', slots: WIKI_SLOTS, created_by: 'system' }); + const shared = await createEntry(db, { content: '共用的那段:KBDB 永不加表', entry_type: 'block', owner_id: 'leo' }); + const other = await createEntry(db, { content: '# 另一張卡', entry_type: 'block', owner_id: 'leo' }); + + const before = countEntries(raw); + const a = await createRecord(db, { + template: 'wiki', + // 同一筆 record 的兩個 slot 指同一條 entry(只 UNIQUE(record_id, slot_name),合法) + entry_ids: { title: shared.id, gloss: shared.id }, + owner_id: 'leo', + }); + const b = await createRecord(db, { + template: 'wiki', + entry_ids: { title: other.id, points: shared.id }, + owner_id: 'leo', + }); + const after = countEntries(raw); + + const ra = await getRecord(db, a.record_id); + const rb = await getRecord(db, b.record_id); + console.log( + `[#128 驗收②] entries: ${before} → ${after};A.title=${ra!.values.title} / A.gloss=${ra!.values.gloss} / B.points=${rb!.values.points}`, + ); + + expect(after).toBe(before); + expect(ra!.values.title).toBe('共用的那段:KBDB 永不加表'); + expect(ra!.values.gloss).toBe('共用的那段:KBDB 永不加表'); + expect(rb!.values.title).toBe('# 另一張卡'); + expect(rb!.values.points).toBe('共用的那段:KBDB 永不加表'); + // 三個 slot 位置指的都是**同一個** entry id + expect(entryIdOfSlot(raw, a.record_id, 'title')).toBe(shared.id); + expect(entryIdOfSlot(raw, a.record_id, 'gloss')).toBe(shared.id); + expect(entryIdOfSlot(raw, b.record_id, 'points')).toBe(shared.id); + }); +}); + +describe('Arcrun#128 驗收③ — slot 值就是那筆既有 entry,不是副本', () => { + it('改那條 entry 的內容 → 兩筆 record 讀回來都是新內容(副本做不到這件事)', async () => { + const { db } = makeSqliteD1(); + await createTemplate(db, { name: 'wiki', slots: WIKI_SLOTS, created_by: 'system' }); + const shared = await createEntry(db, { content: '第一版', entry_type: 'block', owner_id: 'leo' }); + const a = await createRecord(db, { template: 'wiki', entry_ids: { gloss: shared.id }, owner_id: 'leo' }); + const b = await createRecord(db, { template: 'wiki', entry_ids: { points: shared.id }, owner_id: 'leo' }); + + await updateEntry(db, shared.id, { content: '第二版(改在水池那一份)' }); + + const ra = await getRecord(db, a.record_id); + const rb = await getRecord(db, b.record_id); + console.log(`[#128 驗收③] 改水池後:A.gloss=「${ra!.values.gloss}」/B.points=「${rb!.values.points}」`); + expect(ra!.values.gloss).toBe('第二版(改在水池那一份)'); + expect(rb!.values.points).toBe('第二版(改在水池那一份)'); + }); + + it('createRecord 回傳的 values 帶的是被參照 entry 的現有內容(不是空字串)', async () => { + const { db } = makeSqliteD1(); + await createTemplate(db, { name: 'wiki', slots: WIKI_SLOTS, created_by: 'system' }); + const e = await createEntry(db, { content: '既有內容', entry_type: 'block', owner_id: 'leo' }); + const rec = await createRecord(db, { + template: 'wiki', + values: { title: '新建的' }, + entry_ids: { gloss: e.id }, + owner_id: 'leo', + }); + expect(rec.values).toEqual({ title: '新建的', gloss: '既有內容' }); + }); + + it('kbdb_query(searchByTemplate)撈得到用 entry_ids 建的 record,且 slot 值正確', async () => { + const { db } = makeSqliteD1(); + await createTemplate(db, { name: 'wiki', slots: WIKI_SLOTS, created_by: 'system' }); + const ids = await seedWikiSections(db, 'leo'); + await createRecord(db, { template: 'wiki', entry_ids: ids, owner_id: 'leo' }); + + const recs = await searchByTemplate(db, 'wiki', 'leo'); + console.log( + `[#128 驗收③] searchByTemplate('wiki','leo') → ${recs.length} 筆,slots=${Object.keys(recs[0]?.values ?? {}).join(',')}`, + ); + expect(recs).toHaveLength(1); + expect(recs[0].values.title).toBe('# KBDB'); + expect(recs[0].values.entities).toBe('## 關鍵實體\nKBDB / template / slot'); + expect(recs[0].owner_id).toBe('leo'); + }); +}); + +describe('Arcrun#128 驗收④ — 舊呼叫端行為完全不變', () => { + it('只給 values:每個 slot 各建一筆新 entry、回傳 values 原樣、template 沒有的 slot 照舊靜默略過', async () => { + const { db, raw } = makeSqliteD1(); + await createTemplate(db, { id: 'tpl-t', name: 'triplet', slots: ['subject', 'predicate', 'object'], created_by: 'system' }); + + const before = countEntries(raw); + const rec = await createRecord(db, { + template: 'triplet', + // `library` 不在 template 的 slots 裡 → 舊行為是「不存,但回傳原樣 echo」 + //(triplet-library-backfill.test.ts 就是靠這個行為在描述存量資料),本次不得改變 + values: { subject: 'A', predicate: 'r', object: 'B', library: 'kb' }, + owner_id: 'leo', + }); + const after = countEntries(raw); + + expect(after - before).toBe(3); + expect(rec.values).toEqual({ subject: 'A', predicate: 'r', object: 'B', library: 'kb' }); + expect(rec.template_id).toBe('tpl-t'); + expect(rec.owner_id).toBe('leo'); + const stored = await getRecord(db, rec.record_id); + expect(stored!.values).toEqual({ subject: 'A', predicate: 'r', object: 'B' }); + // 新建的 entry 沿用舊行為:entry_type='value'、owner_id 帶 record 的歸屬 + const e = await getEntry(db, entryIdOfSlot(raw, rec.record_id, 'subject')!); + expect(e!.entry_type).toBe('value'); + expect(e!.owner_id).toBe('leo'); + }); + + it('POST /records 舊 body(只有 template + values)→ 200,與過去相同', async () => { + const { db } = makeSqliteD1(); + await createTemplate(db, { name: 'triplet', slots: ['subject', 'predicate', 'object'], created_by: 'system' }); + const app = new Hono<{ Bindings: Bindings }>(); + app.route('/records', recordRoutes); + const env = { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings; + + const res = await app.request( + '/records', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' }, owner_id: 'leo' }), + }, + env, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { success: boolean; record: { values: Record } }; + expect(body.success).toBe(true); + expect(body.record.values).toEqual({ subject: 'A', predicate: 'r', object: 'B' }); + }); + + it('POST /records 只給 entry_ids(沒有 values)→ 200(舊版這裡是 400「values required」)', async () => { + const { db, raw } = makeSqliteD1(); + await createTemplate(db, { name: 'wiki', slots: WIKI_SLOTS, created_by: 'system' }); + const ids = await seedWikiSections(db, 'leo'); + const app = new Hono<{ Bindings: Bindings }>(); + app.route('/records', recordRoutes); + const env = { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings; + + const before = countEntries(raw); + const res = await app.request( + '/records', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ template: 'wiki', entry_ids: ids, owner_id: 'leo' }), + }, + env, + ); + const after = countEntries(raw); + const body = (await res.json()) as { success: boolean; record: { values: Record } }; + console.log(`[#128 route] POST /records(只給 entry_ids)→ ${res.status};entries ${before} → ${after}`); + expect(res.status).toBe(200); + expect(body.record.values.title).toBe('# KBDB'); + expect(after).toBe(before); + }); + + it('POST /records 兩個都沒給 → 400;型別不對 → 400', async () => { + const { db } = makeSqliteD1(); + await createTemplate(db, { name: 'wiki', slots: WIKI_SLOTS, created_by: 'system' }); + const app = new Hono<{ Bindings: Bindings }>(); + app.route('/records', recordRoutes); + const env = { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings; + const post = (body: unknown) => + app.request('/records', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, env); + + expect((await post({ template: 'wiki' })).status).toBe(400); + expect((await post({ template: 'wiki', entry_ids: { title: 123 } })).status).toBe(400); + expect((await post({ template: 'wiki', values: ['a'] })).status).toBe(400); + }); +}); + +describe('Arcrun#128 — 指不到的外鍵要當場講話,而且一列都不寫', () => { + it('entry id 不存在 → throw entry not found,且 entries/entry_values 都沒動', async () => { + const { db, raw } = makeSqliteD1(); + await createTemplate(db, { name: 'wiki', slots: WIKI_SLOTS, created_by: 'system' }); + const good = await createEntry(db, { content: 'ok', entry_type: 'block', owner_id: 'leo' }); + + const e0 = countEntries(raw); + const v0 = countEntryValues(raw); + await expect( + createRecord(db, { template: 'wiki', entry_ids: { title: good.id, gloss: 'e_不存在' }, owner_id: 'leo' }), + ).rejects.toThrow(/entry not found: e_不存在/); + expect(countEntries(raw)).toBe(e0); + expect(countEntryValues(raw)).toBe(v0); // 檢查全在第一筆 INSERT 之前 ⇒ 沒有半筆殘骸 + }); + + it('entry_ids 指到 template 沒有的 slot → throw(不像 values 那樣靜默略過)', async () => { + const { db } = makeSqliteD1(); + await createTemplate(db, { name: 'wiki', slots: WIKI_SLOTS, created_by: 'system' }); + const e = await createEntry(db, { content: 'x', entry_type: 'block', owner_id: 'leo' }); + await expect( + createRecord(db, { template: 'wiki', entry_ids: { 沒這個欄位: e.id }, owner_id: 'leo' }), + ).rejects.toThrow(/slot not in template: 沒這個欄位/); + }); + + it('同一個 slot 同時給 value 與 entry_id → throw(不猜要哪個)', async () => { + const { db } = makeSqliteD1(); + await createTemplate(db, { name: 'wiki', slots: WIKI_SLOTS, created_by: 'system' }); + const e = await createEntry(db, { content: 'x', entry_type: 'block', owner_id: 'leo' }); + await expect( + createRecord(db, { template: 'wiki', values: { gloss: '字串' }, entry_ids: { gloss: e.id }, owner_id: 'leo' }), + ).rejects.toThrow(/slot given both value and entry_id: gloss/); + }); + + it('🔴 租戶邊界:指向別人的 entry → throw owner mismatch,一列都不寫', async () => { + const { db, raw } = makeSqliteD1(); + await createTemplate(db, { name: 'wiki', slots: WIKI_SLOTS, created_by: 'system' }); + const someoneElse = await createEntry(db, { content: '別人的機密', entry_type: 'block', owner_id: 'alice' }); + + const e0 = countEntries(raw); + const v0 = countEntryValues(raw); + await expect( + createRecord(db, { template: 'wiki', entry_ids: { gloss: someoneElse.id }, owner_id: 'leo' }), + ).rejects.toThrow(/entry owner mismatch/); + expect(countEntries(raw)).toBe(e0); + expect(countEntryValues(raw)).toBe(v0); + }); + + it('無主(owner_id=null)的 entry 可以被參照(既有資料多半無主,不能擋死)', async () => { + const { db } = makeSqliteD1(); + await createTemplate(db, { name: 'wiki', slots: WIKI_SLOTS, created_by: 'system' }); + const orphan = await createEntry(db, { content: '無主資料', entry_type: 'block' }); + const rec = await createRecord(db, { template: 'wiki', entry_ids: { gloss: orphan.id }, owner_id: 'leo' }); + expect(rec.values.gloss).toBe('無主資料'); + }); +}); From aa49b4eeb215a72563b3d1a8f6e2e80290383105 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 15 Aug 2026 14:44:32 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(kbdb):=20=E5=88=AA=20record=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=88=AA=E6=8E=89=E5=88=A5=E4=BA=BA=E9=82=84=E6=8C=87?= =?UTF-8?q?=E8=91=97=E7=9A=84=20entry=EF=BC=88Arcrun#128=20=E7=9A=84?= =?UTF-8?q?=E5=BF=85=E7=84=B6=E9=85=8D=E5=A5=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一筆讓 slot 可以指向既有 entry 之後,同一條 entry 會同時被別的 record 指著。 deleteRecord 舊寫法是「這筆 record 的每個 entry_id 都刪掉」,在共用的情況下會: · 把**別人還在用**的那條水池資料一起刪掉(他的 slot 從此指向不存在的列),或 · 撞上 `entry_values.entry_id REFERENCES entries(id)` 的 FK 而整個刪除失敗 ⇒ 條件改成「已經沒有任何 entry_values 指著它」才刪 (`DELETE FROM entries WHERE id = ? AND NOT EXISTS (SELECT 1 FROM entry_values WHERE entry_id = ?)`) **沒有共用時行為與舊版完全相同**:這筆 record 的關聯列已先刪掉,若沒有別人指著, NOT EXISTS 恆為真 ⇒ 照樣刪。差別只出現在真的被共用的那一條上。 驗(同檔 +2 條,真 SQLite 且 `PRAGMA foreign_keys = ON`) · 兩筆 record 共用一段、刪掉其中一筆:entries 2 → 1(只少掉那筆自己專屬的 title), 共用那條還在,另一筆 record 讀回來的 slot 值不受影響 · 沒有共用時:3 個 slot 的 entry 全刪、entry_values 歸零、getRecord 回 null(同舊版) kbdb 全套 232 綠 Co-Authored-By: Claude Opus 5 --- kbdb/src/actions/record-crud.ts | 19 +++++++++++-- kbdb/tests/record-entry-ref.test.ts | 43 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/kbdb/src/actions/record-crud.ts b/kbdb/src/actions/record-crud.ts index b9279b6..682099e 100644 --- a/kbdb/src/actions/record-crud.ts +++ b/kbdb/src/actions/record-crud.ts @@ -325,7 +325,19 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r); } -/** 刪除一筆 record:先刪 entry_values(FK),再刪底層 entries。回 false 表示 record 不存在。 */ +/** + * 刪除一筆 record:先刪 entry_values(FK),再刪底層 entries。回 false 表示 record 不存在。 + * + * 🔴 **還有別人指著的 entry 不刪**(Arcrun#128 的必然配套,不是順手加的): + * slot 值可以指向既有 entry 之後,同一條 entry 會同時被別的 record 指著。 + * 舊寫法「這筆 record 的每個 entry_id 都刪掉」在那種情況下會: + * · 把**別人還在用**的那條水池資料一起刪掉(他的 slot 從此指向不存在的列),或 + * · 撞上 `entry_values.entry_id REFERENCES entries(id)` 的 FK 而整個刪除失敗。 + * ⇒ 條件改成「已經沒有任何 entry_values 指著它」才刪。 + * + * **沒有共用時行為與舊版完全相同**:這筆 record 的關聯列已先刪掉,若沒有別人指著, + * `NOT EXISTS` 恆為真 ⇒ 照樣刪。差別只出現在「真的被共用」的那條上。 + */ export async function deleteRecord(db: D1Database, recordId: string): Promise { const evRes = await db .prepare('SELECT entry_id FROM entry_values WHERE record_id = ?') @@ -335,7 +347,10 @@ export async function deleteRecord(db: D1Database, recordId: string): Promise { + it('共用的 entry 在另一筆 record 刪掉後仍在,且那筆 record 讀得到;沒共用的照舊被刪', async () => { + const { db, raw } = makeSqliteD1(); + await createTemplate(db, { name: 'wiki', slots: WIKI_SLOTS, created_by: 'system' }); + const shared = await createEntry(db, { content: '兩張卡共用的段落', entry_type: 'block', owner_id: 'leo' }); + + const a = await createRecord(db, { + template: 'wiki', + values: { title: 'A 專屬' }, + entry_ids: { gloss: shared.id }, + owner_id: 'leo', + }); + const b = await createRecord(db, { template: 'wiki', entry_ids: { gloss: shared.id }, owner_id: 'leo' }); + const aTitleEntry = entryIdOfSlot(raw, a.record_id, 'title')!; + + const before = countEntries(raw); + expect(await deleteRecord(db, a.record_id)).toBe(true); + const after = countEntries(raw); + console.log(`[#128 配套] 刪掉 A 之後 entries: ${before} → ${after}(只該少掉 A 專屬那一筆)`); + + expect(after).toBe(before - 1); // 只有 A 專屬的 title entry 被刪 + expect(await getEntry(db, aTitleEntry)).toBeNull(); + expect(await getEntry(db, shared.id)).not.toBeNull(); // 🔴 共用那條還在 + const rb = await getRecord(db, b.record_id); + expect(rb!.values.gloss).toBe('兩張卡共用的段落'); // B 沒被波及 + }); + + it('沒有共用時,deleteRecord 行為與舊版相同(底層 entries 全刪)', async () => { + const { db, raw } = makeSqliteD1(); + await createTemplate(db, { name: 'triplet', slots: ['subject', 'predicate', 'object'], created_by: 'system' }); + const rec = await createRecord(db, { + template: 'triplet', + values: { subject: 'A', predicate: 'r', object: 'B' }, + owner_id: 'leo', + }); + const before = countEntries(raw); + expect(await deleteRecord(db, rec.record_id)).toBe(true); + expect(countEntries(raw)).toBe(before - 3); + expect(countEntryValues(raw)).toBe(0); + expect(await getRecord(db, rec.record_id)).toBeNull(); + }); +}); From 9ba6ba75fc3ba8c3f88ce8a435bde9a89bbbdb98 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 15 Aug 2026 14:48:01 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(cypher):=20POST=20/kbdb/records=20?= =?UTF-8?q?=E9=96=8B=E9=80=9A=20entry=5Fids=20=E9=80=9A=E9=81=93=EF=BC=88A?= =?UTF-8?q?rcrun#128=20=E7=9A=84=E5=B0=8D=E5=A4=96=E9=96=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基本盤補好而通道不開=能力在、沒人打得到——同 b6ef0f0 那次 PATCH 的形狀。 #129(wiki template)與 #130(三元組正規化)的寫入端走的就是這扇門。 改了兩件事(維持純轉發,判斷全在基本盤,薄殼鐵律見檔頭): · 舊版寫死 `!body.values → 400`:只給 entry_ids 的合法請求會被自己的 proxy 擋掉 ⇒ 改成「values 與 entry_ids 至少要有一個」 · 轉發 body 帶上 entry_ids;沒給的那個 key 就不塞(不憑空給基本盤一個空物件) 租戶隔離沿用本檔既有做法:**忽略 caller 自帶的 owner_id,一律注入 header 身份**。 配合基本盤這次加的檢查(被參照 entry 的 owner_id 必須與注入的租戶相符), 「呼叫端可以自己指定 entry_id」這條新路徑不會變成跨租戶的門。 驗(tests/kbdb-records-entry-ids-proxy.test.ts,6 條,fetchMock 假 host+斷網) · 無 X-Arcrun-API-Key → 401 不碰 KBDB · 只給 entry_ids → 轉發成功,body 為 {template, entry_ids, owner_id:'leo'}(無 values key), 且 caller 自帶的 owner_id:'alice' 被忽略 · values 與 entry_ids 混用 → 兩個都轉過去 · 舊呼叫端只給 values → 轉發 body 與過去逐字相同,不夾帶 entry_ids · 兩個都沒給 → 400 不轉發;base 擋跨租戶(400)→ 原樣透傳不假裝成功 cypher-executor 全套 445 綠 / 14 紅——那 14 條是**既有**紅燈 (auth-dispatcher/console-library-map-page/executor/portal-admin/portal-data), 已用 `git stash` 拿掉本次改動複跑同五個檔複驗:同樣 14 紅,與本次無關。 Co-Authored-By: Claude Opus 5 --- cypher-executor/src/routes/kbdb-proxy.ts | 21 ++- .../kbdb-records-entry-ids-proxy.test.ts | 126 ++++++++++++++++++ 2 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 cypher-executor/tests/kbdb-records-entry-ids-proxy.test.ts diff --git a/cypher-executor/src/routes/kbdb-proxy.ts b/cypher-executor/src/routes/kbdb-proxy.ts index 0074369..5c555e4 100644 --- a/cypher-executor/src/routes/kbdb-proxy.ts +++ b/cypher-executor/src/routes/kbdb-proxy.ts @@ -81,20 +81,33 @@ kbdbProxyRouter.get('/kbdb/templates/:idOrName', async (c) => { // ── records(以租戶 namespace 為 owner_id 隔離)──────────────────────────────── -// POST /kbdb/records — 填一筆 record(template + values)。owner_id 自動注入。 +// POST /kbdb/records — 填一筆 record(template + values/entry_ids)。owner_id 自動注入。 +// +// `entry_ids`(Arcrun#128)= slot 指向**既有** entry 的 id,不新建、不複製;與 values 並存 +// (給字串照舊新建)。這裡維持純轉發,判斷與擋人全在基本盤 kbdb(薄殼鐵律,見檔頭): +// · 兩者都沒給/型別不對 → base 回 400 +// · 指到別人的 entry → base 擋(它比對被參照 entry 的 owner_id 與這裡注入的租戶身份, +// 所以「呼叫端自己指定 entry_id」這條新路徑不會變成跨租戶的門) +// 🔴 為什麼通道要一起開:#129(wiki template)與 #130(三元組正規化)的寫入端走這扇門。 +// 基本盤補好而通道不開=能力在、沒人打得到——同 PATCH 那次(b6ef0f0)的教訓。 kbdbProxyRouter.post('/kbdb/records', async (c) => { const owner = tenant(c); if (!owner) return c.json(NEED_KEY, 401); const body = await c.req.json().catch(() => null); - if (!body || !body.template || !body.values) { - return c.json({ error: 'template 與 values 必填' }, 400); + if (!body || !body.template || (!body.values && !body.entry_ids)) { + return c.json({ error: 'template 必填,values 與 entry_ids 至少要有一個' }, 400); } const { base, headers } = kbdbBase(c.env); const res = await fetch(`${base}/records`, { method: 'POST', headers, // 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入) - body: JSON.stringify({ template: body.template, values: body.values, owner_id: owner }), + body: JSON.stringify({ + template: body.template, + ...(body.values ? { values: body.values } : {}), + ...(body.entry_ids ? { entry_ids: body.entry_ids } : {}), + owner_id: owner, + }), }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); diff --git a/cypher-executor/tests/kbdb-records-entry-ids-proxy.test.ts b/cypher-executor/tests/kbdb-records-entry-ids-proxy.test.ts new file mode 100644 index 0000000..6e4c03b --- /dev/null +++ b/cypher-executor/tests/kbdb-records-entry-ids-proxy.test.ts @@ -0,0 +1,126 @@ +/** + * POST /kbdb/records — `entry_ids` 通道(Arcrun#128) + * + * 背景:基本盤 kbdb 的 createRecord 現在接受 `entry_ids`(slot 指向**既有** entry 的 id, + * 不新建、不複製)。這條 proxy 之前寫死只轉發 `values`,且沒有 values 就 400 + * ⇒ 走 X-Arcrun-API-Key 的呼叫者(#129 的 wiki 寫入端、#130 的三元組正規化)打不到新能力, + * 等於基本盤補好了、通道沒開(同 b6ef0f0 那次 PATCH 的形狀)。 + * + * 驗的是 IO 接線(判斷真身在基本盤,這裡只測轉發,比照 kbdb-records-patch-proxy.test.ts): + * 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB + * 2. 只給 entry_ids(沒有 values)→ 轉發成功(舊版這裡是 400) + * 3. 轉發的 body:帶 entry_ids + **注入租戶當 owner_id**(caller 自帶的 owner_id 被忽略) + * 4. values 與 entry_ids 混用 → 兩個都轉過去 + * 5. 兩個都沒給 → 400,不轉發 + * 6. base 擋跨租戶(400)→ 原樣透傳,不假裝成功 + * + * KBDB 打 fetchMock 假 host(wrangler.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('POST /kbdb/records(entry_ids)— 租戶閘與參數', () => { + it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => { + const res = await SELF.fetch('http://localhost/kbdb/records', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1' } }), + }); + expect(res.status).toBe(401); + }); + + it('values 與 entry_ids 都沒給 → 400,不轉發', async () => { + const res = await SELF.fetch('http://localhost/kbdb/records', { + method: 'POST', + headers: KEY, + body: JSON.stringify({ template: 'wiki' }), + }); + expect(res.status).toBe(400); + }); +}); + +describe('POST /kbdb/records(entry_ids)— 轉發', () => { + it('只給 entry_ids(沒有 values)→ 轉發,且 owner_id 由租戶身份注入', async () => { + fetchMock + .get('https://kbdb.test') + .intercept({ + path: '/records', + method: 'POST', + // 沒有 values 這個 key(不要憑空塞一個空物件給基本盤) + body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'leo' }), + }) + .reply(200, { + success: true, + record: { record_id: 'rec_1', template_id: 'tpl-wiki', values: { gloss: '既有', points: '既有2' }, owner_id: 'leo' }, + }); + const res = await SELF.fetch('http://localhost/kbdb/records', { + method: 'POST', + headers: KEY, + // caller 自帶 owner_id:必須被忽略(防跨租戶寫入,本檔既有慣例) + body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'alice' }), + }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; record: { values: Record } }; + expect(data.success).toBe(true); + expect(data.record.values.gloss).toBe('既有'); + }); + + it('values 與 entry_ids 混用 → 兩個都轉過去', async () => { + fetchMock + .get('https://kbdb.test') + .intercept({ + path: '/records', + method: 'POST', + body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' }, owner_id: 'leo' }), + }) + .reply(200, { success: true, record: { record_id: 'rec_2', template_id: 'tpl-wiki', values: {}, owner_id: 'leo' } }); + const res = await SELF.fetch('http://localhost/kbdb/records', { + method: 'POST', + headers: KEY, + body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' } }), + }); + expect(res.status).toBe(200); + }); + + it('舊呼叫端(只給 values)→ 轉發的 body 不夾帶 entry_ids,行為與過去相同', async () => { + fetchMock + .get('https://kbdb.test') + .intercept({ + path: '/records', + method: 'POST', + body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' }, owner_id: 'leo' }), + }) + .reply(200, { success: true, record: { record_id: 'rec_3', template_id: 'tpl-triplet', values: {}, owner_id: 'leo' } }); + const res = await SELF.fetch('http://localhost/kbdb/records', { + method: 'POST', + headers: KEY, + body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' } }), + }); + expect(res.status).toBe(200); + }); + + it('base 擋下跨租戶參照(400)→ 原樣透傳,不假裝成功', async () => { + fetchMock + .get('https://kbdb.test') + .intercept({ path: '/records', method: 'POST' }) + .reply(400, { success: false, error: 'entry owner mismatch: e_x(alice) != leo' }); + const res = await SELF.fetch('http://localhost/kbdb/records', { + method: 'POST', + headers: KEY, + body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_x' } }), + }); + expect(res.status).toBe(400); + const data = (await res.json()) as { success: boolean; error: string }; + expect(data.success).toBe(false); + expect(data.error).toContain('owner mismatch'); + }); +});