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:
@@ -0,0 +1,31 @@
|
||||
// entity 正規化 — exact match(語意合併屬基本盤 embed 模組),走 mock client。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createEntity, findEntityByName, listEntities } from '../src/actions/entity-crud';
|
||||
import { normalizeEntity } from '../src/actions/entity-normalize';
|
||||
import { mockClient } from './mock-client';
|
||||
|
||||
describe('entity-crud', () => {
|
||||
it('建立後可 exact 查回(大小寫不敏感)', async () => {
|
||||
const c = mockClient();
|
||||
await createEntity(c, 'InkStone');
|
||||
const found = await findEntityByName(c, 'inkstone');
|
||||
expect(found?.canonical).toBe('InkStone');
|
||||
});
|
||||
|
||||
it('listEntities 列出', async () => {
|
||||
const c = mockClient();
|
||||
await createEntity(c, 'A');
|
||||
await createEntity(c, 'B');
|
||||
const all = await listEntities(c);
|
||||
expect(all.map((e) => e.canonical).sort()).toEqual(['A', 'B']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeEntity', () => {
|
||||
it('已存在回 canonical,不存在建新回原值', async () => {
|
||||
const c = mockClient();
|
||||
await createEntity(c, 'InkStone');
|
||||
expect(await normalizeEntity(c, 'INKSTONE')).toBe('InkStone');
|
||||
expect(await normalizeEntity(c, '新公司')).toBe('新公司');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// graph 查詢 — 圖在插件層記憶體組裝(不靠 DB VIEW),走 mock client。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createTriplet } from '../src/actions/triplet-crud';
|
||||
import { listNodes, getNodeEdges, getNeighbors } from '../src/actions/graph-nodes';
|
||||
import { findShortestPath } from '../src/actions/graph-path';
|
||||
import { mockClient } from './mock-client';
|
||||
import type { KbdbClient } from '../src/lib/kbdb-client';
|
||||
|
||||
async function seed(c: KbdbClient) {
|
||||
// A — B — C,D 孤立連 A
|
||||
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'B' });
|
||||
await createTriplet(c, { subject: 'B', predicate: 'r', object: 'C' });
|
||||
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'D' });
|
||||
}
|
||||
|
||||
describe('graph-nodes', () => {
|
||||
it('listNodes 統計 edge_count', async () => {
|
||||
const c = mockClient();
|
||||
await seed(c);
|
||||
const nodes = await listNodes(c, {});
|
||||
const a = nodes.find((n) => n.node === 'A');
|
||||
expect(a?.edge_count).toBe(2);
|
||||
});
|
||||
|
||||
it('getNeighbors 收鄰居', async () => {
|
||||
const c = mockClient();
|
||||
await seed(c);
|
||||
const neighbors = await getNeighbors(c, 'A');
|
||||
expect(neighbors.sort()).toEqual(['B', 'D']);
|
||||
});
|
||||
|
||||
it('getNodeEdges 取邊', async () => {
|
||||
const c = mockClient();
|
||||
await seed(c);
|
||||
const edges = await getNodeEdges(c, 'B');
|
||||
expect(edges.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('graph-path', () => {
|
||||
it('A→C 最短路經過 B', async () => {
|
||||
const c = mockClient();
|
||||
await seed(c);
|
||||
const res = await findShortestPath(c, 'A', 'C');
|
||||
expect(res.path).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// 記憶體版 KbdbClient — 模擬基本盤 API,供插件單元測試(不打真網路、不碰 D1)。
|
||||
// 只實作插件實際用到的方法,行為對齊 arcrun/kbdb 契約。
|
||||
|
||||
import type { KbdbClient, BaseEntry, BaseRecord } from '../src/lib/kbdb-client';
|
||||
|
||||
export class MockKbdbClient {
|
||||
private entries = new Map<string, BaseEntry>();
|
||||
private templates = new Map<string, string[]>();
|
||||
private records = new Map<string, { template: string; values: Record<string, string>; owner_id?: string }>();
|
||||
private seq = 0;
|
||||
|
||||
private id(prefix: string): string {
|
||||
this.seq += 1;
|
||||
return `${prefix}-${this.seq}`;
|
||||
}
|
||||
|
||||
async createEntry(input: any): Promise<BaseEntry> {
|
||||
const id = this.id('entry');
|
||||
const entry: BaseEntry = { id, content: input.content ?? null, entry_type: input.entry_type, owner_id: input.owner_id ?? null };
|
||||
this.entries.set(id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async getEntry(id: string): Promise<BaseEntry | null> {
|
||||
return this.entries.get(id) ?? null;
|
||||
}
|
||||
|
||||
async listEntries(filters: any = {}): Promise<BaseEntry[]> {
|
||||
return [...this.entries.values()].filter(
|
||||
(e) => (!filters.entry_type || e.entry_type === filters.entry_type) && (!filters.owner_id || e.owner_id === filters.owner_id),
|
||||
);
|
||||
}
|
||||
|
||||
async searchEntries(q: string, owner_id?: string): Promise<BaseEntry[]> {
|
||||
const needle = q.toLowerCase();
|
||||
return [...this.entries.values()].filter(
|
||||
(e) => (e.content ?? '').toLowerCase().includes(needle) && (!owner_id || e.owner_id === owner_id),
|
||||
);
|
||||
}
|
||||
|
||||
async updateEntry(id: string, patch: any): Promise<BaseEntry | null> {
|
||||
const e = this.entries.get(id);
|
||||
if (!e) return null;
|
||||
Object.assign(e, patch);
|
||||
return e;
|
||||
}
|
||||
|
||||
async deleteEntry(id: string): Promise<void> {
|
||||
this.entries.delete(id);
|
||||
}
|
||||
|
||||
async ensureTemplate(name: string, slots: string[]): Promise<void> {
|
||||
if (!this.templates.has(name)) this.templates.set(name, slots);
|
||||
}
|
||||
|
||||
async createRecord(template: string, values: Record<string, string>, owner_id?: string): Promise<string> {
|
||||
const id = this.id('rec');
|
||||
this.records.set(id, { template, values, owner_id });
|
||||
return id;
|
||||
}
|
||||
|
||||
async getRecord(recordId: string): Promise<BaseRecord | null> {
|
||||
const r = this.records.get(recordId);
|
||||
if (!r) return null;
|
||||
return { record_id: recordId, template: r.template, values: r.values };
|
||||
}
|
||||
|
||||
async listRecordsByTemplate(template: string, owner_id?: string): Promise<BaseRecord[]> {
|
||||
return [...this.records.entries()]
|
||||
.filter(([, r]) => r.template === template && (!owner_id || r.owner_id === owner_id))
|
||||
.map(([record_id, r]) => ({ record_id, template: r.template, values: r.values }));
|
||||
}
|
||||
}
|
||||
|
||||
/** 轉成 KbdbClient 型別供 action 接受。 */
|
||||
export function mockClient(): KbdbClient {
|
||||
return new MockKbdbClient() as unknown as KbdbClient;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// triplet CRUD — 走 mock KbdbClient(API-as-Wall),零 SQL。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createTriplet, queryTriplets, getTriplet } from '../src/actions/triplet-crud';
|
||||
import { mockClient } from './mock-client';
|
||||
|
||||
describe('createTriplet → records API', () => {
|
||||
it('建立後可由 id 取回', async () => {
|
||||
const c = mockClient();
|
||||
const r = await createTriplet(c, { subject: 'InkStone', predicate: '是', object: '創業 OS' });
|
||||
expect(r.id).toBeDefined();
|
||||
expect(r.subject).toBe('InkStone');
|
||||
|
||||
const got = await getTriplet(c, r.id);
|
||||
expect(got?.predicate).toBe('是');
|
||||
expect(got?.object).toBe('創業 OS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryTriplets → 插件層 filter', () => {
|
||||
it('by subject 過濾', async () => {
|
||||
const c = mockClient();
|
||||
await createTriplet(c, { subject: 'KBDB', predicate: '使用', object: 'D1' });
|
||||
await createTriplet(c, { subject: 'Other', predicate: '使用', object: 'X' });
|
||||
|
||||
const { triplets, count } = await queryTriplets(c, { subject: 'KBDB' });
|
||||
expect(count).toBe(1);
|
||||
expect(triplets[0].object).toBe('D1');
|
||||
});
|
||||
|
||||
it('limit/offset 分頁', async () => {
|
||||
const c = mockClient();
|
||||
for (let i = 0; i < 5; i++) await createTriplet(c, { subject: `s${i}`, predicate: 'p', object: 'o' });
|
||||
const { triplets } = await queryTriplets(c, { limit: 2, offset: 1 });
|
||||
expect(triplets.length).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user