// KBDB 基本盤 API client — 唯一對外通道(API-as-Wall) // // 鐵律:插件不碰表、零 SQL。所有讀寫只透過基本盤 HTTP API(arcrun/kbdb)。 // 這裡只發 fetch,絕無 .prepare / D1。base URL 由 KBDB_BASE_URL env var 注入(可留空於本地測試以 mock 替代)。 // // 對齊基本盤真實契約(讀 arcrun/kbdb/src 2026-06-14): // 欄位用 entry_type / owner_id(非 type / user_id);回應包在 { success, ... }。 export type BaseEntry = { id: string; content: string | null; entry_type: string; owner_id: string | null; parent_id?: string | null; page_name?: string | null; created_at?: number; updated_at?: number; }; export type BaseRecord = { record_id: string; template: string; values: Record; }; export type CreateEntryInput = { content: string | null; entry_type: string; owner_id?: string; parent_id?: string; page_name?: string; }; /** 基本盤 API client。所有方法 = 一個 HTTP 呼叫,零 SQL。 */ export class KbdbClient { constructor( private readonly baseUrl: string, private readonly token?: string, ) { if (!baseUrl) { throw new Error('KBDB_BASE_URL 未設定:插件需指向基本盤 API(不可直連 D1)'); } } private async req(method: string, path: string, body?: unknown): Promise { const headers: Record = { 'Content-Type': 'application/json' }; if (this.token) headers['Authorization'] = `Bearer ${this.token}`; const res = await fetch(this.baseUrl.replace(/\/$/, '') + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), }); const json = (await res.json().catch(() => null)) as any; if (!res.ok || (json && json.success === false)) { const msg = json?.error ?? `${res.status} ${res.statusText}`; throw new Error(`[kbdb-base] ${method} ${path}: ${msg}`); } return json as T; } // --- entries --- async createEntry(input: CreateEntryInput): Promise { const { entry } = await this.req<{ entry: BaseEntry }>('POST', '/entries', input); return entry; } async getEntry(id: string): Promise { try { const { entry } = await this.req<{ entry: BaseEntry }>('GET', `/entries/${encodeURIComponent(id)}`); return entry; } catch { return null; } } async listEntries(filters: { entry_type?: string; owner_id?: string; parent_id?: string; page_name?: string; limit?: number; offset?: number; } = {}): Promise { const { entries } = await this.req<{ entries: BaseEntry[] }>('GET', '/entries' + qs(filters)); return entries ?? []; } /** 基本盤 D1 LIKE keyword 搜尋(語意搜尋屬 optional embed 模組,base 沒有)。 */ async searchEntries(q: string, owner_id?: string): Promise { const { entries } = await this.req<{ entries: BaseEntry[] }>( 'GET', '/entries/search' + qs({ q, owner_id }), ); return entries ?? []; } async updateEntry(id: string, patch: Partial): Promise { try { const { entry } = await this.req<{ entry: BaseEntry }>('PATCH', `/entries/${encodeURIComponent(id)}`, patch); return entry; } catch { return null; } } async deleteEntry(id: string): Promise { await this.req('DELETE', `/entries/${encodeURIComponent(id)}`); } // --- templates(= 替代建表;插件要新類型只能建 template) --- async ensureTemplate(name: string, slots: string[], description?: string): Promise { const existing = await this.req<{ id?: string; slots?: string[] } | { error: string }>( 'GET', `/templates/${encodeURIComponent(name)}`, ).catch(() => null); // 全新 template → 建。 if (!existing || !(existing as any).id) { await this.req('POST', '/templates', { name, slots, description, created_by: 'kbdb-graph' }); return; } // 既有 template → 補缺 slot(不 early-return;否則 seed 後新增的 slot 永遠進不來)。 // 走 base PATCH /templates/:id 增 slot;既有環境免另跑遷移腳本即收斂。 const have = new Set((existing as any).slots ?? []); const missing = slots.filter((s) => !have.has(s)); if (missing.length === 0) return; await this.req('PATCH', `/templates/${encodeURIComponent((existing as any).id)}`, { slots: [...have, ...missing], }); } // --- records(= template 實例,填 slot) --- async createRecord(template: string, values: Record, owner_id?: string): Promise { const { record } = await this.req<{ record: { record_id: string } }>('POST', '/records', { template, values, owner_id, }); return record.record_id; } async getRecord(recordId: string): Promise { try { const { record } = await this.req<{ record: BaseRecord }>('GET', `/records/${encodeURIComponent(recordId)}`); return record; } catch { return null; } } /** 翻 record 的 slot 值(base PATCH /records/:id)。deprecate(翻 status)與 rollback 都靠它。 */ async updateRecord(recordId: string, values: Record): Promise { await this.req('PATCH', `/records/${encodeURIComponent(recordId)}`, { values }); } async listRecordsByTemplate(template: string, owner_id?: string): Promise { const { records } = await this.req<{ records: BaseRecord[] }>( 'GET', `/records/by-template/${encodeURIComponent(template)}` + qs({ owner_id }), ); return records ?? []; } } function qs(params: Record): string { const parts = Object.entries(params) .filter(([, v]) => v !== undefined && v !== '') .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`); return parts.length ? `?${parts.join('&')}` : ''; } /** 從 Bindings 建 client。KBDB_BASE_URL 未設時拋錯(不准 fallback 直連 D1)。 */ export function makeKbdbClient(env: { KBDB_BASE_URL?: string; KBDB_INTERNAL_TOKEN?: string }): KbdbClient { return new KbdbClient(env.KBDB_BASE_URL ?? '', env.KBDB_INTERNAL_TOKEN); }