diff --git a/kbdb/src/actions/entry-crud.ts b/kbdb/src/actions/entry-crud.ts index 192826e..45d26e2 100644 --- a/kbdb/src/actions/entry-crud.ts +++ b/kbdb/src/actions/entry-crud.ts @@ -21,6 +21,20 @@ export interface CreateEntryInput { id?: string; } +// owner_id 寫入必填(D27/D28:機制強制,設計成 AI 錯不了)。 +// 缺 owner_id → 資料無主,會被 mira 等 caller 的 owner 過濾濾掉(實測 gloss owner=None 查不到)。 +// 這是通用驗證器,POST /entries(單筆)與 POST /entries/bulk(逐筆)共用同一把尺。 +// 註(機制邊界):強制點在「HTTP 寫入路由」,不在 createEntry() 本身——因為 createEntry 被 +// record-crud 的 slot value entry(owner 由 record 帶)與 recipe-stat(全域統計、天生無主)內部呼叫。 +// 所有「外部」寫入(graph 插件 / mira / sync 腳本)都經 POST /entries|/entries/bulk,故路由層擋 = 擋死外部漏欄。 +export function validateEntryInput(input: unknown): string | null { + if (!input || typeof input !== 'object') return 'entry must be an object'; + const e = input as Partial; + if (!e.entry_type) return 'entry_type required'; + if (!e.owner_id) return 'owner_id required: 每筆寫入必須指定租戶,避免跨租戶資料無主'; + return null; +} + export async function createEntry(db: D1Database, input: CreateEntryInput): Promise { const id = input.id ?? uid('e'); await db @@ -123,6 +137,147 @@ export async function deleteEntry(db: D1Database, id: string): Promise { await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run(); } +// ── Bulk write(缺口1 keystone:一次收整個 envelope,少 subrequest 寫法)──────────────── +// +// 為什麼:base 舊寫入是「每筆一個 await db.prepare().run()」=每筆一個 subrequest,串行。 +// 大筆記(journals 11 三元組+10 node)撞 CF「Too many subrequests by single Worker invocation」→ 500。 +// 解:用 D1 `db.batch([...stmts])`(一次 call 跑多語句)→ N 筆 ≈ 常數個 subrequest。 +// 本函式對任意 N 筆 entries 固定約 3 個 subrequest:① 一次 page_name 冪等查詢 ② 一次 batch 寫入 +// ③ 一次 hydrate(IN(...) 撈回寫入列)。不隨 N 線性增長 → free/paid tier 都安全。 +// 冪等:沿用既有 page_name idempotency(同 sync-registry-to-kbdb.py 的 GET→PATCH/POST 模式)—— +// payload 帶 page_name 且庫內已有 → UPDATE(不重複建);否則 INSERT。單次查詢批量解析。 +// 部分失敗不整批炸:逐筆驗證(缺 owner_id/entry_type)先剔除記為 failed,其餘照寫; +// batch 若整體拋錯(如 DB 約束)→ 退化成逐句執行隔離出真正壞的那筆(只在錯誤路徑付 N 個 subrequest)。 + +export interface BulkEntryItemResult { + index: number; + status: 'created' | 'updated' | 'failed'; + id?: string; + page_name?: string | null; + error?: string; +} + +export interface BulkEntriesResult { + created: number; + updated: number; + failed: number; + results: BulkEntryItemResult[]; // 逐筆結果(與輸入同順序) + entries: Entry[]; // 成功寫入(created+updated)的完整列,供 caller / embed 用 +} + +interface EntryOp { + index: number; + id: string; + kind: 'created' | 'updated'; + stmt: D1PreparedStatement; +} + +export async function bulkCreateEntries(db: D1Database, inputs: CreateEntryInput[]): Promise { + const results: BulkEntryItemResult[] = new Array(inputs.length); + + // 1. 逐筆驗證(owner_id/entry_type 必填)——壞的先剔除,不阻塞好的。 + const valid: { index: number; input: CreateEntryInput; id: string }[] = []; + for (let i = 0; i < inputs.length; i++) { + const err = validateEntryInput(inputs[i]); + if (err) { + results[i] = { index: i, status: 'failed', error: err, page_name: (inputs[i] as CreateEntryInput | undefined)?.page_name ?? null }; + continue; + } + valid.push({ index: i, input: inputs[i], id: inputs[i].id ?? uid('e') }); + } + + // 2. 冪等:單次查詢庫內已存在的 page_name → id(取代逐筆 GET,省 subrequest)。 + const pageNames = [...new Set(valid.map((v) => v.input.page_name).filter((p): p is string => !!p))]; + const existingByPage = new Map(); + if (pageNames.length > 0) { + const ph = pageNames.map(() => '?').join(','); + const res = await db + .prepare(`SELECT id, page_name FROM entries WHERE page_name IN (${ph})`) + .bind(...pageNames) + .all<{ id: string; page_name: string }>(); + for (const r of res.results ?? []) existingByPage.set(r.page_name, r.id); + } + + // 3. 組 INSERT / UPDATE 語句。 + const ops: EntryOp[] = []; + for (const v of valid) { + const pn = v.input.page_name ?? null; + const existingId = pn ? existingByPage.get(pn) : undefined; + if (existingId) { + // 同 page_name 重入 → 就地更新可變欄位(entry_type/owner_id/page_name 不動)。不重複建列。 + const stmt = db + .prepare( + `UPDATE entries SET content = ?, parent_id = ?, refs_json = ?, tags_json = ?, task_status = ?, confidence = ?, metadata_json = ?, updated_at = unixepoch() WHERE id = ?`, + ) + .bind( + v.input.content ?? null, + v.input.parent_id ?? null, + v.input.refs_json ?? '[]', + v.input.tags_json ?? '[]', + v.input.task_status ?? null, + v.input.confidence ?? null, + v.input.metadata_json ?? null, + existingId, + ); + ops.push({ index: v.index, id: existingId, kind: 'updated', stmt }); + } else { + const stmt = db + .prepare( + `INSERT INTO entries (id, content, entry_type, owner_id, parent_id, page_name, refs_json, tags_json, task_status, confidence, metadata_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + v.id, + v.input.content ?? null, + v.input.entry_type, + v.input.owner_id ?? null, + v.input.parent_id ?? null, + pn, + v.input.refs_json ?? '[]', + v.input.tags_json ?? '[]', + v.input.task_status ?? null, + v.input.confidence ?? null, + v.input.metadata_json ?? null, + ); + ops.push({ index: v.index, id: v.id, kind: 'created', stmt }); + } + } + + // 4. 一次 batch 寫入;整體失敗才退化逐句隔離(避免一筆壞牽連整批)。 + if (ops.length > 0) { + try { + await db.batch(ops.map((o) => o.stmt)); + for (const o of ops) results[o.index] = { index: o.index, status: o.kind, id: o.id }; + } catch { + for (const o of ops) { + try { + await o.stmt.run(); + results[o.index] = { index: o.index, status: o.kind, id: o.id }; + } catch (e) { + results[o.index] = { index: o.index, status: 'failed', id: o.id, error: e instanceof Error ? e.message : String(e) }; + } + } + } + } + + // 5. hydrate 成功寫入的列(單次 IN(...) 查詢,不逐筆 getEntry)。 + const okIds = [...new Set(ops.filter((o) => results[o.index]?.status !== 'failed').map((o) => o.id))]; + let entries: Entry[] = []; + if (okIds.length > 0) { + const ph = okIds.map(() => '?').join(','); + const res = await db.prepare(`SELECT * FROM entries WHERE id IN (${ph})`).bind(...okIds).all(); + entries = res.results ?? []; + } + + let created = 0, updated = 0, failed = 0; + for (const r of results) { + if (r.status === 'created') created++; + else if (r.status === 'updated') updated++; + else failed++; + } + return { created, updated, failed, results, entries }; +} + // D1 LIKE keyword search (base; semantic search is the optional embed module). // entry_type: optional base filter (generic — caller passes any type, base stays type-agnostic). export async function searchEntries( diff --git a/kbdb/src/actions/record-crud.ts b/kbdb/src/actions/record-crud.ts index bbddcd2..bcdfc48 100644 --- a/kbdb/src/actions/record-crud.ts +++ b/kbdb/src/actions/record-crud.ts @@ -134,6 +134,144 @@ export async function updateRecord( return getRecord(db, recordId); } +// ── Bulk records(缺口1:三元組批量寫入,avoid subrequest 撞牆)───────────────────────── +// +// 為什麼:一筆 record = 1 次 template 查詢 + N 個 slot(各一 INSERT entries + INSERT entry_values)。 +// 舊 createRecord 逐筆逐句串行 → 大批三元組(journals 11 筆)疊加 node/gloss 就撞 subrequest 500。 +// 解:整個 envelope 的所有 record 的所有 INSERT 攤平進「單一 db.batch()」→ 常數個 subrequest。 +// template 也一次查回(WHERE id IN (...) OR name IN (...)),不逐筆 getTemplate。 +// 租戶必填(D27/D28):每筆 record 缺 owner_id → 記 failed(value entries 的 owner 由 record 帶下去)。 +// 部分失敗不整批炸:驗證失敗 / template 不存在的先剔除;batch 整體拋錯 → 退化「逐 record 子 batch」隔離。 + +export interface BulkRecordItemResult { + index: number; + status: 'created' | 'failed'; + record_id?: string; + template_id?: string; + error?: string; +} + +export interface BulkRecordsResult { + created: number; + failed: number; + results: BulkRecordItemResult[]; + records: RecordResult[]; +} + +export async function bulkCreateRecords(db: D1Database, inputs: CreateRecordInput[]): Promise { + const results: BulkRecordItemResult[] = new Array(inputs.length); + + // 1. 驗證(template + values + owner_id 必填)+ 收集要查的 template key。 + const need: { index: number; input: CreateRecordInput }[] = []; + const templKeys = new Set(); + for (let i = 0; i < inputs.length; i++) { + const inp = inputs[i]; + if (!inp || !inp.template || !inp.values || typeof inp.values !== 'object') { + results[i] = { index: i, status: 'failed', error: 'template and values required' }; + continue; + } + if (!inp.owner_id) { + results[i] = { index: i, status: 'failed', error: 'owner_id required: 每筆寫入必須指定租戶,避免跨租戶資料無主' }; + continue; + } + need.push({ index: i, input: inp }); + templKeys.add(inp.template); + } + + // 2. 一次查回所有用到的 template(id 或 name 皆可)。 + const templMap = new Map(); + if (templKeys.size > 0) { + const keys = [...templKeys]; + const ph = keys.map(() => '?').join(','); + const res = await db + .prepare(`SELECT * FROM templates WHERE id IN (${ph}) OR name IN (${ph})`) + .bind(...keys, ...keys) + .all