Files
Arcrun/kbdb/src/actions/entry-crud.ts
T
uncle6me-web 60688c3108 fix(kv-quota): workflow 執行紀錄搬離 KV,改走 KBDB template 機制(A1/A2/A7)
事故:cypher-executor/src/actions/execution-logger.ts 舊版每跑完一次 workflow 就
ANALYTICS_KV.put() 一筆新 key(註解寫「避免覆蓋」)= 只增不減,封測者 Evan 處理約 690 個
檔案就把 KV 免費層 1,000 write/日打爆(實測 1,070 write),整個實例 429。

A1 少記:workflow 執行紀錄改走 KBDB template 機制(entries 表 entry_type='execution_log',
kbdb/migrations/0004_execution_log_template.sql 只 seed 一列 template 定義,零建表/改表)。
儲存精神比照既有 recipe_stat(kbdb/src/actions/recipe-stat.ts):template 只負責文件化,
實際一筆執行是 entries 表一列(1 次執行=1 次 D1 寫入,不走 entry_values 全展開)。欄位收斂:
時間/workflow/verdict/duration/錯誤訊息/(可得的)目標;成功記最少,失敗多記(訊息截斷長度
不對稱:200 vs 2000 字)。target 只認 trigger context 的 page_name/path,不整包存 input。

A2 自我降級:D1 額度仍與知識卡共用同一顆 100,000 rows/日,本模組自設 20% 軟上限(可用
EXECUTION_LOG_DAILY_WRITE_LIMIT 覆寫),超過 80% 降成只記失敗、超過 100% 完全停止記錄,
但 workflow 執行永遠照跑(cypher-executor 端 fire-and-forget 永不 throw)。

A7 讀取端:/workflows/:name/executions、/portal/data/workflows 的 last_execution、MCP
list_recent_executions 全部改打 KBDB HTTP API(GET /execution-log、/execution-log/latest),
取代原本的 ANALYTICS_KV list/get(免費層 list 也是 1,000/日)。

架構鐵律修正(本次施工中兩度被抓到走偏,過程留痕於 commit 訊息供後續參考):
- KBDB 三張表打天下(entries/templates/entry_values),永遠不加新 table——新資料類型
  一律用 template + entries,不建表、不 ALTER TABLE。
- KBDB = API-as-Wall,零 SQL:cypher-executor 端一律走 KBDB 的 HTTP API(連法比照既有
  recordRecipeStats/kbdbFetch 慣例),不直連任何 D1、不對 arcrun-kbdb 下任何原生 SQL。

順帶修復:kbdb/src/actions/entry-crud.ts listEntries 的 ORDER BY 補 `, rowid DESC` 二級
排序——entries.created_at 是 unixepoch() 秒級解析度,高頻寫入(execution_log 一秒內多筆)
常同秒,單靠 created_at DESC 不保證「最新一筆」正確,此為本次測試(latestExecutionLog)
發現的既有潛在缺陷,順手補上決定性排序,不改變任何既有查詢在 created_at 不同時的行為。

隔離:portal-data.ts INTERNAL_ENTRY_TYPES 加入 execution_log/execution_log_usage(與既有
value/workflow 同層級排除),避免用戶知識搜尋混進執行 log;本模組從不設 metadata_json.embed,
故永不進 Vectorize 語意搜尋索引。

不動:registry/src/actions/recordAnalytics.ts(零件市場統計,獨立 Worker、獨立 KV 命名空間、
不同資料模型,非本次事故根因所指範圍);cypher-executor/{wrangler.toml,kbdb/wrangler.toml}
未變動(repo 層級 deny 規則保護這兩個生產設定檔不被 AI 編輯)——ANALYTICS_KV binding
因此仍留在 wrangler.toml 宣告中但程式碼零讀寫點(見 PR 說明的完整 grep 佐證)。

KV 裡既有的 stats:* 舊資料不搬移(是統計不是真相源,維持原樣任其依 90 天 TTL 自然過期)。

