From 4190fbcf90d15f6ed9a7ef41bc8d93701f7a1653 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 11:00:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(graph):=20owner=5Fid=20=E5=AF=AB=E5=85=A5?= =?UTF-8?q?=E9=8F=88=E5=BF=85=E7=B6=93(=E6=A0=B9=E6=B2=BB=E7=84=A1?= =?UTF-8?q?=E4=B8=BB=E8=B3=87=E6=96=99)=E2=80=94=E2=80=94=E9=85=8D?= =?UTF-8?q?=E5=90=88=20base=20owner-mandatory(D28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 必填(附說明),不跨支疊改 --- src/actions/entity-crud.ts | 14 +++++---- src/actions/entity-normalize.ts | 2 +- src/actions/entity-pending.ts | 8 ++--- src/actions/node-persist.ts | 6 ++-- src/actions/triplet-crud.ts | 2 +- src/actions/triplet-extract.ts | 4 +-- src/actions/triplet-ingest.ts | 2 +- src/lib/kbdb-client.ts | 28 ++++++++++++++--- src/routes/entities.ts | 10 ++++-- src/routes/triplets.ts | 10 ++++-- tests/entity.test.ts | 28 ++++++++++++----- tests/graph-source.test.ts | 6 ++-- tests/graph.test.ts | 8 ++--- tests/mock-client.ts | 4 +++ tests/triplet-crud.test.ts | 18 ++++++++--- tests/triplet-ingest.test.ts | 56 +++++++++++++++++++++++++++------ 16 files changed, 152 insertions(+), 54 deletions(-) diff --git a/src/actions/entity-crud.ts b/src/actions/entity-crud.ts index be62471..75e1b1e 100644 --- a/src/actions/entity-crud.ts +++ b/src/actions/entity-crud.ts @@ -13,12 +13,12 @@ const norm = (s: string): string => s.toLowerCase().trim(); // ─── Entity ────────────────────────────────────────────────────────────────── /** 建立 Entity(canonical name)。底層 = 一筆 entity template record。 */ -export async function createEntity(client: KbdbClient, canonical: string, owner?: string): Promise { +export async function createEntity(client: KbdbClient, canonical: string, owner: string): Promise { await ensurePluginTemplates(client); const id = await client.createRecord( TPL_ENTITY, - { canonical, aliases_json: '[]', entity_type: '', owner: owner ?? '' }, - owner, + { canonical, aliases_json: '[]', entity_type: '', owner }, // 真 owner 落 slot(不再 ?? '') + owner, // 必經:createRecord 內 requireOwner 守衛 ); return { id, canonical, aliases: [] }; } @@ -45,21 +45,23 @@ export async function listEntities(client: KbdbClient, limit = 100, owner?: stri * 新增 alias。base 無 PUT /records/:id → 改「重建一筆新 entity record」覆寫(含舊 canonical + 既有 aliases + 新 alias)。 * [→arcrun] base 缺 PUT /records/:id:補上後改為原地 patch aliases_json,省一次重建。 */ -export async function addAlias(client: KbdbClient, entityId: string, alias: string, owner?: string): Promise { +export async function addAlias(client: KbdbClient, entityId: string, alias: string, owner: string): Promise { const rec = await client.getRecord(entityId); if (!rec) throw new Error(`Entity ${entityId} not found`); const ent = recordToEntity(rec); if (ent.aliases.includes(alias)) return; const aliases = [...ent.aliases, alias]; await ensurePluginTemplates(client); + // 重建時沿用原 record 既有 owner;原 record 無主(舊資料 owner=None)才退回 caller 指定的 owner。 + const effectiveOwner = (rec.values.owner || '').trim() || owner; await client.createRecord( TPL_ENTITY, { canonical: ent.canonical, aliases_json: JSON.stringify(aliases), entity_type: rec.values.entity_type ?? '', - owner: rec.values.owner ?? owner ?? '', + owner: effectiveOwner, }, - owner, + effectiveOwner, // 必經:createRecord 內 requireOwner 守衛 ); } diff --git a/src/actions/entity-normalize.ts b/src/actions/entity-normalize.ts index 54b964b..02ec0eb 100644 --- a/src/actions/entity-normalize.ts +++ b/src/actions/entity-normalize.ts @@ -18,7 +18,7 @@ import type { KbdbClient } from '../lib/kbdb-client'; export async function normalizeEntity( client: KbdbClient, rawName: string, - owner?: string, + owner: string, // 必經:未命中會建新 entity,必帶 owner ): Promise { try { const exact = await findEntityByName(client, rawName, owner); diff --git a/src/actions/entity-pending.ts b/src/actions/entity-pending.ts index 23ab412..dd949c1 100644 --- a/src/actions/entity-pending.ts +++ b/src/actions/entity-pending.ts @@ -16,7 +16,7 @@ export async function createPendingAlias( candidateEntityId: string, candidateCanonical: string, similarity: number, - owner?: string, + owner: string, // 必經:pending record 也不可無主 ): Promise { await ensurePluginTemplates(client); const id = await client.createRecord( @@ -27,7 +27,7 @@ export async function createPendingAlias( candidate_canonical: candidateCanonical, similarity: String(similarity), }, - owner, + owner, // 必經:createRecord 內 requireOwner 守衛 ); return { id, @@ -56,14 +56,14 @@ export async function getPendingAliases(client: KbdbClient, limit = 100, owner?: } /** 確認 → addAlias 到候選 entity。pending soft 保留([→arcrun] base 缺 DELETE record)。 */ -export async function confirmPendingAlias(client: KbdbClient, pendingId: string, owner?: string): Promise { +export async function confirmPendingAlias(client: KbdbClient, pendingId: string, owner: string): Promise { const rec = await client.getRecord(pendingId); if (!rec || !rec.values.raw_name) throw new Error(`Pending alias ${pendingId} not found`); await addAlias(client, rec.values.candidate_entity_id, rec.values.raw_name, owner); } /** 拒絕 → 以 raw_name 建新 entity。pending soft 保留([→arcrun] base 缺 DELETE record)。 */ -export async function rejectPendingAlias(client: KbdbClient, pendingId: string, owner?: string): Promise { +export async function rejectPendingAlias(client: KbdbClient, pendingId: string, owner: string): Promise { const rec = await client.getRecord(pendingId); if (!rec || !rec.values.raw_name) throw new Error(`Pending alias ${pendingId} not found`); return createEntity(client, rec.values.raw_name, owner); diff --git a/src/actions/node-persist.ts b/src/actions/node-persist.ts index ef32576..dc8a5e3 100644 --- a/src/actions/node-persist.ts +++ b/src/actions/node-persist.ts @@ -22,7 +22,7 @@ export type IngestNode = { export async function persistNodes( client: KbdbClient, nodes: IngestNode[], - owner_id?: string, + owner_id: string, // 必經:owner 一路從 ingest envelope / route 帶到底,不得掉成空字串 ): Promise { if (!nodes || nodes.length === 0) return; await ensurePluginTemplates(client); @@ -42,9 +42,9 @@ export async function persistNodes( gloss: n.gloss ?? '', // contract 預設 true;只在明確 false 時存標(base 看 'false' 跳過 embed)。 embed: n.embed === false ? 'false' : 'true', - owner: owner_id ?? '', + owner: owner_id, // 真 owner 落 slot(不再 ?? '') }, - owner_id, + owner_id, // createRecord 內 requireOwner 守衛:缺→throw ); } } diff --git a/src/actions/triplet-crud.ts b/src/actions/triplet-crud.ts index da541bc..61110c1 100644 --- a/src/actions/triplet-crud.ts +++ b/src/actions/triplet-crud.ts @@ -13,7 +13,7 @@ export type CreateTripletData = { object: string; source_block_id?: string; confidence?: number; - owner_id?: string; + owner_id: string; // 必經:三元組寫入必帶 owner(createRecord 內 requireOwner 守衛) clusters?: string[]; bridge_score?: number; subject_entity_type?: string; diff --git a/src/actions/triplet-extract.ts b/src/actions/triplet-extract.ts index fd26845..26d0625 100644 --- a/src/actions/triplet-extract.ts +++ b/src/actions/triplet-extract.ts @@ -55,7 +55,7 @@ export async function extractTripletsViaLLM(ai: Ai, chunks: string[]): Promise { // 查重:以 S-P-O 三欄精確比對(queryTriplets 取 template record 後在插件層 filter) const { count } = await queryTriplets(client, { @@ -71,7 +71,7 @@ export async function writeTripletToDb( predicate: t.predicate, object: t.object, confidence: t.confidence ?? 0.8, - owner_id: owner ?? undefined, + owner_id: owner, }); return true; } diff --git a/src/actions/triplet-ingest.ts b/src/actions/triplet-ingest.ts index bba23c8..7ec9d82 100644 --- a/src/actions/triplet-ingest.ts +++ b/src/actions/triplet-ingest.ts @@ -53,7 +53,7 @@ export type IngestResult = { skipped: boolean; ingested: number; deprecated: num export async function ingestEnvelope( client: KbdbClient, env: IngestEnvelope, - owner_id?: string, + owner_id: string, // 必經:owner 從 route 帶入,一路 thread 進 triplet + node 寫入,不得掉成空 ): Promise { await ensurePluginTemplates(client); diff --git a/src/lib/kbdb-client.ts b/src/lib/kbdb-client.ts index 391d790..94cd1e0 100644 --- a/src/lib/kbdb-client.ts +++ b/src/lib/kbdb-client.ts @@ -26,11 +26,29 @@ export type BaseRecord = { export type CreateEntryInput = { content: string | null; entry_type: string; - owner_id?: string; + // owner_id 必經(D27/D28):型別上必填、且執行期缺→throw。不再靜默送空 → 不寫出無主資料。 + owner_id: string; parent_id?: string; page_name?: string; }; +/** + * owner_id 必經守衛(D27/D28,2026-07-05)。 + * 病根:漏 owner → 靜默送 owner:'' → base 寫成 owner=None → mira owner 過濾濾掉 → 使用者查不到 + * (實測回填 16 筆 gloss entry owner=None)。base 即將把 owner 設必填(缺→400);插件端先擋,fail loud。 + * 任何寫入前呼叫此守衛:漏 owner 在插件端就爆、不會送到 base,錯誤訊息指名該從最外層 route/envelope 帶入。 + */ +export function requireOwner(owner_id: string | null | undefined, op: string): string { + const v = (owner_id ?? '').trim(); + if (!v) { + throw new Error( + `[kbdb-graph] ${op} 缺 owner_id:owner 為必經欄位,不可寫出無主資料。` + + `請從最外層(route 參數 / ingest envelope)把真 owner thread 到底。`, + ); + } + return v; +} + /** 基本盤 API client。所有方法 = 一個 HTTP 呼叫,零 SQL。 */ export class KbdbClient { constructor( @@ -70,7 +88,8 @@ export class KbdbClient { // --- entries --- async createEntry(input: CreateEntryInput): Promise { - const { entry } = await this.req<{ entry: BaseEntry }>('POST', '/entries', input); + const owner_id = requireOwner(input.owner_id, 'createEntry'); + const { entry } = await this.req<{ entry: BaseEntry }>('POST', '/entries', { ...input, owner_id }); return entry; } @@ -154,11 +173,12 @@ export class KbdbClient { // --- records(= template 實例,填 slot) --- - async createRecord(template: string, values: Record, owner_id?: string): Promise { + async createRecord(template: string, values: Record, owner_id: string): Promise { + const owner = requireOwner(owner_id, `createRecord(template=${template})`); const { record } = await this.req<{ record: { record_id: string } }>('POST', '/records', { template, values, - owner_id, + owner_id: owner, }); return record.record_id; } diff --git a/src/routes/entities.ts b/src/routes/entities.ts index 53234ad..816c293 100644 --- a/src/routes/entities.ts +++ b/src/routes/entities.ts @@ -54,13 +54,19 @@ entityRoutes.openapi(listPendingRoute, async (c) => { entityRoutes.post('/pending/:id/confirm', async (c) => { const id = c.req.param('id'); - await confirmPendingAlias(makeKbdbClient(c.env), id); + // owner 必經:confirm 會 addAlias(重建 entity record),缺 owner→400 不寫無主資料。 + const owner = c.req.query('owner_id')?.trim(); + if (!owner) return c.json({ error: 'owner_id query parameter required(資料不可無主)' }, 400); + await confirmPendingAlias(makeKbdbClient(c.env), id, owner); return c.json({ success: true, action: 'confirmed', id }); }); entityRoutes.post('/pending/:id/reject', async (c) => { const id = c.req.param('id'); - const newEntity = await rejectPendingAlias(makeKbdbClient(c.env), id); + // owner 必經:reject 會 createEntity,缺 owner→400 不寫無主資料。 + const owner = c.req.query('owner_id')?.trim(); + if (!owner) return c.json({ error: 'owner_id query parameter required(資料不可無主)' }, 400); + const newEntity = await rejectPendingAlias(makeKbdbClient(c.env), id, owner); return c.json({ success: true, action: 'rejected', newEntity }); }); diff --git a/src/routes/triplets.ts b/src/routes/triplets.ts index ecc35f6..12849de 100644 --- a/src/routes/triplets.ts +++ b/src/routes/triplets.ts @@ -77,7 +77,7 @@ const createRouteDefinition = createRoute({ subject: z.string().min(1), predicate: z.string().min(1), object: z.string().min(1), - owner_id: z.string().optional(), + owner_id: z.string().min(1), // 必填:缺→400(資料不可無主) source_block_id: z.string().optional(), confidence: z.number().optional(), clusters: z.array(z.string()).optional(), @@ -103,10 +103,13 @@ const ingestRoute = createRoute({ method: 'post', path: '/ingest', request: { + // owner_id 走 route 參數(envelope 是 .strict() 凍結契約,不塞 owner 進去)。缺→400。 + query: z.object({ owner_id: z.string().optional().describe('資料所有者(必填,缺→400)') }), body: { content: { 'application/json': { schema: IngestEnvelopeSchema } } }, }, responses: { 200: { description: 'Envelope ingested (or skipped if same content_hash)' }, + 400: { description: 'Missing owner_id (資料不可無主)' }, 422: { description: 'Invalid envelope (forbidden field or shape mismatch)' }, }, tags: ['Triplets'], @@ -115,8 +118,11 @@ const ingestRoute = createRoute({ tripletRoutes.openapi( ingestRoute, async (c) => { + // owner 必經:缺→400(不寫出無主 triplet/node;避免再現 owner=None 被 mira 過濾)。 + const owner_id = c.req.query('owner_id')?.trim(); + if (!owner_id) return c.json({ error: 'owner_id query parameter required(資料不可無主)' }, 400); const env = c.req.valid('json'); - const result = await ingestEnvelope(makeKbdbClient(c.env), env); + const result = await ingestEnvelope(makeKbdbClient(c.env), env, owner_id); return c.json(result, 200); }, // strict() 驗證失敗(如送禁止欄位 bridge_score)→ 422,不是預設 400。 diff --git a/tests/entity.test.ts b/tests/entity.test.ts index 028ca1d..041dac0 100644 --- a/tests/entity.test.ts +++ b/tests/entity.test.ts @@ -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('新公司'); }); }); diff --git a/tests/graph-source.test.ts b/tests/graph-source.test.ts index 0685b3a..76bc4eb 100644 --- a/tests/graph-source.test.ts +++ b/tests/graph-source.test.ts @@ -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); diff --git a/tests/graph.test.ts b/tests/graph.test.ts index 7b44933..0b6d3fd 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -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', () => { diff --git a/tests/mock-client.ts b/tests/mock-client.ts index c26d290..0ee4694 100644 --- a/tests/mock-client.ts +++ b/tests/mock-client.ts @@ -15,6 +15,8 @@ export class MockKbdbClient { } async createEntry(input: any): Promise { + // 對齊基本盤 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, owner_id?: string): Promise { + // 對齊基本盤 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; diff --git a/tests/triplet-crud.test.ts b/tests/triplet-crud.test.ts index a70cf8a..cfa0b9c 100644 --- a/tests/triplet-crud.test.ts +++ b/tests/triplet-crud.test.ts @@ -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); }); diff --git a/tests/triplet-ingest.test.ts b/tests/triplet-ingest.test.ts index 399547a..bbfacf5 100644 --- a/tests/triplet-ingest.test.ts +++ b/tests/triplet-ingest.test.ts @@ -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 });