ceb7638d74
規格: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>
108 lines
5.2 KiB
TypeScript
108 lines
5.2 KiB
TypeScript
// portal-auth P2 附帶修復回歸測試(design §2.2):
|
||
// updateRecord 的「grow 新 slot」路徑建 entry 時,必須帶 record 既有 owner_id。
|
||
// 修前:createEntry(db, { content, entry_type: 'value' }) → owner_id=NULL 孤兒 entry,
|
||
// owner-scoped 查詢(searchByTemplate JOIN entries.owner_id / searchEntries owner filter)看不到該 slot 值。
|
||
// 測法沿 library-filter.test.ts 慣例:fake D1 按 SQL 形狀路由回應+捕捉 INSERT 參數
|
||
// (不解讀 SQL 語意——真語意由本機 miniflare 端到端驗證,PR 附證據)。
|
||
import { describe, it, expect } from 'vitest';
|
||
import { updateRecord } from '../src/actions/record-crud';
|
||
|
||
interface Captured { sql: string; params: unknown[] }
|
||
|
||
/**
|
||
* 可路由 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 → 捕捉參數(本測試的斷言目標;ensureFieldEntries 走
|
||
* INSERT OR IGNORE,字面不同,不會污染這個斷言)
|
||
* - SELECT * FROM entries WHERE id → 回假 entry(createEntry 的 insert 後回讀)
|
||
* - 其餘(UPDATE / 關係列 INSERT / getRecord SELECT)→ 空殼
|
||
*/
|
||
function makeRoutedDB(recordOwnerId: string | null, captured: Captured[]) {
|
||
const prepare = (sql: string) => {
|
||
const rec: Captured = { sql, params: [] };
|
||
captured.push(rec);
|
||
const stmt = {
|
||
bind(...args: unknown[]) { rec.params = args; return stmt; },
|
||
async all<T>() {
|
||
// 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' },
|
||
] as unknown 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;
|
||
}
|
||
return null as unknown as T;
|
||
},
|
||
async run() { return { success: true }; },
|
||
};
|
||
return stmt;
|
||
};
|
||
return { prepare } as unknown as D1Database;
|
||
}
|
||
|
||
describe('updateRecord grow-slot 路徑帶 owner_id(design §2.2 回歸)', () => {
|
||
it('record 有 owner_id → grow 出的新 entry INSERT 帶同一 owner_id(修前為 null)', async () => {
|
||
const captured: Captured[] = [];
|
||
const db = makeRoutedDB('leo::portal', captured);
|
||
// 'status' slot 在 template 裡但 record 尚無 → 走 grow 路徑
|
||
await updateRecord(db, 'rec_1', { status: 'active' });
|
||
|
||
const insertEntry = captured.find((c) => c.sql.includes('INSERT INTO entries'));
|
||
expect(insertEntry, 'grow 路徑應 INSERT 新 entry').toBeTruthy();
|
||
// entry-crud.ts createEntry 的 INSERT 欄位序:id, content, entry_type, owner_id, ...
|
||
expect(insertEntry!.params[1]).toBe('active'); // content
|
||
expect(insertEntry!.params[2]).toBe('value'); // entry_type
|
||
expect(insertEntry!.params[3]).toBe('leo::portal'); // owner_id ← 本修復的斷言核心
|
||
});
|
||
|
||
it('record 本就無 owner_id(全域資料)→ grow 出的 entry owner_id 維持 null(行為不變)', async () => {
|
||
const captured: Captured[] = [];
|
||
const db = makeRoutedDB(null, captured);
|
||
await updateRecord(db, 'rec_1', { status: 'active' });
|
||
|
||
const insertEntry = captured.find((c) => c.sql.includes('INSERT INTO entries'));
|
||
expect(insertEntry).toBeTruthy();
|
||
expect(insertEntry!.params[3]).toBeNull();
|
||
});
|
||
|
||
it('既有 slot 更新(非 grow)→ 走 UPDATE entries,不 INSERT 新 entry', async () => {
|
||
const captured: Captured[] = [];
|
||
const db = makeRoutedDB('leo::portal', captured);
|
||
await updateRecord(db, 'rec_1', { email: 'new@example.com' });
|
||
|
||
expect(captured.some((c) => c.sql.includes('UPDATE entries SET content'))).toBe(true);
|
||
expect(captured.some((c) => c.sql.includes('INSERT INTO entries'))).toBe(false);
|
||
});
|
||
});
|