測試:kbdb/tests/execution-log.test.ts(13 個,含零建表證明/少記/A2 降級/route)、
cypher-executor/tests/execution-logger.test.ts(payload 正確性/永不 throw)、
cypher-executor/tests/executions-route.test.ts(讀取端轉發)、portal-data.test.ts 對應區塊
改寫。kbdb 全測試 104/104 通過;cypher-executor 320 個測試中 9 個失敗為 main 既有(與本次
改動無關,改動前後 stash 對照確認)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:13:00 +08:00

336 lines
17 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.
// Entry CRUD — atomic data + tree (project/workflow via parent_id). Base, D1 only.
import type { Bindings, Entry } from '../types';
function uid(prefix: string): string {
// deterministic-enough unique id without Math.random in hot path is fine here;
// crypto.randomUUID is available in Workers runtime.
return `${prefix}_${crypto.randomUUID()}`;
}
export interface CreateEntryInput {
content?: string | null;
entry_type: string;
owner_id?: string | null;
parent_id?: string | null;
page_name?: string | null;
refs_json?: string;
tags_json?: string;
task_status?: string | null;
confidence?: number | null;
metadata_json?: string | null;
id?: string;
}
export async function createEntry(db: D1Database, input: CreateEntryInput): Promise<Entry> {
const id = input.id ?? uid('e');
await 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(
id,
input.content ?? null,
input.entry_type,
input.owner_id ?? null,
input.parent_id ?? null,
input.page_name ?? null,
input.refs_json ?? '[]',
input.tags_json ?? '[]',
input.task_status ?? null,
input.confidence ?? null,
input.metadata_json ?? null,
)
.run();
const row = await getEntry(db, id);
if (!row) throw new Error('createEntry: insert succeeded but row not found');
return row;
}
export async function getEntry(db: D1Database, id: string): Promise<Entry | null> {
const row = await db.prepare('SELECT * FROM entries WHERE id = ?').bind(id).first<Entry>();
return row ?? null;
}
export interface ListEntriesFilter {
entry_type?: string;
owner_id?: string;
parent_id?: string;
page_name?: string; // exact-match lookup (e.g. skill-/example- idempotency key)
source?: string; // filter by metadata_json.$.source (ingest envelope source.uri). issue #5.1
library?: string[]; // filter by metadata_json.$.library(多值 ORportal-auth P1#24/#25)。
// 未帶=不過濾(向後相容硬驗收);未標記的舊資料視同 'general'design §3.2)。
q?: string; // keyword filter on content (LIKE). Arcrun#3 發現①:list 端點原本完全不吃
// search/qcaller 帶了也被靜默丟棄(不是 458K 筆搜不到,是這個 filter 沒接)。
limit?: number;
offset?: number;
}
export interface ListEntriesResult {
entries: Entry[];
total: number; // 符合本次篩選條件的「全部」筆數(不受 limit/offset 影響)。
// Arcrun#3 發現①「count 欄位語意誤導」:舊版 route 把 entries.length(分頁筆數)
// 當 count 回傳,容易被誤讀成「總共只有這幾筆」。total 才是真總數,count 仍保留=本頁筆數。
}
export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Promise<ListEntriesResult> {
const conds: string[] = [];
const params: unknown[] = [];
if (f.entry_type) { conds.push('entry_type = ?'); params.push(f.entry_type); }
if (f.owner_id) { conds.push('owner_id = ?'); params.push(f.owner_id); }
if (f.parent_id) { conds.push('parent_id = ?'); params.push(f.parent_id); }
if (f.page_name) { conds.push('page_name = ?'); params.push(f.page_name); }
// source is queryable via SQLite json_extract on the existing metadata_json TEXT column —
// no new column / no migration (表不變鐵律). Per issue #5.1 (頂層化 source 成可查 filter).
if (f.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(f.source); }
if (f.library && f.library.length > 0) { conds.push(libraryPredicate(f.library)); params.push(...f.library); }
if (f.q) {
const m = buildContentLike(f.q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
conds.push(...m.conds); params.push(...m.params);
}
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
const limit = Math.min(f.limit ?? 100, 1000);
const offset = f.offset ?? 0;
const [rowsRes, countRow] = await Promise.all([
db
// `, rowid DESC` 二級排序(KV 額度事故修復,2026-08-07 發現):created_at 是
// unixepoch()=秒級解析度,高頻寫入(例如 execution_log 一秒內多筆執行)常同秒,
// 單靠 created_at DESC 的同分排序不保證插入序,「最新一筆」可能取到錯的一列。
// rowid 是 SQLite/D1 一般表的隱含遞增欄,同分時退回插入序,不改變既有排序結果
// created_at 不同時完全一字不變),純粹補上同分時的決定性。
.prepare(`SELECT * FROM entries ${where} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`)
.bind(...params, limit, offset)
.all<Entry>(),
db.prepare(`SELECT COUNT(*) as total FROM entries ${where}`).bind(...params).first<{ total: number }>(),
]);
return { entries: rowsRes.results ?? [], total: countRow?.total ?? 0 };
}
export interface UpdateEntryInput {
content?: string | null;
parent_id?: string | null;
page_name?: string | null;
refs_json?: string;
tags_json?: string;
task_status?: string | null;
confidence?: number | null;
metadata_json?: string | null;
}
export async function updateEntry(db: D1Database, id: string, patch: UpdateEntryInput): Promise<Entry | null> {
const cols: string[] = [];
const params: unknown[] = [];
const map: Record<string, unknown> = patch as Record<string, unknown>;
for (const k of ['content', 'parent_id', 'page_name', 'refs_json', 'tags_json', 'task_status', 'confidence', 'metadata_json']) {
if (k in map && map[k] !== undefined) { cols.push(`${k} = ?`); params.push(map[k]); }
}
if (cols.length === 0) return getEntry(db, id);
cols.push('updated_at = unixepoch()');
await db.prepare(`UPDATE entries SET ${cols.join(', ')} WHERE id = ?`).bind(...params, id).run();
return getEntry(db, id);
}
export async function deleteEntry(db: D1Database, id: string): Promise<void> {
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
}
/**
* 把某 owner 下某庫的所有 entries 標 deprecatedt135 by-name 移除語意)。
* 沿用既有 deprecated 機制:metadata_json.status='deprecated' → 搜尋端過濾、庫列表排除。
* 回 deprecated 的筆數(0 = 庫名不存在或早已全部 deprecated)。
*/
/**
* 撈出某 owner 下某庫、**目前還有向量**的 entry id(供下架時連帶清向量用)。
*
* 🔴 2026-08-05 leo:「已經被刪掉的內容?理論上它的向量也要刪掉,就不會有殘影了吧?」——對。
* 單筆真刪(`DELETE /entries/:id`)已經接了 `VECTORIZE.deleteByIds`b7af622),
* 但「移除整個庫」走軟刪(只標 status),**向量原地不動** ⇒ 殘影就是這樣長出來的:
* 搜尋端每次都要靠事後過濾擋它,而它還會頂著高分去影響門檻計算。
* ⇒ 標 deprecated 的同時把向量刪掉,讓殘影**在源頭就不存在**。
* 不違背 t135「資料保留可還原」:**D1 那列原封不動**,還原後跑
* `POST /embed/backfill` 重嵌即可(backfill 已排除 deprecated,所以不會自己跑回來)。
*/
export async function embeddedIdsByLibrary(db: D1Database, ownerId: string, library: string): Promise<string[]> {
const rows = await db
.prepare(
`SELECT id FROM entries
WHERE owner_id = ?
AND COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') = ?
AND is_embedded = 1`,
)
.bind(ownerId, library)
.all<{ id: string }>();
return (rows.results ?? []).map((r) => r.id);
}
/** 把這些 entry 標成「已無向量」(配合 deleteByIds,讓 D1 與 Vectorize 不說兩套話)。 */
export async function markUnembedded(db: D1Database, ids: string[]): Promise<void> {
if (ids.length === 0) return;
const holes = ids.map(() => '?').join(',');
await db.prepare(`UPDATE entries SET is_embedded = 0 WHERE id IN (${holes})`).bind(...ids).run();
}
export async function deprecateEntriesByLibrary(db: D1Database, ownerId: string, library: string): Promise<number> {
const result = await db
.prepare(
`UPDATE entries
SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.status', 'deprecated'),
updated_at = unixepoch()
WHERE owner_id = ?
AND COALESCE(json_extract(metadata_json, '$.library'), 'general') = ?
AND (json_extract(metadata_json, '$.status') IS NULL
OR json_extract(metadata_json, '$.status') != 'deprecated')`,
)
.bind(ownerId, library)
.run();
return (result.meta?.changes as number | undefined) ?? 0;
}
// ── content 關鍵字比對:D1 的 LIKE pattern 有 50 bytes 硬上限 ───────────────────
//
// 病徵(2026-08-03 在 1.4.4 實例上二分實測):`/entries/search?q=…` 只要 q **超過 48 bytes**
// 就回 HTTP 500「Internal Server Error」——不是 400、沒有錯誤訊息,從外面看像伺服器壞了。
// q = 48 bytes → 200q = 49 bytes → 500ASCII 逐 byte 二分)
// 中文 16 字(48 bytes)→ 200|中文 17 字(51 bytes)→ 500
// 判別實驗(排除「整句 SQL 太長」這個猜想):q 固定 48 bytes、把 owner_id/entry_type/source/
// library 全塞滿讓 SQL 變很長 → 仍然 200 ⇒ **會爆的是 LIKE 的 pattern,不是 statement**。
// pattern = '%' + q + '%' ⇒ 48+2 = 50 ⇒ 上限就是 50 bytes。
// 對照:同一個長 q 走 mode=semantic 完全正常(那條路不經過 LIKE)。
//
// 為什麼要修(不是邊角):**中文問句超過 16 個字是常態**。
// rag_chat 的 kw_search 用整句問題當 q ⇒ 使用者問任何一句正常長度的中文,
// 整條問答鏈在第二個節點就 500 ⇒ 聊天功能等於不能用。
// (這也是 InkStoneCo status.md 待辦第 1 條「KBDB keyword 長查詢會炸」的根因。)
//
// 修法(**短查詢行為逐字不變**):
// · q ≤ 48 bytes → 走原本那條路,單一 `content LIKE '%q%'`,一個字都沒改。
// · q > 48 bytes → 拆成詞,每個詞各一個 LIKE 用 AND 串(「每個詞都要出現」)。
// 沒有空白可拆的長句(中文常見)→ 切成 ≤48 bytes 的片段(切在 UTF-8 邊界上,不切壞字)。
// 詞數上限 6:再多對 D1 是白花成本,而且「要同時命中 7 個詞」本來就不會有結果。
//
// 誠實限制:對「無空白的長中文句」,拆片段是機械切分、不是斷詞 ⇒ 命中率不會變好。
// 但它的對照組是 **500**,不是「更好的結果」;而且這種查詢原本就算不炸也幾乎命不中
// (整句子字串比對)。真正的中文關鍵字檢索要走 FTS5 或斷詞,那是另一件事、要另外立案。
const MAX_LIKE_Q_BYTES = 48; // D1: LIKE pattern 上限 50 bytespattern = '%' + q + '%'
const MAX_LIKE_TERMS = 6;
const utf8Len = (s: string): number => new TextEncoder().encode(s).length;
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。 */
function chunkByBytes(s: string, maxBytes: number): string[] {
const out: string[] = [];
let cur = '';
for (const ch of s) {
if (utf8Len(cur + ch) > maxBytes) {
if (cur) out.push(cur);
cur = ch;
} else {
cur += ch;
}
}
if (cur) out.push(cur);
return out;
}
/**
* 把 q 轉成一組 `content LIKE ?` 謂詞與參數(純函式,單測用 export)。
* 回 `split=false` 代表走的是與舊版逐字相同的單一 LIKE。
*/
export function buildContentLike(q: string): { conds: string[]; params: string[]; split: boolean } {
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
return { conds: ['content LIKE ?'], params: [`%${q}%`], split: false };
}
const terms: string[] = [];
for (const word of q.split(/\s+/).filter(Boolean)) {
for (const piece of chunkByBytes(word, MAX_LIKE_Q_BYTES)) {
terms.push(piece);
if (terms.length >= MAX_LIKE_TERMS) break;
}
if (terms.length >= MAX_LIKE_TERMS) break;
}
// 理論上不會空(q 非空才進得來),但空陣列會產出 `WHERE` 沒有條件 ⇒ 保底退回單一截斷 LIKE
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? '');
return {
conds: terms.map(() => 'content LIKE ?'),
params: terms.map((t) => `%${t}%`),
split: true,
};
}
// 「庫」filter 的 SQL 謂詞(portal-auth P1design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
function libraryPredicate(libraries: string[]): string {
const placeholders = libraries.map(() => '?').join(',');
return `COALESCE(json_extract(metadata_json, '$.library'), 'general') IN (${placeholders})`;
}
// daemon-beta t24(總管 0.971 親復現、t11 斷點①②)——下架(rag_takedown_direct)只把
// metadata_json.status 標成 'deprecated'(軟刪,append-only,見 KBDB 表不變鐵律),從不刪列。
// 濾層過去只存在 cypher-executor/src/routes/portal-data.ts 的 filterDeprecatedEntries(客端治標,
// Arcrun#46),rag_chat 沒部署的實例(如 leo21c)等於完全沒濾——AI 實際會用到的 MCP/raw
// /entries/search 面直接把已下架內容當現役回傳(semantic 甚至最高分回傳,見 t11 斷點②)。
// 本謂詞把過濾下沉到 KBDB 服務端(薄殼原則 07:能力只長一次),source-of-truth 修好後
// portal-data.ts 的客端治標理論上可拔(未在本 PR 動,範圍只限 kbdb/)。
//
// 用 json_extract 判等(不用 NOT LIKE '%"status":"deprecated"%')——LIKE 對 JSON 序列化格式敏感
// (key 順序、空白、字串轉義都可能讓子字串比對誤判/漏判,例如 metadata_json 裡若有其他欄位的
// 值恰好含這段子字串就會被誤殺),json_extract 是結構化取值,只認真正的 $.status 欄位,同一謂詞
// 家族(source/library)已驗證過這個模式對 SQLite/D1 穩定可靠(issue #5.1、#18 mistake)。
// NULL(沒有 metadata_json 或沒有 status 欄)視為未下架(保留,不誤殺——大多數既有資料沒有
// status 欄)。
const NOT_DEPRECATED_PREDICATE =
"(json_extract(metadata_json, '$.status') IS NULL OR json_extract(metadata_json, '$.status') != 'deprecated')";
/**
* JS 側判斷單筆 entry 是否已下架(status==='deprecated')。給 semantic 路徑用——Vectorize
* hit 的 metadata 沒有存 status(見 embed.ts upsert 的 indexed metadata 只有
* owner_id/entry_type/source/library),要濾必須先 hydrate 回完整 entry 再判斷,故無法像
* keyword 走 SQL 謂詞,只能在拿到 metadata_json 後用同一套判準(status==='deprecated')在
* JS 層濾。metadata_json parse 失敗 → 視為保留(治標不誤殺,與 portal-data.ts
* filterDeprecatedEntries 同慣例)。
*/
export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boolean {
if (!entry.metadata_json) return false;
try {
const meta = JSON.parse(entry.metadata_json) as { status?: unknown } | null;
return !!meta && meta.status === 'deprecated';
} catch {
return false;
}
}
// 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).
// library: optional 多值庫 filterportal-auth P1);未帶=行為與舊版一字不變(向後相容)。
// source: metadata_json.$.source filterissue #66——#5.1 只接了 listEntries 那半,keyword search
// 路徑 route 解析完即丟;謂詞與 listEntries 同款 json_extract,不動表)。加在參數尾端,
// 既有 positional caller 一個都不用改(向後相容)。
// includeDeprecateddaemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
// 加在參數最尾端,既有 positional callersource 之後)一個都不用改。
export async function searchEntries(
db: D1Database,
q: string,
owner_id?: string,
entry_type?: string,
limit = 50,
library?: string[],
source?: string,
includeDeprecated = false,
): Promise<Entry[]> {
const m = buildContentLike(q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
const conds = [...m.conds];
const params: unknown[] = [...m.params];
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
const res = await db
.prepare(`SELECT * FROM entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`)
.bind(...params, Math.min(limit, 200))
.all<Entry>();
return res.results ?? [];
}