Files
Arcrun/kbdb/tests/record-entry-ref.test.ts
T
uncle6me-web ceb7638d74 feat(kbdb): 樹狀 record 模型第一刀——record 有身分、關係是唯一機制、entry_values 拆表(v7 定稿實作)
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。
模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」

- 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數
  (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+
  每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列
  (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values
  (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。
- record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留,
  驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。
- entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry
  接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。
- 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列,
  LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀),
  GET /maintenance/relation-orphans 唯讀巡檢。
- cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS,
  整檔送 /query 會在重跑時假紅)。
- 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描;
  釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。

遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型
與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:34:48 +08:00

386 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, string>;
// ── 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(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
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<T>() { return { results: raw.prepare(sql).all(...(params as never[])) as T[] }; }, // kbdb-sql-ok:測試治具
async first<T>() { 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 };
}
// 0007 樹狀 record 模型之後的記帳單位:
// 「水池列數」=**內容列**(不含關係列與機制節點)——#128 的驗收標準是「外鍵不複製內容」,
// 關係列本來就是指標的載體,多一條指標不是多一份內容。
// 「關聯列」=格子關係列(rel 不是 sys_belongs / sys_field_of 的關係列),對應舊 entry_values。
const countEntries = (raw: DatabaseSync): number =>
(raw.prepare("SELECT COUNT(*) AS n FROM entries WHERE src_id IS NULL AND entry_type NOT IN ('record','sheet','field','system')").get() as { n: number }).n; // kbdb-sql-ok:測試治具
const countEntryValues = (raw: DatabaseSync): number =>
(raw.prepare("SELECT COUNT(*) AS n FROM entries WHERE src_id IS NOT NULL AND rel_id NOT IN ('sys_belongs','sys_field_of')").get() as { n: number }).n; // kbdb-sql-ok:測試治具
const entryIdOfSlot = (raw: DatabaseSync, recordId: string, slot: string): string | undefined =>
(raw.prepare('SELECT r.dst_id AS entry_id FROM entries r JOIN entries f ON r.rel_id = f.id WHERE r.src_id = ? AND f.content = ?').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<SlotIds> {
const out: SlotIds = {};
const sections: Record<string, string> = {
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_querysearchByTemplate)撈得到用 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<string, string> } };
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<string, string> } };
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,且 entriesentry_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('無主資料');
});
});
describe('Arcrun#128 配套 — 刪 record 不准刪掉別人還指著的 entry', () => {
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();
});
});