From 5b22d569c9b30b08777624346841e29a85c989fc Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 15 Aug 2026 14:43:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(kbdb):=20createRecord=20=E7=9A=84=20slot=20?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E6=8C=87=E5=90=91=E6=97=A2=E6=9C=89=20entry?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=A4=96=E9=8D=B5=E4=B8=8D=E5=86=8D=E8=A2=AB?= =?UTF-8?q?=E5=AF=A6=E4=BD=9C=E6=88=90=E8=A4=87=E8=A3=BD=EF=BC=88Arcrun#12?= =?UTF-8?q?8=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('無主資料'); + }); +});