Files
Arcrun/kbdb/src/actions/record-crud.ts
T
uncle6me-web 934b9265d9 feat: KBDB self-hosted 查詢 + embed 模組 + thin-shell 收窄 + search_workflow(code done 待端到端)
按 issue 分段標明(檔 #5/#8 改動交疊處無法乾淨拆檔,故併一個 commit):

#4 thin-shell §3.1 自力救濟階梯 + code-node 規則(純文檔/規則,code-node 零件未實作)
#5 KBDB source filter(json_extract metadata_json 零建表)+ 能力對照;documents 聚合與
   DELETE proxy 部分擱置等頂層 T8
#7 base embed 模組(kbdb/src/embed.ts)+ vectorize 開關(deploy/config/wrangler.toml 註解範本)
   + 語義查詢降級閉環(mode=semantic 未開→LIKE+capability_hint)
#8 部分(workflow-discovery):
   - KBDB /entries/search 加 base 通用 entry_type filter(entry-crud/embed/route/kbdb-proxy 透傳)
   - /webhooks/named 強制 description(空→400,訊息要求操盤 AI 據實寫一句)
   - 部署雙寫 entry_type=workflow embeddable entry(waitUntil 非阻塞,供 search)
   - cypher GET /workflows/search + MCP u6u_search_workflows(優先語意、降級 hint)
   - cypher POST /workflows/backfill-search-entries(無 desc 列出不編造)
   - GET /webhooks/named 補回 description/created_at 欄位(為 list 來源收斂備)

⚠️ tsc 綠 = code done,非完成(mindset §7 禁假綠):
- #7/#8 端到端待 leo21c 部署驗(Vectorize 需官方憑證、CC 跑不了)
- #8 ①-a(MCP deploy 改打 /webhooks/named)未做、MCP deploy 那半仍 404
- #8 端到端(強制填擋空/語義命中/租戶隔離/降級 hint)未驗

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:52:52 +08:00

181 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
const evRes = await db
.prepare(`SELECT slot_name, entry_id, template_id FROM entry_values WHERE record_id = ?`)
.bind(recordId)
.all<{ slot_name: string; entry_id: string; template_id: string }>();
const evRows = evRes.results ?? [];
if (evRows.length === 0) return null; // record does not exist
const templateId = evRows[0].template_id;
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)
const entry = await createEntry(db, { content, entry_type: 'value' });
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_idcreateRecord 寫入時帶)。
// 給了 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 }>();
const out: RecordResult[] = [];
for (const { record_id } of res.results ?? []) {
const rec = await getRecord(db, record_id);
if (rec) out.push(rec);
}
return out;
}