feat(graph): owner_id 寫入鏈必經(根治無主資料)——配合 base owner-mandatory(D28)
- kbdb-client requireOwner 守衛:漏 owner 插件端即 throw,不再靜默送 owner:'' - owner 一路 thread:persistNodes/ingestEnvelope/entity-crud/triplet-crud 全必填 - 病灶修:POST /triplets/ingest 原本沒傳 owner→加 owner_id query+400 - 寫入 route 缺 owner→400;vitest 23→27 - 註:gloss-bridge 分支的 backfill/gloss-entry 另需套 owner 必填(附說明),不跨支疊改
This commit is contained in:
+20
-8
@@ -5,17 +5,29 @@ import { normalizeEntity } from '../src/actions/entity-normalize';
|
||||
import { mockClient } from './mock-client';
|
||||
|
||||
describe('entity-crud', () => {
|
||||
it('建立後可 exact 查回(大小寫不敏感)', async () => {
|
||||
it('建立後可 exact 查回(大小寫不敏感),owner slot 帶真 owner', async () => {
|
||||
const c = mockClient();
|
||||
await createEntity(c, 'InkStone');
|
||||
const found = await findEntityByName(c, 'inkstone');
|
||||
const ent = await createEntity(c, 'InkStone', 'leo');
|
||||
const found = await findEntityByName(c, 'inkstone', 'leo');
|
||||
expect(found?.canonical).toBe('InkStone');
|
||||
// owner 落底:record 掛在 leo 名下(非無主),用錯 owner 查不到。
|
||||
const rec = await c.getRecord(ent.id);
|
||||
expect(rec?.values.owner).toBe('leo');
|
||||
expect(await findEntityByName(c, 'inkstone', 'someone-else')).toBeNull();
|
||||
});
|
||||
|
||||
it('漏 owner → 插件端 fail(不寫出無主 entity)', async () => {
|
||||
const c = mockClient();
|
||||
await expect(
|
||||
// @ts-expect-error 蓄意漏 owner:型別上 owner 必填,執行期也應 throw
|
||||
createEntity(c, 'NoOwner'),
|
||||
).rejects.toThrow(/owner/i);
|
||||
});
|
||||
|
||||
it('listEntities 列出', async () => {
|
||||
const c = mockClient();
|
||||
await createEntity(c, 'A');
|
||||
await createEntity(c, 'B');
|
||||
await createEntity(c, 'A', 'leo');
|
||||
await createEntity(c, 'B', 'leo');
|
||||
const all = await listEntities(c);
|
||||
expect(all.map((e) => e.canonical).sort()).toEqual(['A', 'B']);
|
||||
});
|
||||
@@ -24,8 +36,8 @@ describe('entity-crud', () => {
|
||||
describe('normalizeEntity', () => {
|
||||
it('已存在回 canonical,不存在建新回原值', async () => {
|
||||
const c = mockClient();
|
||||
await createEntity(c, 'InkStone');
|
||||
expect(await normalizeEntity(c, 'INKSTONE')).toBe('InkStone');
|
||||
expect(await normalizeEntity(c, '新公司')).toBe('新公司');
|
||||
await createEntity(c, 'InkStone', 'leo');
|
||||
expect(await normalizeEntity(c, 'INKSTONE', 'leo')).toBe('InkStone');
|
||||
expect(await normalizeEntity(c, '新公司', 'leo')).toBe('新公司');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('getSource — 回節點的原文來源指標', () => {
|
||||
source: { uri: 'github:u/w@a.md', content_hash: 'h1', anchor: '#graph-rag' },
|
||||
extractor: { model: 'm', tier: 'deep' },
|
||||
triplets: [{ subject: 'GraphRAG', predicate: '是', object: 'RAG 變體' }],
|
||||
});
|
||||
}, 'leo');
|
||||
|
||||
const refs = await getSource(c, 'GraphRAG');
|
||||
expect(refs.length).toBe(1);
|
||||
@@ -27,12 +27,12 @@ describe('getSource — 回節點的原文來源指標', () => {
|
||||
source: { uri: 'github:u/w@a.md', content_hash: 'h1', anchor: '#old' },
|
||||
extractor: { model: 'm', tier: 'deep' },
|
||||
triplets: [{ subject: 'X', predicate: 'r', object: 'old' }],
|
||||
});
|
||||
}, 'leo');
|
||||
await ingestEnvelope(c, {
|
||||
source: { uri: 'github:u/w@a.md', content_hash: 'h2', anchor: '#new' },
|
||||
extractor: { model: 'm', tier: 'deep' },
|
||||
triplets: [{ subject: 'X', predicate: 'r', object: 'new' }],
|
||||
});
|
||||
}, 'leo');
|
||||
|
||||
const refs = await getSource(c, 'X');
|
||||
expect(refs.length).toBe(1);
|
||||
|
||||
+4
-4
@@ -7,10 +7,10 @@ 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' });
|
||||
// A — B — C,D 孤立連 A(owner 必經:所有寫入帶真 owner)
|
||||
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'B', owner_id: 'leo' });
|
||||
await createTriplet(c, { subject: 'B', predicate: 'r', object: 'C', owner_id: 'leo' });
|
||||
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'D', owner_id: 'leo' });
|
||||
}
|
||||
|
||||
describe('graph-nodes', () => {
|
||||
|
||||
@@ -15,6 +15,8 @@ export class MockKbdbClient {
|
||||
}
|
||||
|
||||
async createEntry(input: any): Promise<BaseEntry> {
|
||||
// 對齊基本盤 D27/D28 即將上線的 owner 必填:缺 owner → 400(模擬 base 擋死無主寫入)。
|
||||
if (!(input?.owner_id ?? '').trim()) throw new Error('[kbdb-base] POST /entries: owner_id required (400)');
|
||||
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);
|
||||
@@ -61,6 +63,8 @@ export class MockKbdbClient {
|
||||
}
|
||||
|
||||
async createRecord(template: string, values: Record<string, string>, owner_id?: string): Promise<string> {
|
||||
// 對齊基本盤 D27/D28 即將上線的 owner 必填:缺 owner → 400(模擬 base 擋死無主寫入)。
|
||||
if (!(owner_id ?? '').trim()) throw new Error('[kbdb-base] POST /records: owner_id required (400)');
|
||||
const id = this.id('rec');
|
||||
this.records.set(id, { template, values: { ...values }, owner_id });
|
||||
return id;
|
||||
|
||||
@@ -6,7 +6,7 @@ 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' });
|
||||
const r = await createTriplet(c, { subject: 'InkStone', predicate: '是', object: '創業 OS', owner_id: 'leo' });
|
||||
expect(r.id).toBeDefined();
|
||||
expect(r.subject).toBe('InkStone');
|
||||
|
||||
@@ -14,13 +14,23 @@ describe('createTriplet → records API', () => {
|
||||
expect(got?.predicate).toBe('是');
|
||||
expect(got?.object).toBe('創業 OS');
|
||||
});
|
||||
|
||||
it('漏 owner → 插件端 fail(不靜默送空,不寫出無主 triplet)', async () => {
|
||||
const c = mockClient();
|
||||
await expect(
|
||||
// @ts-expect-error 蓄意漏 owner:型別上 owner 必填,執行期也應 throw
|
||||
createTriplet(c, { subject: 'X', predicate: 'p', object: 'Y' }),
|
||||
).rejects.toThrow(/owner/i);
|
||||
const { count } = await queryTriplets(c, { includeDeprecated: true });
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
await createTriplet(c, { subject: 'KBDB', predicate: '使用', object: 'D1', owner_id: 'leo' });
|
||||
await createTriplet(c, { subject: 'Other', predicate: '使用', object: 'X', owner_id: 'leo' });
|
||||
|
||||
const { triplets, count } = await queryTriplets(c, { subject: 'KBDB' });
|
||||
expect(count).toBe(1);
|
||||
@@ -29,7 +39,7 @@ describe('queryTriplets → 插件層 filter', () => {
|
||||
|
||||
it('limit/offset 分頁', async () => {
|
||||
const c = mockClient();
|
||||
for (let i = 0; i < 5; i++) await createTriplet(c, { subject: `s${i}`, predicate: 'p', object: 'o' });
|
||||
for (let i = 0; i < 5; i++) await createTriplet(c, { subject: `s${i}`, predicate: 'p', object: 'o', owner_id: 'leo' });
|
||||
const { triplets } = await queryTriplets(c, { limit: 2, offset: 1 });
|
||||
expect(triplets.length).toBe(2);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// ingest 寫入端 — 走 mock KbdbClient(API-as-Wall),零 SQL、不打網路。
|
||||
// 覆蓋 T3.4 五案:正常 envelope / 同 hash no-op / 新 hash deprecate / 污染 envelope 422 / rollback。
|
||||
// + owner 必經(D27/D28):漏 owner→插件端 fail;真 owner 一路 thread 進 triplet + node 寫入。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ingestEnvelope, IngestEnvelopeSchema, type IngestEnvelope } from '../src/actions/triplet-ingest';
|
||||
import { queryTriplets } from '../src/actions/triplet-crud';
|
||||
import { mockClient } from './mock-client';
|
||||
|
||||
const OWNER = 'leo';
|
||||
|
||||
function envelope(hash: string, triplets: IngestEnvelope['triplets']): IngestEnvelope {
|
||||
return {
|
||||
source: { uri: 'github:uncle6me-web/wiki@a.md', content_hash: hash },
|
||||
@@ -19,7 +22,7 @@ describe('ingestEnvelope — 正常 envelope', () => {
|
||||
const res = await ingestEnvelope(c, envelope('h1', [
|
||||
{ subject: 'A', predicate: 'rel', object: 'B' },
|
||||
{ subject: 'B', predicate: 'rel', object: 'C' },
|
||||
]));
|
||||
]), OWNER);
|
||||
expect(res).toEqual({ skipped: false, ingested: 2, deprecated: 0 });
|
||||
|
||||
const { triplets } = await queryTriplets(c, {});
|
||||
@@ -30,11 +33,46 @@ describe('ingestEnvelope — 正常 envelope', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ingestEnvelope — owner 必經(D27/D28)', () => {
|
||||
it('漏 owner → 插件端 fail(不靜默送空,不寫出無主 triplet)', async () => {
|
||||
const c = mockClient();
|
||||
await expect(
|
||||
// @ts-expect-error 蓄意漏 owner:型別上 owner 必填,執行期也應 throw
|
||||
ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }])),
|
||||
).rejects.toThrow(/owner/i);
|
||||
// fail loud:一筆都不該寫進去(第一筆寫入即擋)。
|
||||
const { triplets } = await queryTriplets(c, {});
|
||||
expect(triplets.length).toBe(0);
|
||||
});
|
||||
|
||||
it('真 owner 一路 thread 到 triplet + node 寫入(owner slot 非空、record owner_id=真 owner)', async () => {
|
||||
const c = mockClient();
|
||||
const env: IngestEnvelope = {
|
||||
source: { uri: 'github:uncle6me-web/wiki@own.md', content_hash: 'ho' },
|
||||
extractor: { model: 'm', tier: 'deep' },
|
||||
nodes: [{ name: 'Graph RAG', id: 'graph-rag.md', gloss: '關係遍歷檢索' }],
|
||||
triplets: [{ subject: 'Graph RAG', predicate: 'r', object: 'X' }],
|
||||
};
|
||||
await ingestEnvelope(c, env, OWNER);
|
||||
|
||||
// triplet record 帶真 owner。
|
||||
const triplets = await c.listRecordsByTemplate('triplet', OWNER);
|
||||
expect(triplets.length).toBe(1);
|
||||
// node → entity record 帶真 owner(owner slot + record owner_id 皆為 leo,非 None/空)。
|
||||
const entities = await c.listRecordsByTemplate('entity', OWNER);
|
||||
const gr = entities.find((e) => e.values.canonical === 'Graph RAG');
|
||||
expect(gr).toBeDefined();
|
||||
expect(gr!.values.owner).toBe(OWNER);
|
||||
// 用錯 owner 查 → 濾掉(證明 record 確實掛在 leo 名下,非無主)。
|
||||
expect((await c.listRecordsByTemplate('entity', 'someone-else')).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ingestEnvelope — 同 hash no-op', () => {
|
||||
it('同 uri+hash 再送 → skipped,不新增', async () => {
|
||||
const c = mockClient();
|
||||
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }]));
|
||||
const res = await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }]));
|
||||
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }]), OWNER);
|
||||
const res = await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }]), OWNER);
|
||||
expect(res.skipped).toBe(true);
|
||||
|
||||
const { triplets } = await queryTriplets(c, {});
|
||||
@@ -45,8 +83,8 @@ describe('ingestEnvelope — 同 hash no-op', () => {
|
||||
describe('ingestEnvelope — 新 hash deprecate-then-append', () => {
|
||||
it('同 uri 新 hash → 舊批轉 deprecated、新批 active;查詢 active-only', async () => {
|
||||
const c = mockClient();
|
||||
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]));
|
||||
const res = await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]));
|
||||
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]), OWNER);
|
||||
const res = await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]), OWNER);
|
||||
expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 1 });
|
||||
|
||||
// active-only 查詢只見新批。
|
||||
@@ -103,7 +141,7 @@ describe('ingestEnvelope — 向量化打標欄位(contract 升格,ingest#1
|
||||
expect(IngestEnvelopeSchema.safeParse(env).success).toBe(true);
|
||||
|
||||
// 落地:triplet 寫入、node 打標存進 entity slot。
|
||||
const res = await ingestEnvelope(c, env);
|
||||
const res = await ingestEnvelope(c, env, OWNER);
|
||||
expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 0 });
|
||||
|
||||
const { triplets } = await queryTriplets(c, {});
|
||||
@@ -131,7 +169,7 @@ describe('ingestEnvelope — 向量化打標欄位(contract 升格,ingest#1
|
||||
],
|
||||
triplets: [{ subject: 'Graph RAG', predicate: 'r', object: 'X' }],
|
||||
};
|
||||
await ingestEnvelope(c, env);
|
||||
await ingestEnvelope(c, env, OWNER);
|
||||
const entities = await c.listRecordsByTemplate('entity');
|
||||
expect(entities.filter((e) => e.values.node_id === 'graph-rag.md').length).toBe(1);
|
||||
});
|
||||
@@ -160,8 +198,8 @@ describe('ingestEnvelope — 向量化打標欄位(contract 升格,ingest#1
|
||||
describe('ingestEnvelope — rollback(翻回 status)', () => {
|
||||
it('把 deprecated 翻回 active 後,active 查詢重新見到它', async () => {
|
||||
const c = mockClient();
|
||||
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]));
|
||||
await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]));
|
||||
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]), OWNER);
|
||||
await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]), OWNER);
|
||||
|
||||
// 取出被 deprecate 的舊批 id,手動 rollback(翻回 active、清 superseded_by)。
|
||||
const all = await queryTriplets(c, { includeDeprecated: true });
|
||||
|
||||
Reference in New Issue
Block a user