// 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 { 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 { const row = await db.prepare('SELECT * FROM entries WHERE id = ?').bind(id).first(); 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(多值 OR;portal-auth P1,#24/#25)。 // 未帶=不過濾(向後相容硬驗收);未標記的舊資料視同 'general'(design §3.2)。 q?: string; // keyword filter on content (LIKE). Arcrun#3 發現①:list 端點原本完全不吃 // search/q,caller 帶了也被靜默丟棄(不是 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 { 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(), 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 { const cols: string[] = []; const params: unknown[] = []; const map: Record = patch as Record; 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 { await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run(); } /** * 把某 owner 下某庫的所有 entries 標 deprecated(t135 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 { 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 { 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 { 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 → 200|q = 49 bytes → 500(ASCII 逐 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 bytes,pattern = '%' + 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 P1,design §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 多值庫 filter(portal-auth P1);未帶=行為與舊版一字不變(向後相容)。 // source: metadata_json.$.source filter(issue #66——#5.1 只接了 listEntries 那半,keyword search // 路徑 route 解析完即丟;謂詞與 listEntries 同款 json_extract,不動表)。加在參數尾端, // 既有 positional caller 一個都不用改(向後相容)。 // includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。 // 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。 // 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。 export async function searchEntries( db: D1Database, q: string, owner_id?: string, entry_type?: string, limit = 50, library?: string[], source?: string, includeDeprecated = false, ): Promise { 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(); return res.results ?? []; }