0860e84d22
leo:「需要加移除按鈕。因為別人裝錯我沒辦法幫他弄,需要可以自主」
+「你應該要顯示這個庫沒有本地對應的 folder,那就不容易刪錯」
(兩態非三態——leo 二修:「分兩種沒意義」,那是內部狀態不是用戶分類)。
- DELETE /portal/admin/libraries/:id(登記簿)與 by-name/:name(auto 庫需輸入庫名確認)
- kbdb 加 deprecate-by-library(auto 庫移除=標 deprecated,資料保留可還原)
- daemon/libraries 存 active 清單 → 卡片標 🟢同步中/灰目前沒有在同步
- 不自動刪(daemon 可能沒開機);daemon 從未回報時整列不標
vitest 24 passed(1 紅=console HTML 搬遷陳舊測試,非本案)。
(實作=子 CC;驗證+commit=總管。含 t116/t117 先前未 commit 的 graph-executor/wasi-shim 修正)
227 lines
10 KiB
TypeScript
227 lines
10 KiB
TypeScript
// Template + Record CRUD. A "record" = multiple entries composed via a template's slots.
|
||
// Base, D1 only. (Ported clean from KBDB; no vectorize/triplet imports.)
|
||
import type { Template } from '../types';
|
||
import { createEntry } from './entry-crud';
|
||
|
||
function uid(prefix: string): string {
|
||
return `${prefix}_${crypto.randomUUID()}`;
|
||
}
|
||
|
||
// ---- Templates ----
|
||
|
||
export interface CreateTemplateInput {
|
||
name: string;
|
||
description?: string | null;
|
||
slots: string[];
|
||
created_by?: string | null;
|
||
id?: string;
|
||
}
|
||
|
||
export async function createTemplate(db: D1Database, input: CreateTemplateInput): Promise<Template> {
|
||
const id = input.id ?? uid('tpl');
|
||
await db
|
||
.prepare(`INSERT INTO templates (id, name, description, slots_json, created_by) VALUES (?, ?, ?, ?, ?)`)
|
||
.bind(id, input.name, input.description ?? null, JSON.stringify(input.slots), input.created_by ?? null)
|
||
.run();
|
||
const row = await getTemplate(db, id);
|
||
if (!row) throw new Error('createTemplate: row not found after insert');
|
||
return row;
|
||
}
|
||
|
||
export async function getTemplate(db: D1Database, idOrName: string): Promise<Template | null> {
|
||
const row = await db
|
||
.prepare('SELECT * FROM templates WHERE id = ? OR name = ? LIMIT 1')
|
||
.bind(idOrName, idOrName)
|
||
.first<Template>();
|
||
return row ?? null;
|
||
}
|
||
|
||
export async function listTemplates(db: D1Database): Promise<Template[]> {
|
||
const res = await db.prepare('SELECT * FROM templates ORDER BY created_at DESC').all<Template>();
|
||
return res.results ?? [];
|
||
}
|
||
|
||
export async function updateTemplate(db: D1Database, id: string, patch: { description?: string | null; slots?: string[] }): Promise<Template | null> {
|
||
const cols: string[] = [];
|
||
const params: unknown[] = [];
|
||
if (patch.description !== undefined) { cols.push('description = ?'); params.push(patch.description); }
|
||
if (patch.slots !== undefined) { cols.push('slots_json = ?'); params.push(JSON.stringify(patch.slots)); }
|
||
if (cols.length === 0) return getTemplate(db, id);
|
||
cols.push('updated_at = unixepoch()');
|
||
await db.prepare(`UPDATE templates SET ${cols.join(', ')} WHERE id = ?`).bind(...params, id).run();
|
||
return getTemplate(db, id);
|
||
}
|
||
|
||
// ---- Records (entry_values composed by template) ----
|
||
|
||
export interface CreateRecordInput {
|
||
template: string; // template id or name
|
||
values: Record<string, string>; // slot_name -> content
|
||
owner_id?: string | null;
|
||
record_id?: string;
|
||
}
|
||
|
||
export interface RecordResult {
|
||
record_id: string;
|
||
template_id: string;
|
||
values: Record<string, string>;
|
||
}
|
||
|
||
export async function createRecord(db: D1Database, input: CreateRecordInput): Promise<RecordResult> {
|
||
const tpl = await getTemplate(db, input.template);
|
||
if (!tpl) throw new Error(`template not found: ${input.template}`);
|
||
const slots: string[] = JSON.parse(tpl.slots_json);
|
||
const recordId = input.record_id ?? uid('rec');
|
||
|
||
for (const slot of slots) {
|
||
if (!(slot in input.values)) continue;
|
||
const entry = await createEntry(db, {
|
||
content: input.values[slot],
|
||
entry_type: 'value',
|
||
owner_id: input.owner_id ?? null,
|
||
});
|
||
await db
|
||
.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`)
|
||
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
|
||
.run();
|
||
}
|
||
return { record_id: recordId, template_id: tpl.id, values: input.values };
|
||
}
|
||
|
||
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
|
||
// "Deprecate by flipping a slot value" — base append-only is NOT broken: we change the
|
||
// underlying entries.content of the slot's entry, we do not alter table structure / add columns / delete rows.
|
||
// - slot already on the record → UPDATE the linked entries.content.
|
||
// - slot valid for the record's template but not yet present → create entry + entry_value (idempotent grow).
|
||
// - slot not in the template's slots_json → reject (records must stay template-shaped).
|
||
// Returns null if the record does not exist.
|
||
export async function updateRecord(
|
||
db: D1Database,
|
||
recordId: string,
|
||
values: Record<string, string>,
|
||
): Promise<RecordResult | null> {
|
||
// Existing slot → entry_id + template_id for this record.
|
||
// JOIN entries 帶回 owner_id:grow 路徑建新 entry 時要沿用 record 既有 owner_id
|
||
//(portal-auth design §2.2 附帶修復——原本漏帶 → 孤兒 entry(owner_id=NULL),
|
||
// owner-scoped 查詢(searchByTemplate / searchEntries)看不到該 slot 值)。
|
||
const evRes = await db
|
||
.prepare(
|
||
`SELECT ev.slot_name AS slot_name, ev.entry_id AS entry_id, ev.template_id AS template_id, e.owner_id AS owner_id
|
||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||
WHERE ev.record_id = ?`,
|
||
)
|
||
.bind(recordId)
|
||
.all<{ slot_name: string; entry_id: string; template_id: string; owner_id: string | null }>();
|
||
const evRows = evRes.results ?? [];
|
||
if (evRows.length === 0) return null; // record does not exist
|
||
|
||
const templateId = evRows[0].template_id;
|
||
// record 的歸屬=其既有 slot entries 的 owner_id(createRecord 寫入時同一值)。
|
||
const recordOwnerId = evRows.find((r) => r.owner_id != null)?.owner_id ?? null;
|
||
const slotToEntry = new Map(evRows.map((r) => [r.slot_name, r.entry_id]));
|
||
|
||
const tpl = await getTemplate(db, templateId);
|
||
const allowed: string[] = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntry.keys()];
|
||
|
||
for (const [slot, content] of Object.entries(values)) {
|
||
if (!allowed.includes(slot)) {
|
||
throw new Error(`slot not in template: ${slot}`);
|
||
}
|
||
const entryId = slotToEntry.get(slot);
|
||
if (entryId) {
|
||
// flip the slot value: update the linked entry's content (table structure untouched)
|
||
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
|
||
} else {
|
||
// valid template slot not yet on this record → grow it (create entry + link)
|
||
// owner_id 帶 record 既有歸屬(design §2.2 附帶修復,防孤兒 entry)
|
||
const entry = await createEntry(db, { content, entry_type: 'value', owner_id: recordOwnerId });
|
||
await db
|
||
.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`)
|
||
.bind(uid('ev'), recordId, templateId, slot, entry.id)
|
||
.run();
|
||
}
|
||
}
|
||
return getRecord(db, recordId);
|
||
}
|
||
|
||
export async function getRecord(db: D1Database, recordId: string): Promise<RecordResult | null> {
|
||
const res = await db
|
||
.prepare(
|
||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||
WHERE ev.record_id = ?`,
|
||
)
|
||
.bind(recordId)
|
||
.all<{ slot: string; content: string; template_id: string }>();
|
||
const rows = res.results ?? [];
|
||
if (rows.length === 0) return null;
|
||
const values: Record<string, string> = {};
|
||
for (const r of rows) values[r.slot] = r.content;
|
||
return { record_id: recordId, template_id: rows[0].template_id, values };
|
||
}
|
||
|
||
export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise<RecordResult[]> {
|
||
const tpl = await getTemplate(db, template);
|
||
if (!tpl) return [];
|
||
// owner_id 過濾在 SQL 做:record 的歸屬存在底層 entries.owner_id(createRecord 寫入時帶)。
|
||
// 給了 owner_id → JOIN entries 限定該 owner(租戶隔離,cypher proxy 強制注入);
|
||
// 沒給 → 不限(內部/全域查詢)。先前 `|| true` 是 stub,會洩漏跨租戶資料(2026-06-14 修)。
|
||
const cap = Math.min(limit, 500);
|
||
const res = owner_id
|
||
? await db
|
||
.prepare(
|
||
`SELECT DISTINCT ev.record_id as record_id FROM entry_values ev
|
||
JOIN entries e ON ev.entry_id = e.id
|
||
WHERE ev.template_id = ? AND e.owner_id = ?
|
||
ORDER BY ev.created_at DESC LIMIT ?`,
|
||
)
|
||
.bind(tpl.id, owner_id, cap)
|
||
.all<{ record_id: string }>()
|
||
: await db
|
||
.prepare(`SELECT DISTINCT record_id FROM entry_values WHERE template_id = ? ORDER BY created_at DESC LIMIT ?`)
|
||
.bind(tpl.id, cap)
|
||
.all<{ record_id: string }>();
|
||
// 批次撈齊所有 record 的 slot 值(2026-07-18 修 N+1:原本逐筆 getRecord=每筆 1 次 D1
|
||
// 往返,100 筆 triplet ≈ 19 秒——graph/總圖/rag_chat 的「查詢 20-30 秒」病根就是這裡)。
|
||
// D1 綁定參數上限 100 → id 以 90 一組分批 IN 查詢;輸出保持原排序(created_at DESC)。
|
||
const ids = (res.results ?? []).map((r) => r.record_id);
|
||
if (ids.length === 0) return [];
|
||
const byId = new Map<string, RecordResult>();
|
||
for (let i = 0; i < ids.length; i += 90) {
|
||
const chunk = ids.slice(i, i + 90);
|
||
const placeholders = chunk.map(() => '?').join(',');
|
||
const evRes = await db
|
||
.prepare(
|
||
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||
WHERE ev.record_id IN (${placeholders})`,
|
||
)
|
||
.bind(...chunk)
|
||
.all<{ record_id: string; slot: string; content: string; template_id: string }>();
|
||
for (const r of evRes.results ?? []) {
|
||
let rec = byId.get(r.record_id);
|
||
if (!rec) {
|
||
rec = { record_id: r.record_id, template_id: r.template_id, values: {} };
|
||
byId.set(r.record_id, rec);
|
||
}
|
||
rec.values[r.slot] = r.content;
|
||
}
|
||
}
|
||
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
|
||
}
|
||
|
||
/** 刪除一筆 record:先刪 entry_values(FK),再刪底層 entries。回 false 表示 record 不存在。 */
|
||
export async function deleteRecord(db: D1Database, recordId: string): Promise<boolean> {
|
||
const evRes = await db
|
||
.prepare('SELECT entry_id FROM entry_values WHERE record_id = ?')
|
||
.bind(recordId)
|
||
.all<{ entry_id: string }>();
|
||
const rows = evRes.results ?? [];
|
||
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();
|
||
}
|
||
return true;
|
||
}
|