60688c3108
事故: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>
336 lines
17 KiB
TypeScript
336 lines
17 KiB
TypeScript
// 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(多值 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<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 標 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<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 → 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<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 ?? [];
|
||
}
|