Files
Arcrun/kbdb/tests/library-filter.test.ts
T
uncle6me-web ceb7638d74 feat(kbdb): 樹狀 record 模型第一刀——record 有身分、關係是唯一機制、entry_values 拆表(v7 定稿實作)
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。
模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」

- 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數
  (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+
  每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列
  (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values
  (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。
- record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留,
  驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。
- entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry
  接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。
- 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列,
  LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀),
  GET /maintenance/relation-orphans 唯讀巡檢。
- cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS,
  整檔送 /query 會在重跑時假紅)。
- 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描;
  釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。

遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型
與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:34:48 +08:00

190 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// portal-auth P1 — 「庫」filter 地基(design §3.2/§3.3Gitea #24/#25)。
// 覆蓋:D1 filter SQL 形狀(單值/多值/NULL→general fallback 謂詞)、route 參數解析(含向後相容:
// 不帶 library SQL 一字不變)、semantic 路徑 Vectorize $in filtermock VECTORIZE)、
// embed 寫入端 library metadata 正規化(未標記→'general')。
// D1 真實 SQL 語意(COALESCE/json_extract 實際執行)由本機 miniflare + wrangler d1 驗證(PR 驗收證據)。
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { entryRoutes } from '../src/routes/entries';
import { listEntries, searchEntries } from '../src/actions/entry-crud';
import { semanticSearch, embedOnWrite } from '../src/embed';
import type { Bindings, Entry } from '../src/types';
const LIB_PREDICATE = "COALESCE(json_extract(metadata_json, '$.library'), 'general') IN";
// ── fake D1:只捕捉 prepared SQL 與 bound params(不解讀語意——真語意交給 miniflare 實跑)──
interface Captured { sql: string; params: unknown[] }
function makeCaptureDB(captured: Captured[]) {
const prepare = (sql: string) => {
const rec: Captured = { sql, params: [] };
captured.push(rec);
const stmt = {
bind(...args: unknown[]) { rec.params = args; return stmt; },
async all<T>() { return { results: [] as T[] }; },
async first<T>() { return { total: 0, c: 0 } as unknown as T; },
async run() { return { success: true }; },
};
return stmt;
};
return { prepare } as unknown as D1Database;
}
function mkEntry(id: string, metadata_json: string | null): Entry {
return {
id, content: 'some content', entry_type: 'block', owner_id: 'tenant1', parent_id: null,
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 0, confidence: null, metadata_json, src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
};
}
describe('D1 library filter — SQL 形狀(entry-crud', () => {
it('listEntries 帶 library 多值 → COALESCE…IN (?,?) 謂詞+參數', async () => {
const captured: Captured[] = [];
await listEntries(makeCaptureDB(captured), { library: ['finance', 'hr'] });
const select = captured.find((c) => c.sql.startsWith('SELECT *'))!;
expect(select.sql).toContain(`${LIB_PREDICATE} (?,?)`);
expect(select.params.slice(0, 2)).toEqual(['finance', 'hr']);
// COUNT 查詢同謂詞(total 與分頁一致)
const count = captured.find((c) => c.sql.includes('COUNT(*)'))!;
expect(count.sql).toContain(`${LIB_PREDICATE} (?,?)`);
});
it('listEntries 不帶 library → SQL 無庫謂詞(向後相容:行為一字不變)', async () => {
const captured: Captured[] = [];
await listEntries(makeCaptureDB(captured), { owner_id: 'tenant1' });
for (const c of captured) expect(c.sql).not.toContain('$.library');
});
it('searchEntries 帶 library → LIKE+庫謂詞;不帶 → 原樣', async () => {
const withLib: Captured[] = [];
await searchEntries(makeCaptureDB(withLib), '遷移', 'tenant1', undefined, undefined, ['general']);
expect(withLib[0].sql).toContain('content LIKE ?');
expect(withLib[0].sql).toContain(`${LIB_PREDICATE} (?)`);
expect(withLib[0].params).toContain('general');
const without: Captured[] = [];
await searchEntries(makeCaptureDB(without), '遷移', 'tenant1');
expect(without[0].sql).not.toContain('$.library');
});
});
describe('route 參數解析(GET /entries、/entries/search', () => {
function makeApp(captured: Captured[]) {
const app = new Hono<{ Bindings: Bindings }>();
app.route('/entries', entryRoutes);
const env = { DB: makeCaptureDB(captured), ENVIRONMENT: 'test' } as unknown as Bindings;
return { app, env };
}
it('GET /entries?library=finance,hr →(含空白容忍)庫謂詞+兩參數', async () => {
const captured: Captured[] = [];
const { app, env } = makeApp(captured);
const res = await app.request('/entries?library=finance,%20hr', {}, env);
expect(res.status).toBe(200);
const select = captured.find((c) => c.sql.startsWith('SELECT *'))!;
expect(select.sql).toContain(`${LIB_PREDICATE} (?,?)`);
expect(select.params).toContain('finance');
expect(select.params).toContain('hr');
});
it('GET /entries 不帶 library / library=空 → SQL 無庫謂詞(向後相容)', async () => {
for (const qs of ['', '?library=', '?library=%20,%20']) {
const captured: Captured[] = [];
const { app, env } = makeApp(captured);
const res = await app.request(`/entries${qs}`, {}, env);
expect(res.status).toBe(200);
for (const c of captured) expect(c.sql).not.toContain('$.library');
}
});
it('GET /entries/search?q=x&library=financekeyword 模式)→ 庫謂詞下到 searchEntries', async () => {
const captured: Captured[] = [];
const { app, env } = makeApp(captured);
const res = await app.request('/entries/search?q=x&library=finance', {}, env);
expect(res.status).toBe(200);
expect(captured[0].sql).toContain(`${LIB_PREDICATE} (?)`);
expect(captured[0].params).toContain('finance');
});
it('semantic 模組未開+帶 library → 誠實降級 keyword 仍套庫 filter(不因降級洩庫)', async () => {
const captured: Captured[] = [];
const { app, env } = makeApp(captured); // 無 VECTORIZE/AI binding → semanticSearch 回 null
const res = await app.request('/entries/search?q=x&mode=semantic&library=finance', {}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { mode: string; requested_mode?: string };
expect(body.mode).toBe('keyword');
expect(body.requested_mode).toBe('semantic');
expect(captured[0].sql).toContain(`${LIB_PREDICATE} (?)`);
expect(captured[0].params).toContain('finance');
});
});
describe('semantic 路徑 — Vectorize $in filtermock VECTORIZE', () => {
function makeSemanticEnv(queryCalls: { vec: number[]; opts: Record<string, unknown> }[]) {
return {
DB: makeCaptureDB([]),
ENVIRONMENT: 'test',
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
VECTORIZE: {
async query(vec: number[], opts: Record<string, unknown>) {
queryCalls.push({ vec, opts });
return { matches: [{ id: 'e1', score: 0.9, metadata: { library: 'finance' } }] };
},
async upsert(v: unknown[]) { return { count: (v as unknown[]).length }; },
},
} as unknown as Bindings;
}
it('帶 library 多值 → filter.library = { $in: [...] }(主路徑,不 fan-out', async () => {
const calls: { vec: number[]; opts: Record<string, unknown> }[] = [];
const env = makeSemanticEnv(calls);
const hits = await semanticSearch(env, 'query', { owner_id: 'tenant1', library: ['finance', 'hr'] });
expect(calls.length).toBe(1); // 單次 query(非每庫 fan-out
const filter = calls[0].opts.filter as Record<string, unknown>;
expect(filter.owner_id).toBe('tenant1');
expect(filter.library).toEqual({ $in: ['finance', 'hr'] });
expect(hits?.[0]?.library).toBe('finance'); // hit 帶回 library metadata
});
it('不帶 library → filter 無 library 鍵(行為不變)', async () => {
const calls: { vec: number[]; opts: Record<string, unknown> }[] = [];
const env = makeSemanticEnv(calls);
await semanticSearch(env, 'query', { owner_id: 'tenant1' });
const filter = calls[0].opts.filter as Record<string, unknown>;
expect('library' in filter).toBe(false);
});
});
describe('embed 寫入端 — library metadata 正規化', () => {
function makeUpsertEnv(upserts: { id: string; metadata: Record<string, unknown> }[]) {
const db = {
prepare: () => {
const stmt = { bind: () => stmt, run: async () => ({ success: true }), all: async () => ({ results: [] }), first: async () => null };
return stmt;
},
} as unknown as D1Database;
return {
DB: db,
ENVIRONMENT: 'test',
AI: { async run(_m: string, i: { text: string[] }) { return { data: i.text.map(() => [0.1, 0.2]) }; } },
VECTORIZE: { async upsert(v: { id: string; metadata: Record<string, unknown> }[]) { upserts.push(...v); return { count: v.length }; } },
} as unknown as Bindings;
}
it('metadata_json 有 library → upsert metadata.library 原值', async () => {
const upserts: { id: string; metadata: Record<string, unknown> }[] = [];
const env = makeUpsertEnv(upserts);
await embedOnWrite(env, mkEntry('e1', JSON.stringify({ embed: true, library: 'finance' })));
expect(upserts[0].metadata.library).toBe('finance');
});
it('未標記 / 空字串 / 非字串 → 正規化為 generaldesign §3.2 舊資料歸 general', async () => {
for (const meta of [{ embed: true }, { embed: true, library: '' }, { embed: true, library: 42 }]) {
const upserts: { id: string; metadata: Record<string, unknown> }[] = [];
const env = makeUpsertEnv(upserts);
await embedOnWrite(env, mkEntry('e1', JSON.stringify(meta)));
expect(upserts[0].metadata.library).toBe('general');
}
});
});