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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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); },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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/templates(entry_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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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); },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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); },
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 D1(0007 樹狀 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 查詢 → 回 template(slots_json 含既有 + 可 grow 的 slot)
|
||||
* - INSERT INTO entries → 捕捉參數(本測試的斷言目標)
|
||||
* - INSERT INTO entries → 捕捉參數(本測試的斷言目標;ensureFieldEntries 走
|
||||
* INSERT OR IGNORE,字面不同,不會污染這個斷言)
|
||||
* - SELECT * FROM entries WHERE id → 回假 entry(createEntry 的 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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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:真 SQLite(node: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 筆 record:40 顆裸身分(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 張 sheet(tpl-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('遷移後讀端讀得回同樣的資料(getRecord/searchByTemplate 走關係列)', 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); },
|
||||
|
||||
Reference in New Issue
Block a user