feat: KBDB-graph 插件獨立 — 全面改寫成走基本盤 API(API-as-Wall)

按 leo 鐵律(2026-06-14)把插件從「直接 SQL 操作基本盤表」改寫成
「只透過基本盤 arcrun/kbdb HTTP API 讀寫」。零建表、零 migration、零 SQL。

- 新增 src/lib/kbdb-client.ts:唯一對外通道,封裝 entries/templates/records API
- 新增 src/lib/templates.ts:triplet/entity template 定義(替代建表)
- 改寫 21 個違規 action(triplet/graph/entity/search)→ 走 client,圖在插件層記憶體組裝
- 移除所有 migrations、D1/Vectorize/AI 綁定;embedding/語意搜尋歸基本盤 optional 模組
- index.ts 只掛 triplets/graph/entities/search 路由;基本盤路由歸 arcrun/kbdb
- 測試改走 mock client(純 node);裁剪 CLAUDE.md 只留 graph 插件 + 鐵律
- 修正 SDD design.md「讀現狀推翻鐵律」的錯誤判斷(共用 D1 → API-as-Wall)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 20:59:41 +08:00
commit efe8e165cf
62 changed files with 7671 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
// 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 } | { error: string }>(
'GET',
`/templates/${encodeURIComponent(name)}`,
).catch(() => null);
if (existing && (existing as any).id) return;
await this.req('POST', '/templates', { name, slots, description, created_by: 'kbdb-graph' });
}
// --- 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;
}
}
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);
}