fix(kbdb): 刪 record 不再刪掉別人還指著的 entry(Arcrun#128 的必然配套)

上一筆讓 slot 可以指向既有 entry 之後,同一條 entry 會同時被別的 record 指著。
deleteRecord 舊寫法是「這筆 record 的每個 entry_id 都刪掉」,在共用的情況下會:
  · 把**別人還在用**的那條水池資料一起刪掉(他的 slot 從此指向不存在的列),或
  · 撞上 `entry_values.entry_id REFERENCES entries(id)` 的 FK 而整個刪除失敗
⇒ 條件改成「已經沒有任何 entry_values 指著它」才刪
  (`DELETE FROM entries WHERE id = ? AND NOT EXISTS (SELECT 1 FROM entry_values WHERE entry_id = ?)`)

**沒有共用時行為與舊版完全相同**:這筆 record 的關聯列已先刪掉,若沒有別人指著,
NOT EXISTS 恆為真 ⇒ 照樣刪。差別只出現在真的被共用的那一條上。

驗(同檔 +2 條,真 SQLite 且 `PRAGMA foreign_keys = ON`)
· 兩筆 record 共用一段、刪掉其中一筆:entries 2 → 1(只少掉那筆自己專屬的 title),
  共用那條還在,另一筆 record 讀回來的 slot 值不受影響
· 沒有共用時:3 個 slot 的 entry 全刪、entry_values 歸零、getRecord 回 null(同舊版)

kbdb 全套 232 綠

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-15 14:44:32 +08:00
parent 5b22d569c9
commit aa49b4eeb2
2 changed files with 60 additions and 2 deletions
+17 -2
View File
@@ -325,7 +325,19 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
}
/** 刪除一筆 record:先刪 entry_valuesFK),再刪底層 entries。回 false 表示 record 不存在。 */
/**
* 刪除一筆 record:先刪 entry_valuesFK),再刪底層 entries。回 false 表示 record 不存在。
*
* 🔴 **還有別人指著的 entry 不刪**Arcrun#128 的必然配套,不是順手加的):
* slot 值可以指向既有 entry 之後,同一條 entry 會同時被別的 record 指著。
* 舊寫法「這筆 record 的每個 entry_id 都刪掉」在那種情況下會:
* · 把**別人還在用**的那條水池資料一起刪掉(他的 slot 從此指向不存在的列),或
* · 撞上 `entry_values.entry_id REFERENCES entries(id)` 的 FK 而整個刪除失敗。
* ⇒ 條件改成「已經沒有任何 entry_values 指著它」才刪。
*
* **沒有共用時行為與舊版完全相同**:這筆 record 的關聯列已先刪掉,若沒有別人指著,
* `NOT EXISTS` 恆為真 ⇒ 照樣刪。差別只出現在「真的被共用」的那條上。
*/
export async function deleteRecord(db: D1Database, recordId: string): Promise<boolean> {
const evRes = await db
.prepare('SELECT entry_id FROM entry_values WHERE record_id = ?')
@@ -335,7 +347,10 @@ export async function deleteRecord(db: D1Database, recordId: string): Promise<bo
if (rows.length === 0) return false;
await db.prepare('DELETE FROM entry_values WHERE record_id = ?').bind(recordId).run();
for (const { entry_id } of rows) {
await db.prepare('DELETE FROM entries WHERE id = ?').bind(entry_id).run();
await db
.prepare('DELETE FROM entries WHERE id = ? AND NOT EXISTS (SELECT 1 FROM entry_values WHERE entry_id = ?)')
.bind(entry_id, entry_id)
.run();
}
return true;
}
+43
View File
@@ -335,3 +335,46 @@ describe('Arcrun#128 — 指不到的外鍵要當場講話,而且一列都不
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();
});
});