934b9265d9
按 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>
181 lines
7.4 KiB
TypeScript
181 lines
7.4 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.
|
||
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_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 }>();
|
||
const out: RecordResult[] = [];
|
||
for (const { record_id } of res.results ?? []) {
|
||
const rec = await getRecord(db, record_id);
|
||
if (rec) out.push(rec);
|
||
}
|
||
return out;
|
||
}
|