Files
kbdb-graph-plugin/src/lib/kbdb-client.ts
T
Leo 27f7448914 feat(ingest): POST /triplets/ingest 寫入端 + deprecate-then-append (T3.2-3.5)
對應 issue #1 T3 B 段。

- templates: TRIPLET_SLOTS 加 status/superseded_by/source_uri/content_hash;
  ENTITY_SLOTS 加 gloss;recordToTriplet 映射新欄位(缺省 status=active 相容舊資料)
- kbdb-client: ensureTemplate 改 slot-diff 補丁(既有 template 走 PATCH /templates/:id
  補缺 slot,取代 early-return → 免遷移腳本);新增 updateRecord(PATCH /records/:id)
- triplet-ingest action(88 行純函式):Zod strict 鏡射 ingest-candidate 契約 →
  idempotency(uri+hash 同→no-op)→ 先 append 後 deprecate(無「全無 active」空窗)
- POST /triplets/ingest route:strict 驗證失敗 → 422(禁送 graph 領域欄位)
- queryTriplets 預設 active-only(traverse/search/neighbors 皆經此),
  includeDeprecated opt-out 供 rollback/考古
- 6 測試案全綠(vitest 16 passed);mock-client 同步 slot-diff + updateRecord

gates: zero SQL / zero migration / 無 D1·Vectorize·AI 綁定 / dry-run bundle 乾淨

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:13:49 +08:00

182 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// KBDB 基本盤 API client — 唯一對外通道(API-as-Wall
//
// 鐵律:插件不碰表、零 SQL。所有讀寫只透過基本盤 HTTP APIarcrun/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<string, string>;
};
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<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { '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<BaseEntry> {
const { entry } = await this.req<{ entry: BaseEntry }>('POST', '/entries', input);
return entry;
}
async getEntry(id: string): Promise<BaseEntry | null> {
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<BaseEntry[]> {
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<BaseEntry[]> {
const { entries } = await this.req<{ entries: BaseEntry[] }>(
'GET',
'/entries/search' + qs({ q, owner_id }),
);
return entries ?? [];
}
async updateEntry(id: string, patch: Partial<CreateEntryInput>): Promise<BaseEntry | null> {
try {
const { entry } = await this.req<{ entry: BaseEntry }>('PATCH', `/entries/${encodeURIComponent(id)}`, patch);
return entry;
} catch {
return null;
}
}
async deleteEntry(id: string): Promise<void> {
await this.req('DELETE', `/entries/${encodeURIComponent(id)}`);
}
// --- templates= 替代建表;插件要新類型只能建 template) ---
async ensureTemplate(name: string, slots: string[], description?: string): Promise<void> {
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<string, string>, owner_id?: string): Promise<string> {
const { record } = await this.req<{ record: { record_id: string } }>('POST', '/records', {
template,
values,
owner_id,
});
return record.record_id;
}
async getRecord(recordId: string): Promise<BaseRecord | null> {
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<string, string>): Promise<void> {
await this.req('PATCH', `/records/${encodeURIComponent(recordId)}`, { values });
}
async listRecordsByTemplate(template: string, owner_id?: string): Promise<BaseRecord[]> {
const { records } = await this.req<{ records: BaseRecord[] }>(
'GET',
`/records/by-template/${encodeURIComponent(template)}` + qs({ owner_id }),
);
return records ?? [];
}
}
function qs(params: Record<string, string | number | undefined>): 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);
}