fix(kbdb): createRecord 的 slot 可以指向既有 entry——外鍵不再被實作成複製(Arcrun#128)

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 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-15 14:43:42 +08:00
parent 30c9312c57
commit 5b22d569c9
3 changed files with 463 additions and 7 deletions
+109 -4
View File
@@ -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<string, string>; // slot_name -> content
values?: Record<string, string>; // 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<string, string>;
owner_id?: string | null;
record_id?: string;
}
/** 被參照 entry 的 id -> 它現在的 content(回傳值要帶真內容,不是空殼)。 */
type ReferencedContent = Map<string, string | null>;
/**
* 讀出被參照的既有 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<string, string>,
recordOwnerId: string | null,
): Promise<ReferencedContent> {
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<string, string> = { ...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).