merge: Arcrun#128 外鍵接上去——slot 可以指向既有 entry,不再複製
總管逐筆審過三筆,並自己跑過測試(不是採信交件):
· kbdb 218 → 232 綠(新增 14 支),零回歸
· cypher-executor 445/459;那 14 支紅在 gitea/main 上就是紅的(main 439/453)
⇒ 零回歸。順帶記下:那套測試已經不是閘了,另案
三筆各是什麼:
5b22d56 createRecord 新增 entry_ids 通道,與 values 並存(舊呼叫端一字不改)
aa49b4e 刪 record 不再刪掉別人還指著的 entry —— **我的派工單沒提,它自己想到的必然配套**
9ba6ba7 cypher-executor 的 POST /kbdb/records 開通同一條通道
它自己補了兩件我沒指定的:
① 🔴 租戶邊界——這條新路徑讓呼叫端可以自己指定 entry_id,不檢查歸屬的話
任何人都能把別人的 entry 掛進自己的 record 再讀回內容 ⇒ 繞過租戶邊界的門
(它引 .claude/rules/02-forbidden.md §6.1 當依據)
② 對照組測試——「同樣五個 slot 給字串 → entries 增加五筆」
只證修法有效沒人知道原本壞在哪;它同時證明了問題存在
驗收五條全部有對應測試:水池列數不增加/一條 entry 被多筆 record 多個 slot 指到/
改內容兩邊都變(副本做不到)/舊呼叫端行為不變/指不到的外鍵當場報錯且一列不寫。
未做(刻意):.worker-builds/ 未重編 ⇒ 修法只在源碼,還沒進任何執行檔。
leo 2026-08-15 裁定先不出貨,重編屬於出貨動作。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -81,20 +81,33 @@ kbdbProxyRouter.get('/kbdb/templates/:idOrName', async (c) => {
|
|||||||
|
|
||||||
// ── records(以租戶 namespace 為 owner_id 隔離)────────────────────────────────
|
// ── records(以租戶 namespace 為 owner_id 隔離)────────────────────────────────
|
||||||
|
|
||||||
// POST /kbdb/records — 填一筆 record(template + values)。owner_id 自動注入。
|
// POST /kbdb/records — 填一筆 record(template + values/entry_ids)。owner_id 自動注入。
|
||||||
|
//
|
||||||
|
// `entry_ids`(Arcrun#128)= slot 指向**既有** entry 的 id,不新建、不複製;與 values 並存
|
||||||
|
// (給字串照舊新建)。這裡維持純轉發,判斷與擋人全在基本盤 kbdb(薄殼鐵律,見檔頭):
|
||||||
|
// · 兩者都沒給/型別不對 → base 回 400
|
||||||
|
// · 指到別人的 entry → base 擋(它比對被參照 entry 的 owner_id 與這裡注入的租戶身份,
|
||||||
|
// 所以「呼叫端自己指定 entry_id」這條新路徑不會變成跨租戶的門)
|
||||||
|
// 🔴 為什麼通道要一起開:#129(wiki template)與 #130(三元組正規化)的寫入端走這扇門。
|
||||||
|
// 基本盤補好而通道不開=能力在、沒人打得到——同 PATCH 那次(b6ef0f0)的教訓。
|
||||||
kbdbProxyRouter.post('/kbdb/records', async (c) => {
|
kbdbProxyRouter.post('/kbdb/records', async (c) => {
|
||||||
const owner = tenant(c);
|
const owner = tenant(c);
|
||||||
if (!owner) return c.json(NEED_KEY, 401);
|
if (!owner) return c.json(NEED_KEY, 401);
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
if (!body || !body.template || !body.values) {
|
if (!body || !body.template || (!body.values && !body.entry_ids)) {
|
||||||
return c.json({ error: 'template 與 values 必填' }, 400);
|
return c.json({ error: 'template 必填,values 與 entry_ids 至少要有一個' }, 400);
|
||||||
}
|
}
|
||||||
const { base, headers } = kbdbBase(c.env);
|
const { base, headers } = kbdbBase(c.env);
|
||||||
const res = await fetch(`${base}/records`, {
|
const res = await fetch(`${base}/records`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
// 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入)
|
// 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入)
|
||||||
body: JSON.stringify({ template: body.template, values: body.values, owner_id: owner }),
|
body: JSON.stringify({
|
||||||
|
template: body.template,
|
||||||
|
...(body.values ? { values: body.values } : {}),
|
||||||
|
...(body.entry_ids ? { entry_ids: body.entry_ids } : {}),
|
||||||
|
owner_id: owner,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* POST /kbdb/records — `entry_ids` 通道(Arcrun#128)
|
||||||
|
*
|
||||||
|
* 背景:基本盤 kbdb 的 createRecord 現在接受 `entry_ids`(slot 指向**既有** entry 的 id,
|
||||||
|
* 不新建、不複製)。這條 proxy 之前寫死只轉發 `values`,且沒有 values 就 400
|
||||||
|
* ⇒ 走 X-Arcrun-API-Key 的呼叫者(#129 的 wiki 寫入端、#130 的三元組正規化)打不到新能力,
|
||||||
|
* 等於基本盤補好了、通道沒開(同 b6ef0f0 那次 PATCH 的形狀)。
|
||||||
|
*
|
||||||
|
* 驗的是 IO 接線(判斷真身在基本盤,這裡只測轉發,比照 kbdb-records-patch-proxy.test.ts):
|
||||||
|
* 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB
|
||||||
|
* 2. 只給 entry_ids(沒有 values)→ 轉發成功(舊版這裡是 400)
|
||||||
|
* 3. 轉發的 body:帶 entry_ids + **注入租戶當 owner_id**(caller 自帶的 owner_id 被忽略)
|
||||||
|
* 4. values 與 entry_ids 混用 → 兩個都轉過去
|
||||||
|
* 5. 兩個都沒給 → 400,不轉發
|
||||||
|
* 6. base 擋跨租戶(400)→ 原樣透傳,不假裝成功
|
||||||
|
*
|
||||||
|
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||||
|
* disableNetConnect——測試絕不外連。
|
||||||
|
*/
|
||||||
|
import { SELF, fetchMock } from 'cloudflare:test';
|
||||||
|
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
fetchMock.activate();
|
||||||
|
fetchMock.disableNetConnect();
|
||||||
|
});
|
||||||
|
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||||
|
|
||||||
|
describe('POST /kbdb/records(entry_ids)— 租戶閘與參數', () => {
|
||||||
|
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
|
||||||
|
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1' } }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('values 與 entry_ids 都沒給 → 400,不轉發', async () => {
|
||||||
|
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: KEY,
|
||||||
|
body: JSON.stringify({ template: 'wiki' }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /kbdb/records(entry_ids)— 轉發', () => {
|
||||||
|
it('只給 entry_ids(沒有 values)→ 轉發,且 owner_id 由租戶身份注入', async () => {
|
||||||
|
fetchMock
|
||||||
|
.get('https://kbdb.test')
|
||||||
|
.intercept({
|
||||||
|
path: '/records',
|
||||||
|
method: 'POST',
|
||||||
|
// 沒有 values 這個 key(不要憑空塞一個空物件給基本盤)
|
||||||
|
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'leo' }),
|
||||||
|
})
|
||||||
|
.reply(200, {
|
||||||
|
success: true,
|
||||||
|
record: { record_id: 'rec_1', template_id: 'tpl-wiki', values: { gloss: '既有', points: '既有2' }, owner_id: 'leo' },
|
||||||
|
});
|
||||||
|
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: KEY,
|
||||||
|
// caller 自帶 owner_id:必須被忽略(防跨租戶寫入,本檔既有慣例)
|
||||||
|
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'alice' }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { success: boolean; record: { values: Record<string, string> } };
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.record.values.gloss).toBe('既有');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('values 與 entry_ids 混用 → 兩個都轉過去', async () => {
|
||||||
|
fetchMock
|
||||||
|
.get('https://kbdb.test')
|
||||||
|
.intercept({
|
||||||
|
path: '/records',
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' }, owner_id: 'leo' }),
|
||||||
|
})
|
||||||
|
.reply(200, { success: true, record: { record_id: 'rec_2', template_id: 'tpl-wiki', values: {}, owner_id: 'leo' } });
|
||||||
|
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: KEY,
|
||||||
|
body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' } }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('舊呼叫端(只給 values)→ 轉發的 body 不夾帶 entry_ids,行為與過去相同', async () => {
|
||||||
|
fetchMock
|
||||||
|
.get('https://kbdb.test')
|
||||||
|
.intercept({
|
||||||
|
path: '/records',
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' }, owner_id: 'leo' }),
|
||||||
|
})
|
||||||
|
.reply(200, { success: true, record: { record_id: 'rec_3', template_id: 'tpl-triplet', values: {}, owner_id: 'leo' } });
|
||||||
|
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: KEY,
|
||||||
|
body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' } }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('base 擋下跨租戶參照(400)→ 原樣透傳,不假裝成功', async () => {
|
||||||
|
fetchMock
|
||||||
|
.get('https://kbdb.test')
|
||||||
|
.intercept({ path: '/records', method: 'POST' })
|
||||||
|
.reply(400, { success: false, error: 'entry owner mismatch: e_x(alice) != leo' });
|
||||||
|
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: KEY,
|
||||||
|
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_x' } }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const data = (await res.json()) as { success: boolean; error: string };
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
expect(data.error).toContain('owner mismatch');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,11 +56,87 @@ export async function updateTemplate(db: D1Database, id: string, patch: { descri
|
|||||||
|
|
||||||
export interface CreateRecordInput {
|
export interface CreateRecordInput {
|
||||||
template: string; // template id or name
|
template: string; // template id or name
|
||||||
values: Record<string, string>; // slot_name -> content
|
values?: Record<string, string>; // slot_name -> content(**新建**一筆 entry 當這個 slot 的值)
|
||||||
|
/**
|
||||||
|
* slot_name -> **既有** entry 的 id:把水池(entries)裡那條既有 entry 直接掛到這個 slot 上,
|
||||||
|
* 不新建、不複製(Arcrun#128)。
|
||||||
|
*
|
||||||
|
* 🔴 為什麼要有這條路(不是優化,是「外鍵」本來就該有的樣子):
|
||||||
|
* leo 2026-08-15 的心智模型——「blocks 是一個大水池,template/slots 組成虛擬表和 fields,
|
||||||
|
* 最後都指向水池的一條 entry⋯⋯連到三元組就是外鍵」。而 `entry_values` 的約束
|
||||||
|
* (`migrations/0001_base.sql`)只有 `UNIQUE(record_id, slot_name)`,
|
||||||
|
* **`entry_id` 上沒有任何 unique** ⇒ 一條 entry 本來就能被無限多筆 record 的無限多個
|
||||||
|
* slot 參照,**儲存層早就是外鍵語意**。壞的只有寫入路徑:本函式舊版對每個 slot 值
|
||||||
|
* **無條件 createEntry** ⇒ 每設一次外鍵就把被參照的資料複製一份。
|
||||||
|
* 那不是外鍵,那是複製。
|
||||||
|
*
|
||||||
|
* 後果不只是多佔列數:兩份從此**各自漂移**(改一邊,另一邊還是舊的),
|
||||||
|
* 且資料量隨「有幾個 App 參照它」線性膨脹 ⇒ 遲早要有人來清、去重、修對不上的兩份
|
||||||
|
* ⇒ 直接違背這個模型的產品承諾「加一個 App 只要建一份 template,不必遷移、不需要工程師」
|
||||||
|
* (`InkStoneCo/system-dev/docs/4-guides/llm-wiki-schema.md`)。
|
||||||
|
*
|
||||||
|
* **與 values 並存**:給字串 → 照舊新建(舊呼叫端一個字都不用改);給 id → 參照既有。
|
||||||
|
*/
|
||||||
|
entry_ids?: Record<string, string>;
|
||||||
owner_id?: string | null;
|
owner_id?: string | null;
|
||||||
record_id?: string;
|
record_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 被參照 entry 的 id -> 它現在的 content(回傳值要帶真內容,不是空殼)。 */
|
||||||
|
type ReferencedContent = Map<string, string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讀出被參照的既有 entry,並在**寫入任何一列之前**把該擋的擋掉。
|
||||||
|
*
|
||||||
|
* 為什麼要先讀(不是多此一舉,靠 FK 報錯不夠):
|
||||||
|
* 1. **id 不存在**要給看得懂的錯(FK 違反在 D1 只回一句 SQLITE_CONSTRAINT,
|
||||||
|
* 呼叫端不知道是哪個 slot、哪個 id);
|
||||||
|
* 2. **跨租戶必須擋**——這條新路徑讓呼叫端可以自己指定 entry_id,若不檢查歸屬,
|
||||||
|
* 任何人都能把別人的 entry 掛進自己的 record,再從 `GET /records/:id` 讀回它的內容
|
||||||
|
* ⇒ 等於開一扇繞過租戶邊界的門(同 `.claude/rules/02-forbidden.md` §6.1 的精神:
|
||||||
|
* 資料面的歸屬要與寫入端同源);
|
||||||
|
* 3. 回傳值要帶被參照 entry 的**現有內容**(呼叫端拿到的 values 才是那條真的 entry)。
|
||||||
|
*
|
||||||
|
* 所有檢查都在第一筆 INSERT 之前跑完 ⇒ 失敗就是「一列都沒寫」,不留半筆殘骸
|
||||||
|
* (base 沒有交易可用,這是這裡唯一保證得了的原子性形式)。
|
||||||
|
*
|
||||||
|
* 批次以 90 個 id 一組:D1 綁定參數上限 100,沿用 searchByTemplate 既有慣例。
|
||||||
|
*/
|
||||||
|
async function loadReferencedEntries(
|
||||||
|
db: D1Database,
|
||||||
|
entryIds: Record<string, string>,
|
||||||
|
recordOwnerId: string | null,
|
||||||
|
): Promise<ReferencedContent> {
|
||||||
|
const ids = [...new Set(Object.values(entryIds))];
|
||||||
|
if (ids.length === 0) return new Map();
|
||||||
|
|
||||||
|
const rows: { id: string; content: string | null; owner_id: string | null }[] = [];
|
||||||
|
for (let i = 0; i < ids.length; i += 90) {
|
||||||
|
const chunk = ids.slice(i, i + 90);
|
||||||
|
const res = await db
|
||||||
|
.prepare(`SELECT id, content, owner_id FROM entries WHERE id IN (${chunk.map(() => '?').join(',')})`)
|
||||||
|
.bind(...chunk)
|
||||||
|
.all<{ id: string; content: string | null; owner_id: string | null }>();
|
||||||
|
rows.push(...(res.results ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = new Map(rows.map((r) => [r.id, r]));
|
||||||
|
const missing = ids.filter((id) => !found.has(id));
|
||||||
|
if (missing.length > 0) throw new Error(`entry not found: ${missing.join(', ')}`);
|
||||||
|
|
||||||
|
// 歸屬不同 → 擋。owner_id 為 null 的 entry 視為無主/共用(既有資料多半如此),放行。
|
||||||
|
if (recordOwnerId != null) {
|
||||||
|
const foreign = rows.filter((r) => r.owner_id != null && r.owner_id !== recordOwnerId);
|
||||||
|
if (foreign.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`entry owner mismatch: ${foreign.map((r) => `${r.id}(${r.owner_id})`).join(', ')} != ${recordOwnerId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Map(rows.map((r) => [r.id, r.content]));
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecordResult {
|
export interface RecordResult {
|
||||||
record_id: string;
|
record_id: string;
|
||||||
template_id: string;
|
template_id: string;
|
||||||
@@ -79,11 +155,36 @@ export async function createRecord(db: D1Database, input: CreateRecordInput): Pr
|
|||||||
if (!tpl) throw new Error(`template not found: ${input.template}`);
|
if (!tpl) throw new Error(`template not found: ${input.template}`);
|
||||||
const slots: string[] = JSON.parse(tpl.slots_json);
|
const slots: string[] = JSON.parse(tpl.slots_json);
|
||||||
const recordId = input.record_id ?? uid('rec');
|
const recordId = input.record_id ?? uid('rec');
|
||||||
|
const values = input.values ?? {};
|
||||||
|
const entryIds = input.entry_ids ?? {};
|
||||||
|
const refSlots = Object.keys(entryIds);
|
||||||
|
|
||||||
|
// 同一個 slot 不准同時給字串又給 id:兩者的意思相反(複製一份 vs 指向既有),
|
||||||
|
// 猜哪一個都可能默默寫錯一份資料 ⇒ 當場報錯,不猜。
|
||||||
|
const both = refSlots.filter((s) => s in values);
|
||||||
|
if (both.length > 0) throw new Error(`slot given both value and entry_id: ${both.join(', ')}`);
|
||||||
|
|
||||||
|
// entry_ids 指到 template 沒有的 slot → 報錯(**不學 values 那條「靜默略過」**)。
|
||||||
|
// 理由:外鍵設了卻無聲消失是最難查的失敗——呼叫端會以為關聯建好了,
|
||||||
|
// 而 `template=` 查詢永遠撈不到它(查詢走 entry_values)。讓它當場講話。
|
||||||
|
const unknown = refSlots.filter((s) => !slots.includes(s));
|
||||||
|
if (unknown.length > 0) throw new Error(`slot not in template: ${unknown.join(', ')}`);
|
||||||
|
|
||||||
|
// 全部檢查(存在/歸屬)先跑完再寫,失敗=一列都沒寫。
|
||||||
|
const referenced = await loadReferencedEntries(db, entryIds, input.owner_id ?? null);
|
||||||
|
|
||||||
for (const slot of slots) {
|
for (const slot of slots) {
|
||||||
if (!(slot in input.values)) continue;
|
// 參照既有 entry:只插一列關聯,**不碰 entries**(這就是外鍵)。
|
||||||
|
if (slot in entryIds) {
|
||||||
|
await db
|
||||||
|
.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`)
|
||||||
|
.bind(uid('ev'), recordId, tpl.id, slot, entryIds[slot])
|
||||||
|
.run();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!(slot in values)) continue;
|
||||||
const entry = await createEntry(db, {
|
const entry = await createEntry(db, {
|
||||||
content: input.values[slot],
|
content: values[slot],
|
||||||
entry_type: 'value',
|
entry_type: 'value',
|
||||||
owner_id: input.owner_id ?? null,
|
owner_id: input.owner_id ?? null,
|
||||||
});
|
});
|
||||||
@@ -92,7 +193,11 @@ export async function createRecord(db: D1Database, input: CreateRecordInput): Pr
|
|||||||
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
|
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
|
|
||||||
|
// 回傳值:舊路徑照舊原樣回 input.values(一字不變),參照來的 slot 補上那條既有 entry 的現有內容。
|
||||||
|
const out: Record<string, string> = { ...values };
|
||||||
|
for (const [slot, entryId] of Object.entries(entryIds)) out[slot] = referenced.get(entryId) ?? '';
|
||||||
|
return { record_id: recordId, template_id: tpl.id, values: out, owner_id: input.owner_id ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
|
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
|
||||||
@@ -220,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);
|
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 刪除一筆 record:先刪 entry_values(FK),再刪底層 entries。回 false 表示 record 不存在。 */
|
/**
|
||||||
|
* 刪除一筆 record:先刪 entry_values(FK),再刪底層 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> {
|
export async function deleteRecord(db: D1Database, recordId: string): Promise<boolean> {
|
||||||
const evRes = await db
|
const evRes = await db
|
||||||
.prepare('SELECT entry_id FROM entry_values WHERE record_id = ?')
|
.prepare('SELECT entry_id FROM entry_values WHERE record_id = ?')
|
||||||
@@ -230,7 +347,10 @@ export async function deleteRecord(db: D1Database, recordId: string): Promise<bo
|
|||||||
if (rows.length === 0) return false;
|
if (rows.length === 0) return false;
|
||||||
await db.prepare('DELETE FROM entry_values WHERE record_id = ?').bind(recordId).run();
|
await db.prepare('DELETE FROM entry_values WHERE record_id = ?').bind(recordId).run();
|
||||||
for (const { entry_id } of rows) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,25 @@ import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord }
|
|||||||
|
|
||||||
export const recordRoutes = new Hono<{ Bindings: Bindings }>();
|
export const recordRoutes = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
// POST /records — { template, values:{slot:content}, owner_id? }
|
// POST /records — { template, values:{slot:content}?, entry_ids:{slot:既有entry_id}?, owner_id? }
|
||||||
|
//
|
||||||
|
// values = 給字串 → **新建**一筆 entry 當這個 slot 的值(原本就有的路,行為不變)
|
||||||
|
// entry_ids = 給既有 entry 的 id → **指向**水池裡那條 entry,不複製(Arcrun#128 的外鍵)
|
||||||
|
// 兩者可混用;至少要有一個。**只給 entry_ids 是合法的**——例如一則 wiki 的五段在 KBDB 裡
|
||||||
|
// 本來就已經是五筆既有 entry,建 record 只是替它們取名字,不該再生出任何新 entry。
|
||||||
|
const isStringMap = (v: unknown): boolean =>
|
||||||
|
!!v && typeof v === 'object' && !Array.isArray(v) && Object.values(v as object).every((x) => typeof x === 'string');
|
||||||
|
|
||||||
recordRoutes.post('/', async (c) => {
|
recordRoutes.post('/', async (c) => {
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
if (!body || !body.template || !body.values) {
|
if (!body || !body.template || (!body.values && !body.entry_ids)) {
|
||||||
return c.json({ success: false, error: 'template and values required' }, 400);
|
return c.json({ success: false, error: 'template and values (or entry_ids) required' }, 400);
|
||||||
|
}
|
||||||
|
if (body.values !== undefined && !isStringMap(body.values)) {
|
||||||
|
return c.json({ success: false, error: 'values must be an object of {slot: string}' }, 400);
|
||||||
|
}
|
||||||
|
if (body.entry_ids !== undefined && !isStringMap(body.entry_ids)) {
|
||||||
|
return c.json({ success: false, error: 'entry_ids must be an object of {slot: entry_id}' }, 400);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const rec = await createRecord(c.env.DB, body);
|
const rec = await createRecord(c.env.DB, body);
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
// 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('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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const countEntries = (raw: DatabaseSync): number =>
|
||||||
|
(raw.prepare('SELECT COUNT(*) AS n FROM entries').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:測試治具
|
||||||
|
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:測試治具
|
||||||
|
| { 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user