feat(workflow-store): workflow 真相源 KV → KBDB API(entry_type=workflow)
SDD kbdb-base §8.3/§8.4 P2+P3。總管裁定 2026-07-06: ① upsert 走 KBDB base PUT /entries(by owner_id+page_name+entry_type) ② cron 掃 KBDB、不保留 KV cron-idx ③ 一 workflow=一筆 entry_type=workflow;content=description(保 embed)、 graph+config+cron_expr 進 metadata_json KBDB: - entry-crud.ts 加 upsertEntry(read-then-write,無 UNIQUE、表不變) - entries.ts 加 PUT /entries(upsert,回 created 旗標,content 變動時 embed-on-write) cypher-executor: - 新增 lib/workflow-store.ts(走 kbdbBase,禁直連 D1) - webhooks-named register/trigger/list/delete 切雙軌(讀先 KBDB miss fallback KV、 寫 KBDB 為主+暫雙寫 KV);writeWorkflowSearchEntry 上收進 putWorkflow(修重複 entry bug); backfill 升級為 KV→KBDB 遷移入口;移除 migrate-cron-index 端點 - scheduled 改掃 KBDB(listCronWorkflows);刪除 lib/cron-index.ts - component-loader(trigger_workflow)、executions 擁有權檢查切雙軌讀 tsc 0 error;kbdb vitest 6/6;cypher vitest 41/42(1 失敗為 pre-existing,與本 PR 無關)。 不 merge、不部署、不動 production KV workflow。搬遷步驟見 MIGRATION.md。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
This commit is contained in:
@@ -123,6 +123,47 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
// Upsert-by-(owner_id, page_name, entry_type) — capability lives in API (thin-shell §1 正例:
|
||||
// 「API 提供 upsert 端點(內部 GET 找→有則 update 無則 insert)」)。base 沒有 UNIQUE 約束
|
||||
// (表不變鐵律 + 既有可能有重複 page_name),故用 read-then-write,不靠 ON CONFLICT。
|
||||
// 用途源: kbdb-base SDD §8.3 workflow 遷 D1 —— page_name 當唯一鍵(owner+type 內),
|
||||
// register/redeploy 同名不再堆重複 entry(修掉 writeWorkflowSearchEntry 每次 POST 的重複 bug)。
|
||||
// key tuple 三者皆為 idempotency key;命中最舊一筆(created_at ASC)更新,讓重跑收斂到同一 canonical。
|
||||
export async function upsertEntry(
|
||||
db: D1Database,
|
||||
input: CreateEntryInput,
|
||||
): Promise<{ entry: Entry; created: boolean }> {
|
||||
if (!input.entry_type || !input.page_name) {
|
||||
throw new Error('upsertEntry: entry_type 與 page_name 為 upsert key,必填');
|
||||
}
|
||||
const owner = input.owner_id ?? null;
|
||||
const existing = owner === null
|
||||
? await db
|
||||
.prepare('SELECT * FROM entries WHERE entry_type = ? AND page_name = ? AND owner_id IS NULL ORDER BY created_at ASC LIMIT 1')
|
||||
.bind(input.entry_type, input.page_name)
|
||||
.first<Entry>()
|
||||
: await db
|
||||
.prepare('SELECT * FROM entries WHERE entry_type = ? AND page_name = ? AND owner_id = ? ORDER BY created_at ASC LIMIT 1')
|
||||
.bind(input.entry_type, input.page_name, owner)
|
||||
.first<Entry>();
|
||||
|
||||
if (existing) {
|
||||
const entry = await updateEntry(db, existing.id, {
|
||||
content: input.content ?? null,
|
||||
parent_id: input.parent_id ?? null,
|
||||
refs_json: input.refs_json,
|
||||
tags_json: input.tags_json,
|
||||
task_status: input.task_status ?? null,
|
||||
confidence: input.confidence ?? null,
|
||||
metadata_json: input.metadata_json ?? null,
|
||||
});
|
||||
if (!entry) throw new Error('upsertEntry: update 後找不到 row');
|
||||
return { entry, created: false };
|
||||
}
|
||||
const entry = await createEntry(db, input);
|
||||
return { entry, created: true };
|
||||
}
|
||||
|
||||
// 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).
|
||||
export async function searchEntries(
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
searchEntries,
|
||||
upsertEntry,
|
||||
} from '../actions/entry-crud';
|
||||
import { embedEnabled, embedOnWrite, semanticSearch } from '../embed';
|
||||
|
||||
@@ -23,6 +24,21 @@ entryRoutes.post('/', async (c) => {
|
||||
return c.json({ success: true, entry });
|
||||
});
|
||||
|
||||
// PUT /entries — upsert by (owner_id, page_name, entry_type)(能力長在 API,thin-shell §1 正例)。
|
||||
// 有則 update、無則 insert,回 created 旗標。用途源: kbdb-base SDD §8.3 workflow 遷 D1
|
||||
// —— cypher workflow-store 靠這個「redeploy 同名不堆重複 entry」。
|
||||
// 三者為 upsert key(缺 page_name/entry_type → 400);content/metadata_json 等隨之覆寫。
|
||||
entryRoutes.put('/', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.entry_type || !body.page_name) {
|
||||
return c.json({ success: false, error: 'entry_type 與 page_name required(upsert key)' }, 400);
|
||||
}
|
||||
const { entry, created } = await upsertEntry(c.env.DB, body);
|
||||
// 內容可能新增或改寫 → 重 embed(保持向量新鮮)。embedOnWrite 內部自檢模組開 + embeddable。
|
||||
if (embedEnabled(c.env)) c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {}));
|
||||
return c.json({ success: true, entry, created });
|
||||
});
|
||||
|
||||
// GET /entries — list with filters (entry_type, owner_id, parent_id, page_name, source, q/search)
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user