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>
386 lines
20 KiB
TypeScript
386 lines
20 KiB
TypeScript
// 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_query(searchByTemplate)撈得到用 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,且 entries/entry_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();
|
||
});
|
||
});
|