7a29dee357
* chore(wiki): 導入 system-dev-template + 合併 wiki 到新位置 - system-dev/ 模板包進版控(VERSION/docs/scripts/wiki 骨架) - 逐檔合併舊 .claude/wiki/ → system-dev/wiki/: - status/mistakes/decisions-summary 真資料覆蓋空範本 - INDEX 新「多角度視圖」結構 + 舊決策/導航併入(過時詞「萬物皆 Block」改 API-as-Wall) - principles/TAXONOMY 為新位置獨有,保留 - 刪舊 .claude/wiki/(git 識別為 rename,內容完整搬移) - 三層機敏防護 hooks + wiki 命令更新 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(sdd): 立 ingest-contract SDD + 搬入 ingest-candidate 契約 (T3.1+T3.8) 對應 issue #1(頂層 mira-dissolve T3)。 - contracts/ingest-candidate.json:ingest→graph 邊界契約(自頂層搬入) - contracts/README.md:標明候選(輸入)≠已存(triplet) - docs/3-specs/ingest-contract/design.md + tasks.md: - ensureTemplate 改 slot-diff 補丁(取代 early-return,免遷移腳本) - 補 KbdbClient.updateRecord(base PATCH /records/:id) - ingest 流程:驗證(422)→idempotency(uri+hash)→先 append 後 deprecate - triplet template 增 source_uri+content_hash slot 承載 idempotency - 跨 repo 協調點(3.6 圖工具併 KBDB MCP)明列需 arcrun 配合 總管已認可四個設計決定(issue #1 comment)。鐵律:零建表/零 SQL/零 migration。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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> * feat(graph): get_source + refresh 端點 + keyword 收斂 (T3.6-3.7) 對應 issue #1 T3 C 段(圖工具 HTTP API 備好,MCP 註冊薄殼待 arcrun)。 - get_source (3.7): graph-source.ts + GET /graph/source/:name — 回節點的 active triplet 來源指標(uri/anchor/block_id/content_hash),去重。 連帶加 source_anchor slot,ingest 從 source.anchor 帶入 - refresh (3.6/3.6b): graph-refresh.ts + POST /graph/refresh — 純被動代轉 ingest(KBDB_INGEST_URL),只人發起、無排程/webhook(fan-out 紅線)。 未設 URL → 誠實 forwarded:false,不假綠 - 3.6d: POST /search 移除公開 keyword 模式(重複 KBDB MCP),收斂 suggest-only; keywordSearch helper 留作 suggest 內部建構塊 - 3 新測試(get_source uri+anchor / active-only / refresh 未就緒誠實回報) gates: vitest 19 passed / zero SQL / 無新綁定 / dry-run bundle 乾淨 待接:MCP 註冊薄殼併 arcrun u6u-mcp-server;refresh 端到端待 ingest(T4) 部署 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: richblack <leo21c@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
182 lines
6.2 KiB
TypeScript
182 lines
6.2 KiB
TypeScript
// 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<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);
|
||
}
|