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>
This commit is contained in:
uncle6me-web
2026-08-15 21:34:48 +08:00
parent 969acff325
commit ceb7638d74
25 changed files with 799 additions and 231 deletions
@@ -29,6 +29,7 @@ function makeSqliteD1(): D1Database {
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔,比照 execution-log.test.ts
raw.exec(readFileSync(new URL('../migrations/0002_credentials.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
raw.exec(readFileSync(new URL('../migrations/0005_credential_template.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
@@ -89,8 +90,9 @@ describe('credential-legacy-migration — 反向驗證:重現 2026-08-07 youli
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) // kbdb-sql-ok: 測試查詢
.all<{ name: string }>();
const names = (tables.results ?? []).map((t) => t.name).sort();
// entries/templates/entry_values 三張核心表 + credentials(舊表,尚未清理)——沒有第五張表。
expect(names).toEqual(['credentials', 'entries', 'entry_values', 'templates']);
// entries/templates 兩張核心表(entry_values 已由 0007 拆掉——關係的第二套實作,D92)
// + credentials(舊表,尚未清理)——沒有第四張表。
expect(names).toEqual(['credentials', 'entries', 'templates']);
});
});
+1
View File
@@ -31,6 +31,7 @@ const CURRENT_MODEL = '@cf/baai/bge-m3'; // embed.ts DEFAULT_EMBED_MODEL(未 e
function makeSqliteD1(): D1Database {
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
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
+1 -1
View File
@@ -12,7 +12,7 @@ function mkEntry(id: string, content: string, ownerId = 'leo', is_embedded = 1):
return {
id, content, entry_type: 'block', owner_id: ownerId, parent_id: null, page_name: null,
refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, is_embedded,
confidence: null, metadata_json: JSON.stringify({ embed: true }), created_at: 1, updated_at: 1,
confidence: null, metadata_json: JSON.stringify({ embed: true }), src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
};
}
+7 -2
View File
@@ -36,6 +36,7 @@ function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8'));
raw.exec(readFileSync(new URL('../migrations/0004_execution_log_template.sql', import.meta.url), 'utf8'));
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
@@ -70,7 +71,11 @@ describe('execution-log — schema 零異動(證明沒建表)', () => {
expect(sql).toContain('INSERT OR IGNORE INTO templates');
});
it('template 存在(tpl-execution-log),entries/templates/entry_values 三表結構不變', async () => {
// 0007(樹狀 record 模型,v7 定稿 2026-08-15 confirm)後的正解表清單:entry_values 已拆
// ——它是「關係」的第二套實作(D92),關係列改住 entries 的指標欄。這條斷言在改版前
// 釘的是「三張核心表」,規格層變更(pending-changes.md「record 要有身分」)把答案改掉,
// 不是實作去配合測試。
it('template 存在(tpl-execution-log),核心表只剩 entries/templatesentry_values 已拆,0007', async () => {
const db = makeSqliteD1();
const tpl = await db.prepare('SELECT * FROM templates WHERE name = ?').bind('execution_log').first<{ id: string }>();
expect(tpl?.id).toBe('tpl-execution-log');
@@ -79,7 +84,7 @@ describe('execution-log — schema 零異動(證明沒建表)', () => {
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`,
).all<{ name: string }>();
const names = (tables.results ?? []).map((t) => t.name).sort();
expect(names).toEqual(['entries', 'entry_values', 'templates']);
expect(names).toEqual(['entries', 'templates']);
});
});
+1
View File
@@ -23,6 +23,7 @@ import type { Bindings, Entry, EntryType } from '../src/types';
function makeSqliteD1(): D1Database {
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
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
+1 -1
View File
@@ -33,7 +33,7 @@ function mkEntry(id: string, metadata_json: string | null): Entry {
return {
id, content: 'some content', entry_type: 'block', owner_id: 'tenant1', parent_id: null,
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 0, confidence: null, metadata_json, created_at: 1, updated_at: 1,
is_embedded: 0, confidence: null, metadata_json, src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
};
}
+1
View File
@@ -25,6 +25,7 @@ function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8'));
raw.exec(readFileSync(new URL('../migrations/0003_library_map.sql', import.meta.url), 'utf8'));
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
+8 -3
View File
@@ -23,6 +23,7 @@ type SlotIds = Record<string, string>;
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 = {
@@ -36,12 +37,16 @@ function makeSqliteD1(): { db: D1Database; raw: DatabaseSync } {
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').get() as { n: number }).n; // kbdb-sql-ok:測試治具
(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 entry_values').get() as { n: number }).n; // kbdb-sql-ok:測試治具
(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 entry_id FROM entry_values WHERE record_id = ? AND slot_name = ?').get(recordId, slot) as // kbdb-sql-ok:測試治具
(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;
+20 -9
View File
@@ -10,12 +10,15 @@ import { updateRecord } from '../src/actions/record-crud';
interface Captured { sql: string; params: unknown[] }
/**
* fake D1
* - entry_values JOIN entries slot rows owner_id
* fake D10007 record SQL
* - SELECT dst_id rel_id = 'sys_belongs' record sheet
* - JOIN entries f ON r.rel_id = f.id v JOIN slot rows
* - owner SELECT owner_id FROM entries WHERE id record
* - templates templateslots_json + grow slot
* - INSERT INTO entries
* - INSERT INTO entries ensureFieldEntries
* INSERT OR IGNORE
* - SELECT * FROM entries WHERE id entrycreateEntry insert
* - UPDATE / INSERT entry_values / getRecord SELECT
* - UPDATE / INSERT / getRecord SELECT
*/
function makeRoutedDB(recordOwnerId: string | null, captured: Captured[]) {
const prepare = (sql: string) => {
@@ -24,30 +27,38 @@ function makeRoutedDB(recordOwnerId: string | null, captured: Captured[]) {
const stmt = {
bind(...args: unknown[]) { rec.params = args; return stmt; },
async all<T>() {
if (sql.includes('FROM entry_values ev JOIN entries e')) {
// getRecord 的格子查詢(有 v JOIN)→ 空殼;updateRecord 的格子查詢(無 v JOIN)→ 既有 slot
if (sql.includes('JOIN entries v ON r.dst_id = v.id')) {
return { results: [] as T[] };
}
if (sql.includes('JOIN entries f ON r.rel_id = f.id')) {
return {
results: [
{ slot_name: 'email', entry_id: 'e_existing', template_id: 'tpl_pu', owner_id: recordOwnerId },
{ slot_name: 'email', entry_id: 'e_existing' },
] as unknown as T[],
};
}
if (sql.includes('FROM entry_values ev JOIN entries e ON ev.entry_id = e.id')) {
return { results: [] as T[] };
}
return { results: [] as T[] };
},
async first<T>() {
if (sql.includes("rel_id = 'sys_belongs'") && sql.includes('SELECT dst_id')) {
return { dst_id: 'tpl_pu' } as unknown as T;
}
if (sql.includes('FROM templates')) {
return {
id: 'tpl_pu', name: 'portal_user', description: null,
slots_json: JSON.stringify(['email', 'status']), created_by: 'system',
} as unknown as T;
}
if (sql.startsWith('SELECT owner_id FROM entries WHERE id')) {
return { owner_id: recordOwnerId } as unknown as T;
}
if (sql.startsWith('SELECT * FROM entries WHERE id')) {
return {
id: 'e_new', content: 'active', entry_type: 'value', owner_id: recordOwnerId,
parent_id: null, page_name: null, refs_json: '[]', tags_json: '[]', task_status: null,
content_hash: null, is_embedded: 0, confidence: null, metadata_json: null,
src_id: null, rel_id: null, dst_id: null,
created_at: 1, updated_at: 1,
} as unknown as T;
}
+1 -1
View File
@@ -26,7 +26,7 @@ function mkEntry(id: string, metadata_json: string | null): Entry {
return {
id, content: 'some content', entry_type: 'block', owner_id: 'tenant1', parent_id: null,
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 0, confidence: null, metadata_json, created_at: 1, updated_at: 1,
is_embedded: 0, confidence: null, metadata_json, src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
};
}
+4 -1
View File
@@ -28,6 +28,7 @@ import {
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:'); // kbdb-sql-ok:記憶體測試替身,非真 KBDB D1
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 migration 原檔
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
return {
bind(...args: unknown[]) { return stmt(sql, args); },
@@ -45,8 +46,10 @@ const legacyPattern = (q: string) => `%${q}%`;
/** 對真 SQLite 跑一次 `content LIKE ?`,回命中的 content(要不要帶 ESCAPE 可選)。 */
async function likeHits(db: D1Database, pattern: string, escape: boolean): Promise<string[]> {
const pred = escape ? "content LIKE ? ESCAPE '\\'" : 'content LIKE ?';
// 0007 之後池裡多了機制節點(sys_* 啟動常數等有 content 的列)——比照 searchEntries 的
// 機制列隔離謂詞排除,讓「全庫」仍然指使用者的語料庫,前後對照的語意不變。
const res = await db
.prepare(`SELECT content FROM entries WHERE ${pred} ORDER BY id`) // kbdb-sql-ok:測試治具讀回斷言用
.prepare(`SELECT content FROM entries WHERE src_id IS NULL AND entry_type NOT IN ('record','sheet','field','system') AND ${pred} ORDER BY id`) // kbdb-sql-ok:測試治具讀回斷言用
.bind(pattern)
.all<{ content: string }>();
return (res.results ?? []).map((x) => x.content);
+1 -1
View File
@@ -21,7 +21,7 @@ function mkEntry(id: string, opts: { deprecated?: boolean } = {}): Entry {
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 1, confidence: null,
metadata_json: opts.deprecated ? JSON.stringify({ status: 'deprecated', embed: true }) : JSON.stringify({ embed: true }),
created_at: 1, updated_at: 1,
src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
};
}
@@ -20,7 +20,7 @@ function mkEntry(id: string, opts: { deprecated?: boolean } = {}): Entry {
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 1, confidence: null,
metadata_json: opts.deprecated ? JSON.stringify({ status: 'deprecated' }) : JSON.stringify({ embed: true }),
created_at: 1, updated_at: 1,
src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
};
}
+1 -1
View File
@@ -39,7 +39,7 @@ function mkEntry(id: string): Entry {
return {
id, content: 'some content', entry_type: 'block', owner_id: 'tenant1', parent_id: null,
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 0, confidence: null, metadata_json: null, created_at: 1, updated_at: 1,
is_embedded: 0, confidence: null, metadata_json: null, src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
};
}
+154
View File
@@ -0,0 +1,154 @@
// 0007 樹狀 record 模型遷移驗收(v7 定稿,leo 2026-08-15 confirm)。
//
// 驗收條件(派工單指定,逐條對應):
// 1. 既有資料一筆都不能掉:entry_values 每列 → 一條格子關係列;每筆 record → 身分 entry
// 2. 冪等:同一份遷移語句跑兩次結果相同——用最嚴苛的那條套用路徑模擬
// (安裝器逐句重放:剝註解、依分號切句、逐句執行、容錯 duplicate column
// 3. value entries 一列都不動(id 穩定 ⇒ 向量索引不失效)
// 4. 孤兒偵測有答案:新模型的孤兒=指標指向不存在 id 的關係列(LEFT JOIN 斷鏈掃描)
//
// 測試策略比照 execution-log.test.ts:真 SQLitenode:sqlite)套 migrations 原檔。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { scanRelationOrphans } from '../src/actions/relation-orphans';
import { getRecord, searchByTemplate } from '../src/actions/record-crud';
// ── 安裝器逐句重放模式(products/arcrun-rag installer 的實際手法)──────────────
function splitStatements(sql: string): string[] {
return sql
.split('\n')
.map((l) => l.replace(/--.*$/, ''))
.join('\n')
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
function applyTolerant(raw: DatabaseSync, file: string): string[] {
const sql = readFileSync(new URL(`../migrations/${file}`, import.meta.url), 'utf8');
const errors: string[] = [];
for (const stmt of splitStatements(sql)) {
try {
raw.exec(stmt); // kbdb-sql-ok:測試治具逐句重放 migration 原檔(模擬安裝器路徑)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (/duplicate column/i.test(msg)) continue; // ADD COLUMN 重跑=已套用
errors.push(`${stmt.slice(0, 50)}… → ${msg}`);
}
}
return errors;
}
// ── 舊世界資料庫(0001 schema entry_values 真資料)────────────────────────
function legacyDB(): DatabaseSync {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 migration 原檔
raw.exec(`INSERT OR IGNORE INTO templates (id,name,description,slots_json,created_by) VALUES ('tpl-triplet','triplet','t','["subject","predicate","object","library"]','system')`); // kbdb-sql-ok:測試治具 seed
const insE = raw.prepare(`INSERT INTO entries (id,content,entry_type,owner_id) VALUES (?,?,?,?)`); // kbdb-sql-ok:測試治具 seed
const insEV = raw.prepare(`INSERT INTO entry_values (id,record_id,template_id,slot_name,entry_id) VALUES (?,?,?,?,?)`); // kbdb-sql-ok:測試治具 seed(舊表在 0007 前存在)
for (let r = 0; r < 40; r++) {
for (const s of ['subject', 'predicate', 'object', 'library']) {
insE.run(`e_${r}_${s}`, `${s}-${r}`, 'value', 'leo');
insEV.run(`ev_${r}_${s}`, `rec_${r}`, 'tpl-triplet', s, `e_${r}_${s}`);
}
}
// 一筆「block 即 record」(library_map 慣例):record_id 是既有 block entry 的 id
insE.run('blk_map', '地圖 block', 'block', 'leo');
insE.run('e_map_lib', 'general', 'value', 'leo');
insEV.run('ev_map_lib', 'blk_map', 'tpl-triplet', 'library', 'e_map_lib');
return raw;
}
const count = (raw: DatabaseSync, sql: string): number => (raw.prepare(sql).get() as { n: number }).n; // kbdb-sql-ok:測試治具讀回斷言
function snapshot(raw: DatabaseSync) {
return {
total: count(raw, 'SELECT COUNT(*) AS n FROM entries'),
cells: count(raw, "SELECT COUNT(*) AS n FROM entries WHERE src_id IS NOT NULL AND rel_id NOT IN ('sys_belongs','sys_field_of')"),
belongs: count(raw, "SELECT COUNT(*) AS n FROM entries WHERE rel_id = 'sys_belongs'"),
identities: count(raw, "SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'record'"),
values: count(raw, "SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'value'"),
evTable: count(raw, "SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='entry_values'"),
};
}
// node:sqlite → D1 最小 adapter(同 execution-log.test.ts 手法)
function asD1(raw: DatabaseSync): D1Database {
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() { const r = raw.prepare(sql).run(...(params as never[])); return { success: true, meta: { changes: r.changes } }; }, // kbdb-sql-ok:測試治具
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
describe('0007 — 資料一筆都不能掉(entry_values → 關係列)', () => {
it('每格一條關係列、每 record 一顆身分、value entries 一列不動、舊表拆掉', () => {
const raw = legacyDB();
const evBefore = count(raw, 'SELECT COUNT(*) AS n FROM entry_values');
const valuesBefore = count(raw, "SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'value'");
const valueIdsBefore = raw.prepare("SELECT id FROM entries WHERE entry_type='value' ORDER BY id").all(); // kbdb-sql-ok:測試治具
const errors = applyTolerant(raw, '0007_tree_record_model.sql');
expect(errors).toEqual([]);
const s = snapshot(raw);
expect(s.cells).toBe(evBefore); // 每格一條關係列,一列不漏
expect(s.values).toBe(valuesBefore); // value entries 沒被動
expect(s.evTable).toBe(0); // entry_values 不存在=遷移完成的證明(v7 §7)
// 41 筆 record40 顆裸身分(entry_type='record')+ 1 顆 block 即 record(不另建身分)
expect(s.identities).toBe(40);
expect(count(raw, "SELECT COUNT(*) AS n FROM entries WHERE id = 'blk_map' AND entry_type = 'block'")).toBe(1);
// 歸屬:41 筆 record 2 張 sheettpl-triplet 與 0001 自帶 seed 的 tpl-recipe-stat,各一條 ─屬於→ sys_root
expect(s.belongs).toBe(43);
// id 穩定(向量索引不失效):value entry 的 id 集合前後逐字相同
const valueIdsAfter = raw.prepare("SELECT id FROM entries WHERE entry_type='value' ORDER BY id").all(); // kbdb-sql-ok:測試治具
expect(valueIdsAfter).toEqual(valueIdsBefore);
});
it('遷移後讀端讀得回同樣的資料(getRecordsearchByTemplate 走關係列)', async () => {
const raw = legacyDB();
applyTolerant(raw, '0007_tree_record_model.sql');
const db = asD1(raw);
const rec = await getRecord(db, 'rec_7');
expect(rec).not.toBeNull();
expect(rec!.values).toEqual({ subject: 'subject-7', predicate: 'predicate-7', object: 'object-7', library: 'library-7' });
expect(rec!.template_id).toBe('tpl-triplet');
expect(rec!.owner_id).toBe('leo');
const all = await searchByTemplate(db, 'triplet', 'leo', 500);
expect(all.length).toBe(41);
});
});
describe('0007 — 冪等(同一份語句跑兩次結果相同;三條套用路徑最嚴苛的一條)', () => {
it('第二次逐句重放:零錯誤、快照逐字相等', () => {
const raw = legacyDB();
const e1 = applyTolerant(raw, '0007_tree_record_model.sql');
const s1 = snapshot(raw);
const e2 = applyTolerant(raw, '0007_tree_record_model.sql');
const s2 = snapshot(raw);
expect(e1).toEqual([]);
expect(e2).toEqual([]);
expect(s2).toEqual(s1);
});
});
describe('0007 配套 — 孤兒偵測(v7 §5:新模型的孤兒要有答案,不能默認)', () => {
it('乾淨遷移後零孤兒;人為斷鏈後掃得到、指得出斷在哪一端', async () => {
const raw = legacyDB();
applyTolerant(raw, '0007_tree_record_model.sql');
const db = asD1(raw);
expect((await scanRelationOrphans(db)).count).toBe(0);
// 人為製造斷鏈(模擬繞牆直刪——正是 2026-06-24 事故那類殘局)
raw.exec("DELETE FROM entries WHERE id = 'e_3_object'"); // kbdb-sql-ok:測試治具刻意製造孤兒
const report = await scanRelationOrphans(db);
expect(report.count).toBe(1);
expect(report.orphans[0]).toEqual({ relation_id: 'relv_ev_3_object', role: 'dst', missing_id: 'e_3_object' });
});
});
@@ -34,6 +34,7 @@ function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:'); // kbdb-sql-ok: 記憶體測試替身,非真 KBDB D1
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 灌測試替身 schema,非真 D1
raw.exec(readFileSync(new URL('../migrations/0003_library_map.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 同上
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },