// Entries route — atomic data + tree (project/workflow). Base; embed is OPTIONAL (issue #7). import { Hono } from 'hono'; import type { Bindings } from '../types'; import { createEntry, getEntry, listEntries, updateEntry, deleteEntry, searchEntries, } from '../actions/entry-crud'; import { embedEnabled, embedOnWrite, semanticSearch } from '../embed'; export const entryRoutes = new Hono<{ Bindings: Bindings }>(); // POST /entries — create (entry_type=block/value/project/workflow/...) entryRoutes.post('/', async (c) => { const body = await c.req.json().catch(() => null); if (!body || !body.entry_type) return c.json({ success: false, error: 'entry_type required' }, 400); const entry = await createEntry(c.env.DB, body); // embed-on-write (#7 / #5 第4點):模組開 + entry 標 embed:true 才做;fire-and-forget,不阻塞回應、失敗不致命。 if (embedEnabled(c.env)) c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {})); return c.json({ success: true, entry }); }); // GET /entries — list with filters (entry_type, owner_id, parent_id, page_name, source) // e.g. list workflows under a project: ?parent_id=PROJECT&entry_type=workflow // e.g. get one by idempotency key: ?page_name=skill-rag_with_arcrun // e.g. filter by ingest source: ?source=logseq://vault/foo.md (issue #5.1) entryRoutes.get('/', async (c) => { const entries = await listEntries(c.env.DB, { entry_type: c.req.query('entry_type') || undefined, owner_id: c.req.query('owner_id') || undefined, parent_id: c.req.query('parent_id') || undefined, page_name: c.req.query('page_name') || undefined, source: c.req.query('source') || undefined, limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined, offset: c.req.query('offset') ? Number(c.req.query('offset')) : undefined, }); return c.json({ success: true, entries, count: entries.length }); }); // GET /entries/search?q=...&owner_id=...&source=...&entry_type=...&mode=keyword|semantic // - mode=keyword(預設):D1 LIKE(base,永遠可用)。 // - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword + capability_hint 告知缺能力(#7 發現閉環)。 // - entry_type:base 通用 filter(caller 傳任意 type,如 workflow;base 不寫死語意,workflow-discovery Q4)。 entryRoutes.get('/search', async (c) => { const q = c.req.query('q'); if (!q) return c.json({ success: false, error: 'q required' }, 400); const owner_id = c.req.query('owner_id') || undefined; const source = c.req.query('source') || undefined; const entry_type = c.req.query('entry_type') || undefined; const mode = c.req.query('mode') === 'semantic' ? 'semantic' : 'keyword'; if (mode === 'semantic') { const hits = await semanticSearch(c.env, q, { owner_id, source, entry_type }); if (hits === null) { // 模組沒開:誠實降級 keyword + 告知「叫 CC 幫你開 vectorize」(不假裝有語義)。 const entries = await searchEntries(c.env.DB, q, owner_id, entry_type); return c.json({ success: true, entries, count: entries.length, mode: 'keyword', requested_mode: 'semantic', capability_hint: '語義查詢需先開 vectorize(embed 模組)。叫 CC「幫我開語義查詢」即可(設 kbdb_embed:true + redeploy)。本次已降級關鍵字搜尋。', }); } // hydrate vector hits → 完整 entry(保持回應形狀與 keyword 一致)。 const entries = (await Promise.all(hits.map((h) => getEntry(c.env.DB, h.id)))).filter( (e): e is NonNullable => e !== null, ); return c.json({ success: true, entries, count: entries.length, mode: 'semantic' }); } const entries = await searchEntries(c.env.DB, q, owner_id, entry_type); return c.json({ success: true, entries, count: entries.length, mode: 'keyword' }); }); // GET /entries/:id entryRoutes.get('/:id', async (c) => { const entry = await getEntry(c.env.DB, c.req.param('id')); if (!entry) return c.json({ success: false, error: 'not found' }, 404); return c.json({ success: true, entry }); }); // PATCH /entries/:id entryRoutes.patch('/:id', async (c) => { const body = await c.req.json().catch(() => ({})); const entry = await updateEntry(c.env.DB, c.req.param('id'), body); if (!entry) return c.json({ success: false, error: 'not found' }, 404); // 內容改了 → 重 embed(保持向量新鮮)。embedOnWrite 內部自會檢查模組開 + entry 是否 embeddable。 if (embedEnabled(c.env) && body.content !== undefined) { c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {})); } return c.json({ success: true, entry }); }); // DELETE /entries/:id entryRoutes.delete('/:id', async (c) => { // 模組開 → 連帶刪向量(避免孤兒向量)。失敗不致命。 if (embedEnabled(c.env)) { c.executionCtx.waitUntil(c.env.VECTORIZE!.deleteByIds([c.req.param('id')]).then(() => {}).catch(() => {})); } await deleteEntry(c.env.DB, c.req.param('id')); return c.json({ success: true }); });