From d7a98f53e76c9e1b136f24fabfb51ca23aa25855 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 15 Aug 2026 23:54:09 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E7=B7=A8=E5=87=BA=E8=B2=A8=E6=88=90?= =?UTF-8?q?=E5=93=81=E2=80=94=E2=80=94kbdb=20=E6=88=90=E5=93=81=E7=B5=82?= =?UTF-8?q?=E6=96=BC=E8=A3=9D=E7=9A=84=E6=98=AF=20v7=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E6=98=AF=E8=88=8A=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 出貨管線 preflight 擋下:kbdb 的成品比源碼舊 3 顆 commit ceb7638 樹狀 record 模型第一刀(entry_values 拆表,v7 定稿實作) aa49b4e 刪 record 不再刪掉別人還指著的 entry(Arcrun#128 配套) 5b22d56 createRecord 的 slot 可指向既有 entry(Arcrun#128) 🔴 這道閘剛好攔住了今晚整件事的意義被抹掉:成品沒重編就出貨, bundle 裡裝的是舊 kbdb ⇒ youlin 依然「資料新、程式舊」, 而版本號會照跳,看起來像出貨成功。 重編 23/23,實際變動只有 3 個檔(kbdb/cypher-executor/manifest)—— 其餘 20 顆內容指紋沒變、不虛增版本,指紋機制運作正常。 arcrun-kbdb 現在 source=ceb7638d、sha256=47b09d60f8d1。 Co-Authored-By: Claude Opus 5 --- .../arcrun-cypher-executor/worker.mjs | 30 +- .worker-builds/arcrun-kbdb/worker.mjs | 390 +++++++++++++----- .worker-builds/manifest.json | 16 +- 3 files changed, 315 insertions(+), 121 deletions(-) diff --git a/.worker-builds/arcrun-cypher-executor/worker.mjs b/.worker-builds/arcrun-cypher-executor/worker.mjs index f069da7..549b6a2 100644 --- a/.worker-builds/arcrun-cypher-executor/worker.mjs +++ b/.worker-builds/arcrun-cypher-executor/worker.mjs @@ -3243,15 +3243,20 @@ var init_kbdb_proxy = __esm({ const owner = tenant(c); if (!owner) return c.json(NEED_KEY, 401); const body = await c.req.json().catch(() => null); - if (!body || !body.template || !body.values) { - return c.json({ error: "template \u8207 values \u5FC5\u586B" }, 400); + if (!body || !body.template || !body.values && !body.entry_ids) { + return c.json({ error: "template \u5FC5\u586B\uFF0Cvalues \u8207 entry_ids \u81F3\u5C11\u8981\u6709\u4E00\u500B" }, 400); } const { base, headers } = kbdbBase(c.env); const res = await fetch(`${base}/records`, { method: "POST", headers, // 強制以租戶身份隔離:忽略 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" } }); }); @@ -13708,12 +13713,14 @@ portalRouter.post( const body = await c.req.json().catch(() => null); const pageName = String(body?.page_name ?? "").trim(); const srcText = String(body?.text ?? ""); - if (!pageName || !srcText.trim()) return c.json({ error: "page_name \u8207 text \u5FC5\u586B" }, 400); + const daemonPrompt = String(body?.prompt ?? "").trim(); + if (!daemonPrompt && (!pageName || !srcText.trim())) + return c.json({ error: "page_name \u8207 text \u5FC5\u586B" }, 400); if (!c.env.AI) { return c.json({ error: "\u9019\u500B\u90E8\u7F72\u6C92\u6709\u7D81\u5B9A Workers AI\uFF08wrangler.toml \u9700\u6709 [ai] binding\uFF09\uFF0C\u8ACB\u66F4\u65B0\u77E5\u8B58\u5EAB\u7248\u672C" }, 501); } const REL = ">".repeat(2); - const prompt = `\u628A\u4EE5\u4E0B\u539F\u7A3F\u91CD\u5BEB\u6210\u5B9A\u7A3F\u77E5\u8B58\u5361\uFF08\u6B63\u9AD4\u4E2D\u6587\uFF09\u3002\u76F4\u63A5\u8F38\u51FA\u5361\u7247\u672C\u8EAB\uFF1A\u7B2C\u4E00\u884C\u5FC5\u9808\u662F\u300C# ${pageName}\u300D\uFF0C\u4E0D\u8981\u4EFB\u4F55\u524D\u8A00\u3001\u601D\u8003\u904E\u7A0B\u3001\u82F1\u6587\u8349\u7A3F\u6216\u8AAA\u660E\u3002\u683C\u5F0F\uFF1A + const prompt = daemonPrompt || `\u628A\u4EE5\u4E0B\u539F\u7A3F\u91CD\u5BEB\u6210\u5B9A\u7A3F\u77E5\u8B58\u5361\uFF08\u6B63\u9AD4\u4E2D\u6587\uFF09\u3002\u76F4\u63A5\u8F38\u51FA\u5361\u7247\u672C\u8EAB\uFF1A\u7B2C\u4E00\u884C\u5FC5\u9808\u662F\u300C# ${pageName}\u300D\uFF0C\u4E0D\u8981\u4EFB\u4F55\u524D\u8A00\u3001\u601D\u8003\u904E\u7A0B\u3001\u82F1\u6587\u8349\u7A3F\u6216\u8AAA\u660E\u3002\u683C\u5F0F\uFF1A # ${pageName} ## \u4E00\u53E5\u8A71\u5B9A\u7FA9 \uFF08\u4E00\u884C\uFF09 @@ -13729,14 +13736,17 @@ ${srcText}`; try { const out = await c.env.AI.run("@cf/meta/llama-4-scout-17b-16e-instruct", { messages: [{ role: "user", content: prompt }], - max_tokens: 2048, + max_tokens: daemonPrompt ? 8192 : 2048, temperature: 0.2 }); - const card = String(out?.response ?? "").trim(); - if (!card) return c.json({ error: "Workers AI \u6C92\u6709\u56DE\u50B3\u5167\u5BB9" }, 502); + const raw2 = String(out?.response ?? "").trim(); + if (!raw2) return c.json({ error: "Workers AI \u6C92\u6709\u56DE\u50B3\u5167\u5BB9" }, 502); + if (daemonPrompt) { + return c.json({ success: true, output: raw2 }); + } const marker = `# ${pageName}`; - const idx = card.lastIndexOf(marker); - return c.json({ success: true, card: (idx >= 0 ? card.slice(idx) : card).trim() + "\n" }); + const idx = raw2.lastIndexOf(marker); + return c.json({ success: true, card: (idx >= 0 ? raw2.slice(idx) : raw2).trim() + "\n" }); } catch (e) { return c.json({ error: `Workers AI \u57F7\u884C\u5931\u6557\uFF1A${e instanceof Error ? e.message : String(e)}` }, 502); } diff --git a/.worker-builds/arcrun-kbdb/worker.mjs b/.worker-builds/arcrun-kbdb/worker.mjs index 78bfb50..240eccb 100644 --- a/.worker-builds/arcrun-kbdb/worker.mjs +++ b/.worker-builds/arcrun-kbdb/worker.mjs @@ -1,3 +1,51 @@ +var __defProp = Object.defineProperty; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __esm = (fn, res, err) => function __init() { + if (err) throw err[0]; + try { + return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; + } catch (e) { + throw err = [e], e; + } +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; + +// kbdb/src/actions/relation-orphans.ts +var relation_orphans_exports = {}; +__export(relation_orphans_exports, { + scanRelationOrphans: () => scanRelationOrphans +}); +async function scanRelationOrphans(db, limit = 200) { + const cap = Math.min(Math.max(limit, 1), 1e3); + const res = await db.prepare( + `SELECT relation_id, role, missing_id FROM ( + SELECT r.id AS relation_id, 'src' AS role, r.src_id AS missing_id + FROM entries r LEFT JOIN entries t ON t.id = r.src_id + WHERE r.src_id IS NOT NULL AND t.id IS NULL + UNION ALL + SELECT r.id, 'rel', r.rel_id + FROM entries r LEFT JOIN entries t ON t.id = r.rel_id + WHERE r.rel_id IS NOT NULL AND t.id IS NULL + UNION ALL + SELECT r.id, 'dst', r.dst_id + FROM entries r LEFT JOIN entries t ON t.id = r.dst_id + WHERE r.dst_id IS NOT NULL AND t.id IS NULL + ) LIMIT ?` + ).bind(cap + 1).all(); + const rows = res.results ?? []; + const truncated = rows.length > cap; + const orphans = truncated ? rows.slice(0, cap) : rows; + return { orphans, count: orphans.length, truncated }; +} +var init_relation_orphans = __esm({ + "kbdb/src/actions/relation-orphans.ts"() { + "use strict"; + } +}); + // kbdb/node_modules/.pnpm/hono@4.12.23/node_modules/hono/dist/compose.js var compose = (middleware, onError, onNotFound) => { return (context, next) => { @@ -2101,12 +2149,15 @@ async function getEntry(db, id) { const row = await db.prepare("SELECT * FROM entries WHERE id = ?").bind(id).first(); return row ?? null; } +var NOT_MACHINERY_PREDICATE = "(src_id IS NULL AND entry_type NOT IN ('record', 'sheet', 'field', 'system'))"; async function listEntries(db, f = {}) { const conds = []; const params = []; if (f.entry_type) { conds.push("entry_type = ?"); params.push(f.entry_type); + } else { + conds.push(NOT_MACHINERY_PREDICATE); } if (f.owner_id) { conds.push("owner_id = ?"); @@ -2158,7 +2209,10 @@ async function updateEntry(db, id, patch) { return getEntry(db, id); } async function deleteEntry(db, id) { + const ref = await db.prepare("SELECT id FROM entries WHERE dst_id = ? LIMIT 1").bind(id).first(); + if (ref) throw new Error(`entry ${id} is still referenced by record relation ${ref.id} \u2014 delete the record (or its slot) first`); await db.prepare("DELETE FROM entries WHERE id = ?").bind(id).run(); + await db.prepare("DELETE FROM entries WHERE src_id = ?").bind(id).run(); } async function embeddedIdsByLibrary(db, ownerId, library) { const rows = await db.prepare( @@ -2392,6 +2446,8 @@ async function searchEntries(db, q, owner_id, entry_type, limit = 50, library, s if (entry_type) { conds.push("entry_type = ?"); params.push(entry_type); + } else { + conds.push(NOT_MACHINERY_PREDICATE); } if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); @@ -3236,9 +3292,38 @@ entryRoutes.delete("/:id", async (c) => { function uid2(prefix) { return `${prefix}_${crypto.randomUUID()}`; } +var SYS_ROOT = "sys_root"; +var SYS_BELONGS = "sys_belongs"; +var SYS_FIELD_OF = "sys_field_of"; +function fieldEntryId(templateId, slot) { + return `fld_${templateId}_${slot}`; +} +async function ensureAnchors(db) { + await db.prepare( + `INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES + ('${SYS_ROOT}', 'root', 'system', NULL), + ('${SYS_BELONGS}', 'belongs', 'system', NULL), + ('${SYS_FIELD_OF}', 'field_of', 'system', NULL)` + ).run(); +} +async function ensureFieldEntries(db, templateId, slots) { + for (const slot of slots) { + const fid = fieldEntryId(templateId, slot); + await db.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'field')`).bind(fid, slot).run(); + await db.prepare( + `INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_FIELD_OF}', ?)` + ).bind(`relf_${templateId}_${slot}`, fid, templateId).run(); + } +} async function createTemplate(db, input) { const id = input.id ?? uid2("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(); + await ensureAnchors(db); + await db.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'sheet')`).bind(id, input.name).run(); + await db.prepare( + `INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_BELONGS}', '${SYS_ROOT}')` + ).bind(`relb_${id}`, id).run(); + await ensureFieldEntries(db, id, input.slots); const row = await getTemplate(db, id); if (!row) throw new Error("createTemplate: row not found after insert"); return row; @@ -3265,104 +3350,187 @@ async function updateTemplate(db, id, patch) { 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(); + if (patch.slots !== void 0) await ensureFieldEntries(db, id, patch.slots); return getTemplate(db, id); } +async function loadReferencedEntries(db, entryIds, recordOwnerId) { + const ids = [...new Set(Object.values(entryIds))]; + if (ids.length === 0) return /* @__PURE__ */ new Map(); + const rows = []; + 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(); + 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(", ")}`); + 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])); +} +async function recordBelongs(db, recordId) { + const row = await db.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id = '${SYS_BELONGS}' AND dst_id != '${SYS_ROOT}' LIMIT 1`).bind(recordId).first(); + return row ?? null; +} +async function insertCellRelation(db, recordId, templateId, slot, dstEntryId, ownerId) { + await db.prepare( + `INSERT INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, ?, ?)` + ).bind(uid2("relv"), ownerId, recordId, fieldEntryId(templateId, slot), dstEntryId).run(); +} async function createRecord(db, input) { const tpl = await getTemplate(db, input.template); if (!tpl) throw new Error(`template not found: ${input.template}`); const slots = JSON.parse(tpl.slots_json); const recordId = input.record_id ?? uid2("rec"); - for (const slot of slots) { - if (!(slot in input.values)) continue; + const values = input.values ?? {}; + const entryIds = input.entry_ids ?? {}; + const refSlots = Object.keys(entryIds); + const ownerId = input.owner_id ?? null; + const both = refSlots.filter((s) => s in values); + if (both.length > 0) throw new Error(`slot given both value and entry_id: ${both.join(", ")}`); + 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, ownerId); + await db.prepare(`INSERT OR IGNORE INTO entries (id, entry_type, owner_id) VALUES (?, 'record', ?)`).bind(recordId, ownerId).run(); + await db.prepare( + `INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, '${SYS_BELONGS}', ?)` + ).bind(`relb_${recordId}_${tpl.id}`, ownerId, recordId, tpl.id).run(); + const writtenSlots = slots.filter((s) => s in entryIds || s in values); + await ensureFieldEntries(db, tpl.id, writtenSlots); + for (const slot of writtenSlots) { + if (slot in entryIds) { + await insertCellRelation(db, recordId, tpl.id, slot, entryIds[slot], ownerId); + continue; + } const entry = await createEntry(db, { - content: input.values[slot], + content: values[slot], entry_type: "value", - owner_id: input.owner_id ?? null + owner_id: ownerId }); - await db.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`).bind(uid2("ev"), recordId, tpl.id, slot, entry.id).run(); + await insertCellRelation(db, recordId, tpl.id, slot, entry.id, ownerId); } - return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null }; + const out = { ...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: ownerId }; } async function updateRecord(db, recordId, values) { - 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 = ?` + const belongs = await recordBelongs(db, recordId); + if (!belongs) return null; + const templateId = belongs.dst_id; + const cellRes = await db.prepare( + `SELECT f.content AS slot_name, r.dst_id AS entry_id + FROM entries r JOIN entries f ON r.rel_id = f.id + WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'` ).bind(recordId).all(); - const evRows = evRes.results ?? []; - if (evRows.length === 0) return null; - const templateId = evRows[0].template_id; - 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 cells = cellRes.results ?? []; + const slotToEntries = /* @__PURE__ */ new Map(); + for (const c of cells) { + const list = slotToEntries.get(c.slot_name) ?? []; + list.push(c.entry_id); + slotToEntries.set(c.slot_name, list); + } + const identity = await db.prepare("SELECT owner_id FROM entries WHERE id = ?").bind(recordId).first(); + const recordOwnerId = identity?.owner_id ?? null; const tpl = await getTemplate(db, templateId); - const allowed = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntry.keys()]; + const allowed = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntries.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) { - await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run(); + const entryIds = slotToEntries.get(slot); + if (entryIds && entryIds.length > 0) { + for (const entryId of entryIds) { + await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run(); + } } else { + await ensureFieldEntries(db, templateId, [slot]); 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(uid2("ev"), recordId, templateId, slot, entry.id).run(); + await insertCellRelation(db, recordId, templateId, slot, entry.id, recordOwnerId); } } return getRecord(db, recordId); } async function getRecord(db, recordId) { + const belongs = await recordBelongs(db, recordId); + if (!belongs) return null; const res = await db.prepare( - `SELECT ev.slot_name as slot, e.content as content, 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 = ?` + `SELECT f.content AS slot, v.content AS content + FROM entries r + JOIN entries f ON r.rel_id = f.id + JOIN entries v ON r.dst_id = v.id + WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'` ).bind(recordId).all(); - const rows = res.results ?? []; - if (rows.length === 0) return null; const values = {}; - for (const r of rows) values[r.slot] = r.content; - const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null; - return { record_id: recordId, template_id: rows[0].template_id, values, owner_id }; + for (const r of res.results ?? []) values[r.slot] = r.content; + const identity = await db.prepare("SELECT owner_id FROM entries WHERE id = ?").bind(recordId).first(); + return { record_id: recordId, template_id: belongs.dst_id, values, owner_id: identity?.owner_id ?? null }; } async function searchByTemplate(db, template, owner_id, limit = 100) { const tpl = await getTemplate(db, template); if (!tpl) return []; 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() : await db.prepare(`SELECT DISTINCT record_id FROM entry_values WHERE template_id = ? ORDER BY created_at DESC LIMIT ?`).bind(tpl.id, cap).all(); + `SELECT src_id AS record_id FROM entries + WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ? AND owner_id = ? + ORDER BY created_at DESC, rowid DESC LIMIT ?` + ).bind(tpl.id, owner_id, cap).all() : await db.prepare( + `SELECT src_id AS record_id FROM entries + WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ? + ORDER BY created_at DESC, rowid DESC LIMIT ?` + ).bind(tpl.id, cap).all(); const ids = (res.results ?? []).map((r) => r.record_id); if (ids.length === 0) return []; const byId = /* @__PURE__ */ new Map(); + for (const id of ids) byId.set(id, { record_id: id, template_id: tpl.id, values: {}, owner_id: null }); 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, e.owner_id as owner_id - FROM entry_values ev JOIN entries e ON ev.entry_id = e.id - WHERE ev.record_id IN (${placeholders})` - ).bind(...chunk).all(); - 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: {}, owner_id: null }; - byId.set(r.record_id, rec); - } - rec.values[r.slot] = r.content; - if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id; + const [cellRes, identRes] = await Promise.all([ + db.prepare( + `SELECT r.src_id AS record_id, f.content AS slot, v.content AS content + FROM entries r + JOIN entries f ON r.rel_id = f.id + JOIN entries v ON r.dst_id = v.id + WHERE r.src_id IN (${placeholders}) AND r.rel_id != '${SYS_BELONGS}'` + ).bind(...chunk).all(), + db.prepare(`SELECT id, owner_id FROM entries WHERE id IN (${placeholders})`).bind(...chunk).all() + ]); + for (const r of cellRes.results ?? []) { + const rec = byId.get(r.record_id); + if (rec) rec.values[r.slot] = r.content; + } + for (const r of identRes.results ?? []) { + const rec = byId.get(r.id); + if (rec) rec.owner_id = r.owner_id; } } return ids.map((id) => byId.get(id)).filter((r) => !!r); } async function deleteRecord(db, recordId) { - const evRes = await db.prepare("SELECT entry_id FROM entry_values WHERE record_id = ?").bind(recordId).all(); - 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(); + const belongs = await recordBelongs(db, recordId); + if (!belongs) return false; + const cellRes = await db.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id != '${SYS_BELONGS}'`).bind(recordId).all(); + const dsts = (cellRes.results ?? []).map((r) => r.dst_id); + await db.prepare(`DELETE FROM entries WHERE src_id = ?`).bind(recordId).run(); + await db.prepare( + `DELETE FROM entries WHERE id = ?1 AND entry_type = 'record' + AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)` + ).bind(recordId).run(); + for (const dst of dsts) { + await db.prepare( + `DELETE FROM entries WHERE id = ?1 + AND entry_type NOT IN ('sheet', 'field', 'system') + AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1) + AND NOT EXISTS (SELECT 1 FROM entries WHERE src_id = ?1) + AND NOT EXISTS (SELECT 1 FROM entries WHERE rel_id = ?1)` + ).bind(dst).run(); } return true; } @@ -3395,10 +3563,17 @@ templateRoutes.patch("/:id", async (c) => { // kbdb/src/routes/records.ts var recordRoutes = new Hono2(); +var isStringMap = (v) => !!v && typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string"); recordRoutes.post("/", async (c) => { const body = await c.req.json().catch(() => null); - if (!body || !body.template || !body.values) { - return c.json({ success: false, error: "template and values required" }, 400); + if (!body || !body.template || !body.values && !body.entry_ids) { + return c.json({ success: false, error: "template and values (or entry_ids) required" }, 400); + } + if (body.values !== void 0 && !isStringMap(body.values)) { + return c.json({ success: false, error: "values must be an object of {slot: string}" }, 400); + } + if (body.entry_ids !== void 0 && !isStringMap(body.entry_ids)) { + return c.json({ success: false, error: "entry_ids must be an object of {slot: entry_id}" }, 400); } try { const rec = await createRecord(c.env.DB, body); @@ -3411,20 +3586,14 @@ recordRoutes.get("/triplet-stats", async (c) => { const owner = c.req.query("owner_id") || ""; const rows = await c.env.DB.prepare( `SELECT - COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, + COALESCE(NULLIF(lib_v.content, ''), 'general') AS library, COUNT(*) AS triplet_count - FROM ( - SELECT DISTINCT ev.record_id - FROM entry_values ev - JOIN templates t ON ev.template_id = t.id - JOIN entries e ON ev.entry_id = e.id - WHERE t.name = 'triplet' - AND (?1 = '' OR e.owner_id = ?1) - ) AS tr - LEFT JOIN entry_values lev - ON lev.record_id = tr.record_id AND lev.slot_name = 'library' - LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id - GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general') + FROM entries b + JOIN templates t ON b.dst_id = t.id AND t.name = 'triplet' + LEFT JOIN entries lr ON lr.src_id = b.src_id AND lr.rel_id = ('fld_' || b.dst_id || '_library') + LEFT JOIN entries lib_v ON lib_v.id = lr.dst_id + WHERE b.rel_id = 'sys_belongs' AND (?1 = '' OR b.owner_id = ?1) + GROUP BY COALESCE(NULLIF(lib_v.content, ''), 'general') ORDER BY library` ).bind(owner).all(); const stats = (rows.results ?? []).map((r) => ({ library: r.library, triplet_count: r.triplet_count })); @@ -3604,31 +3773,35 @@ async function ensureTripletLibrarySlot(db, tripletTemplate) { return true; } function tripletPivotSql(ownerFiltered) { - return `SELECT ev.record_id AS rid, - MAX(CASE WHEN ev.slot_name = 'subject' THEN e.content END) AS subject, - MAX(CASE WHEN ev.slot_name = 'object' THEN e.content END) AS object, - MAX(CASE WHEN ev.slot_name = 'predicate' THEN e.content END) AS predicate, - MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status, - MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library, - MAX(CASE WHEN ev.slot_name = 'source_uri' THEN e.content END) AS source_uri - FROM entry_values ev JOIN entries e ON ev.entry_id = e.id - WHERE ev.template_id = ?${ownerFiltered ? " AND e.owner_id = ?" : ""} - GROUP BY ev.record_id`; + return `SELECT b.src_id AS rid, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_subject' THEN v.content END) AS subject, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_object' THEN v.content END) AS object, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_predicate' THEN v.content END) AS predicate, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_source_uri' THEN v.content END) AS source_uri + FROM entries b + LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs' + LEFT JOIN entries v ON v.id = r.dst_id + WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? " AND b.owner_id = ?" : ""} + GROUP BY b.src_id`; } function mapPivotSql(ownerFiltered) { - return `SELECT ev.record_id AS rid, - MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library, - MAX(CASE WHEN ev.slot_name = 'narrative' THEN e.content END) AS narrative, - MAX(CASE WHEN ev.slot_name = 'top_entities' THEN e.content END) AS top_entities, - MAX(CASE WHEN ev.slot_name = 'relation_profile' THEN e.content END) AS relation_profile, - MAX(CASE WHEN ev.slot_name = 'bridges' THEN e.content END) AS bridges, - MAX(CASE WHEN ev.slot_name = 'triplet_count' THEN e.content END) AS triplet_count, - MAX(CASE WHEN ev.slot_name = 'commit_hash' THEN e.content END) AS commit_hash, - MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status, - MAX(ev.created_at) AS ts - FROM entry_values ev JOIN entries e ON ev.entry_id = e.id - WHERE ev.template_id = ?${ownerFiltered ? " AND e.owner_id = ?" : ""} - GROUP BY ev.record_id`; + return `SELECT b.src_id AS rid, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_narrative' THEN v.content END) AS narrative, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_top_entities' THEN v.content END) AS top_entities, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_relation_profile' THEN v.content END) AS relation_profile, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_bridges' THEN v.content END) AS bridges, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_triplet_count' THEN v.content END) AS triplet_count, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_commit_hash' THEN v.content END) AS commit_hash, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status, + MAX(r.created_at) AS ts + FROM entries b + LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs' + LEFT JOIN entries v ON v.id = r.dst_id + WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? " AND b.owner_id = ?" : ""} + GROUP BY b.src_id`; } function parseJsonArray(raw2) { if (!raw2) return []; @@ -3761,18 +3934,19 @@ async function liveTripletCountsByLibrary(db, tripletTemplateId, owner_id) { const params = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId]; const res = await db.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆 - `SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n + `SELECT COALESCE(NULLIF(tr.library, ''), 'general') AS library, COUNT(*) AS n FROM ( - SELECT ev.record_id AS rid, - MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status - FROM entry_values ev JOIN entries e ON ev.entry_id = e.id - WHERE ev.template_id = ?${owner_id ? " AND e.owner_id = ?" : ""} - GROUP BY ev.record_id + SELECT b.src_id AS rid, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status, + MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library + FROM entries b + LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs' + LEFT JOIN entries v ON v.id = r.dst_id + WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? " AND b.owner_id = ?" : ""} + GROUP BY b.src_id ) AS tr - LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library' - LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id WHERE COALESCE(tr.status, 'active') = 'active' - GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')` + GROUP BY COALESCE(NULLIF(tr.library, ''), 'general')` ).bind(...params).all(); const m = /* @__PURE__ */ new Map(); for (const r of res.results ?? []) m.set(r.library, r.n); @@ -3786,6 +3960,8 @@ async function liveEntryCountsByLibrary(db, owner_id) { COUNT(*) AS n FROM entries WHERE ${owner_id ? "owner_id = ? AND " : ""}entry_type != 'value' + AND src_id IS NULL + AND entry_type NOT IN ('record', 'sheet', 'field', 'system') AND NOT (entry_type = 'block' AND COALESCE(json_extract(metadata_json, '$.kind'), '') = 'library_map') GROUP BY COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general')` ).bind(...params).all(); @@ -3805,10 +3981,12 @@ async function knownLibraryNames(db, owner_id) { if (libTpl) { const libParams = owner_id ? [libTpl.id, owner_id] : [libTpl.id]; const libRows = await db.prepare( - `SELECT MAX(CASE WHEN ev.slot_name = 'name' THEN e.content END) AS name - FROM entry_values ev JOIN entries e ON ev.entry_id = e.id - WHERE ev.template_id = ?${owner_id ? " AND e.owner_id = ?" : ""} - GROUP BY ev.record_id` + `SELECT MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_name' THEN v.content END) AS name + FROM entries b + LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs' + LEFT JOIN entries v ON v.id = r.dst_id + WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? " AND b.owner_id = ?" : ""} + GROUP BY b.src_id` ).bind(...libParams).all(); for (const r of libRows.results ?? []) if (r.name) names.add(r.name); } @@ -4176,6 +4354,12 @@ app.use("*", async (c, next) => { }); app.get("/", (c) => c.json({ service: "arcrun-kbdb", tier: "base", status: "ok" })); app.get("/health", (c) => c.json({ ok: true })); +app.get("/maintenance/relation-orphans", async (c) => { + const { scanRelationOrphans: scanRelationOrphans2 } = await Promise.resolve().then(() => (init_relation_orphans(), relation_orphans_exports)); + const limit = Number(c.req.query("limit") ?? "200"); + const report = await scanRelationOrphans2(c.env.DB, Number.isFinite(limit) ? limit : 200); + return c.json({ success: true, ...report }); +}); app.route("/entries", entryRoutes); app.route("/templates", templateRoutes); app.route("/records", recordRoutes); diff --git a/.worker-builds/manifest.json b/.worker-builds/manifest.json index b9a74f7..4f78594 100644 --- a/.worker-builds/manifest.json +++ b/.worker-builds/manifest.json @@ -1,18 +1,18 @@ { "schema": 1, "built_for": "arcrun-tier2-worker-artifacts", - "generated_at": "2026-08-14T17:00:10.242Z", - "repo_head": "45169ab25e974085aaaebaceeb4a67f785239d1b", + "generated_at": "2026-08-15T15:53:28.612Z", + "repo_head": "cacaa33f7d4e51d0011096250a33bb5107c67074", "repo_dirty": false, "workers": [ { "name": "arcrun-cypher-executor", "source_dir": "cypher-executor", - "source_commit": "340b461f58ed6a9958850f8871cf05381ce95c9d", + "source_commit": "9fa88065abdc5a795d3847711f04101480621af0", "main_module": "worker.mjs", "main_file": "arcrun-cypher-executor/worker.mjs", - "js_bytes": 590085, - "content_sha256": "1da6a46e6a1781cb1a518635ff7e6b43a035710673066f4f764671c75441fdaf", + "js_bytes": 590499, + "content_sha256": "94a21ad012ee0c48d78e9a74752048917ddb4616f1ea089976834acd887a9d14", "modules": [], "compat_date": "2025-02-19", "compat_flags": [ @@ -58,11 +58,11 @@ { "name": "arcrun-kbdb", "source_dir": "kbdb", - "source_commit": "614fe448124dbd25d8228e37723473f1704f3a7a", + "source_commit": "ceb7638d746f0e8847552aef721f6f53ef87ff0b", "main_module": "worker.mjs", "main_file": "arcrun-kbdb/worker.mjs", - "js_bytes": 151412, - "content_sha256": "f15e16cca6cefb0d8631759c27fec58cd36262abf6aed7853f3b35c6e8e1e09b", + "js_bytes": 160306, + "content_sha256": "47b09d60f8d122f7ec234d146a99bc68b2661c56ad6a98eca146d99ec5de03ea", "modules": [], "compat_date": "2025-02-19", "compat_flags": [