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:
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user