Files
Arcrun/kbdb/tests/bulk-write.test.ts
T
Claude 7d5cd06ff5 feat(kbdb): bulk 寫入端點(缺口1)+owner_id 寫入必填——D28 keystone
- POST /entries/bulk、/records/bulk:任意N筆約3 subrequest(批量冪等查+db.batch()+hydrate),解 Too many subrequests
- 部分失敗不整批炸(逐筆驗證剔除+batch錯退化逐句隔離);page_name 冪等
- embedManyOnWrite 批次嵌(單次AI.run+upsert+UPDATE IN)
- owner_id 缺→400(validateEntryInput,擋在HTTP路由層;內部無主路徑recipe-stat/value entries不受傷)
- Phase 13 tasks;tsc 0、vitest 18/18
2026-07-05 10:39:51 +00:00

261 lines
11 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { bulkCreateEntries, validateEntryInput } from '../src/actions/entry-crud';
import { bulkCreateRecords } from '../src/actions/record-crud';
import { entryRoutes } from '../src/routes/entries';
import { recordRoutes } from '../src/routes/records';
import type { Bindings } from '../src/types';
// ── In-memory fake D1 ────────────────────────────────────────────────────────
// Interprets only the statement shapes the bulk/CRUD paths issue. Backed by real
// Maps so idempotency (page_name), batch writes, and IN(...) hydration behave for real.
interface Row { [k: string]: unknown }
function collapse(sql: string): string {
return sql.replace(/\s+/g, ' ').trim();
}
class FakeDB {
entries = new Map<string, Row>();
entryValues: Row[] = [];
templates = new Map<string, Row>(); // keyed by id; name lookup scans
seedTemplate(t: { id: string; name: string; slots: string[] }) {
this.templates.set(t.id, { id: t.id, name: t.name, slots_json: JSON.stringify(t.slots), description: null, created_by: 'system' });
}
prepare(sql: string) {
const db = this;
let bound: unknown[] = [];
const stmt = {
bind(...args: unknown[]) { bound = args; return stmt; },
async run() { db._exec(sql, bound); return { success: true, meta: {} }; },
async all<T>() { return { results: db._query(sql, bound) as T[], success: true, meta: {} }; },
async first<T>() { const r = db._query(sql, bound); return (r[0] ?? null) as T; },
};
return stmt;
}
async batch(stmts: { run: () => Promise<unknown> }[]) {
const out: unknown[] = [];
for (const s of stmts) out.push(await s.run());
return out;
}
_exec(rawSql: string, bound: unknown[]) {
const sql = collapse(rawSql);
let m = sql.match(/^INSERT INTO (\w+) \(([^)]*)\) VALUES/i);
if (m) {
const table = m[1];
const cols = m[2].split(',').map((s) => s.trim());
const row: Row = {};
cols.forEach((c, i) => { row[c] = bound[i]; });
if (table === 'entries') {
this.entries.set(row.id as string, {
content: null, entry_type: null, owner_id: null, parent_id: null, page_name: null,
refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, is_embedded: 0,
confidence: null, metadata_json: null, created_at: 1, updated_at: 1, ...row,
});
} else if (table === 'entry_values') {
this.entryValues.push(row);
}
return;
}
m = sql.match(/^UPDATE entries SET (.*) WHERE id = \?$/i);
if (m) {
const setClause = m[1];
const assigns = setClause.split(',').map((s) => s.trim());
const id = bound[bound.length - 1] as string;
const row = this.entries.get(id);
let bi = 0;
for (const a of assigns) {
const col = a.split('=')[0].trim();
if (/=\s*unixepoch\(\)/i.test(a)) {
if (row) row[col] = 2;
} else {
if (row) row[col] = bound[bi];
bi++;
}
}
return;
}
// ignore anything else in exec
}
_query(rawSql: string, bound: unknown[]): Row[] {
const sql = collapse(rawSql);
if (/FROM templates/i.test(sql)) {
const all = [...this.templates.values()];
if (/IN \(/i.test(sql)) {
const keys = new Set(bound.map(String));
return all.filter((t) => keys.has(String(t.id)) || keys.has(String(t.name)));
}
// getTemplate: WHERE id = ? OR name = ? LIMIT 1
const key = String(bound[0]);
return all.filter((t) => String(t.id) === key || String(t.name) === key).slice(0, 1);
}
if (/FROM entries/i.test(sql)) {
const all = [...this.entries.values()];
if (/SELECT id, page_name/i.test(sql)) {
const set = new Set(bound.map(String));
return all.filter((e) => e.page_name != null && set.has(String(e.page_name))).map((e) => ({ id: e.id, page_name: e.page_name }));
}
if (/WHERE id IN \(/i.test(sql)) {
const set = new Set(bound.map(String));
return all.filter((e) => set.has(String(e.id)));
}
if (/WHERE id = \?/i.test(sql)) {
return all.filter((e) => String(e.id) === String(bound[0]));
}
}
return [];
}
}
function makeEnv(db: FakeDB): Bindings {
return { DB: asDb(db), ENVIRONMENT: 'test' } as Bindings;
}
const asDb = (db: FakeDB): D1Database => db as unknown as D1Database;
// ── validateEntryInput ───────────────────────────────────────────────────────
describe('validateEntryInput (owner_id 機制強制)', () => {
it('rejects missing owner_id', () => {
expect(validateEntryInput({ entry_type: 'block' })).toMatch(/owner_id required/);
});
it('rejects missing entry_type', () => {
expect(validateEntryInput({ owner_id: 'leo' })).toBe('entry_type required');
});
it('rejects non-object', () => {
expect(validateEntryInput(null)).toMatch(/must be an object/);
});
it('accepts a complete entry', () => {
expect(validateEntryInput({ entry_type: 'block', owner_id: 'leo' })).toBeNull();
});
});
// ── bulkCreateEntries ────────────────────────────────────────────────────────
describe('bulkCreateEntries', () => {
it('creates many entries in one shot; partial failure does not abort the batch', async () => {
const db = new FakeDB();
const r = await bulkCreateEntries(asDb(db), [
{ entry_type: 'block', owner_id: 'leo', content: 'a' },
{ entry_type: 'block', owner_id: 'leo', content: 'b' },
{ entry_type: 'block', content: 'no-owner' } as never, // missing owner_id → failed
]);
expect(r.created).toBe(2);
expect(r.failed).toBe(1);
expect(r.results[2].status).toBe('failed');
expect(r.results[2].error).toMatch(/owner_id required/);
expect(r.entries.length).toBe(2);
expect(db.entries.size).toBe(2); // the bad one was never written
});
it('is idempotent by page_name: same key re-entry updates, does not duplicate', async () => {
const db = new FakeDB();
const first = await bulkCreateEntries(asDb(db), [
{ entry_type: 'agent-skill', owner_id: 'registry', page_name: 'skill-x', content: 'v1' },
]);
expect(first.created).toBe(1);
expect(db.entries.size).toBe(1);
const second = await bulkCreateEntries(asDb(db), [
{ entry_type: 'agent-skill', owner_id: 'registry', page_name: 'skill-x', content: 'v2' },
]);
expect(second.updated).toBe(1);
expect(second.created).toBe(0);
expect(db.entries.size).toBe(1); // no duplicate row
const row = [...db.entries.values()][0];
expect(row.content).toBe('v2'); // content flipped to the new value
});
it('accepts an empty array (no-op)', async () => {
const db = new FakeDB();
const r = await bulkCreateEntries(asDb(db), []);
expect(r).toMatchObject({ created: 0, updated: 0, failed: 0 });
});
});
// ── bulkCreateRecords ────────────────────────────────────────────────────────
describe('bulkCreateRecords', () => {
it('creates records via a template; owner_id missing → that record fails', async () => {
const db = new FakeDB();
db.seedTemplate({ id: 'tpl-1', name: 'triplet', slots: ['subject', 'predicate', 'object'] });
const r = await bulkCreateRecords(asDb(db), [
{ template: 'triplet', owner_id: 'leo', values: { subject: 'cat', predicate: 'is', object: 'animal' } },
{ template: 'triplet', owner_id: 'leo', values: { subject: 'dog', predicate: 'is', object: 'animal' } },
{ template: 'triplet', values: { subject: 'x', predicate: 'y', object: 'z' } } as never, // no owner
]);
expect(r.created).toBe(2);
expect(r.failed).toBe(1);
expect(r.records.length).toBe(2);
// each created record wrote 3 value entries + 3 entry_values
expect(db.entryValues.length).toBe(6);
expect(db.entries.size).toBe(6);
// value entries carry the record owner (tenant isolation)
for (const e of db.entries.values()) expect(e.owner_id).toBe('leo');
});
it('unknown template → failed, not thrown', async () => {
const db = new FakeDB();
const r = await bulkCreateRecords(asDb(db), [{ template: 'nope', owner_id: 'leo', values: { a: 'b' } }]);
expect(r.failed).toBe(1);
expect(r.results[0].error).toMatch(/template not found/);
});
});
// ── Route-level owner_id enforcement (D27/D28) ───────────────────────────────
describe('POST /entries owner_id enforcement (route 層擋死)', () => {
const app = new Hono<{ Bindings: Bindings }>();
app.route('/entries', entryRoutes);
app.route('/records', recordRoutes);
it('POST /entries without owner_id → 400', async () => {
const db = new FakeDB();
const res = await app.request('/entries', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entry_type: 'block', content: 'x' }),
}, makeEnv(db));
expect(res.status).toBe(400);
expect((await res.json() as { error: string }).error).toMatch(/owner_id required/);
expect(db.entries.size).toBe(0);
});
it('POST /entries with owner_id → 200 created', async () => {
const db = new FakeDB();
const res = await app.request('/entries', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entry_type: 'block', owner_id: 'leo', content: 'x' }),
}, makeEnv(db));
expect(res.status).toBe(200);
expect((await res.json() as { success: boolean }).success).toBe(true);
expect(db.entries.size).toBe(1);
});
it('POST /entries/bulk mixed → 200 with per-item breakdown', async () => {
const db = new FakeDB();
const res = await app.request('/entries/bulk', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entries: [
{ entry_type: 'block', owner_id: 'leo', content: 'a' },
{ entry_type: 'block', content: 'b' }, // no owner
] }),
}, makeEnv(db));
expect(res.status).toBe(200);
const body = await res.json() as { created: number; failed: number };
expect(body.created).toBe(1);
expect(body.failed).toBe(1);
});
it('POST /records without owner_id → 400', async () => {
const db = new FakeDB();
db.seedTemplate({ id: 'tpl-1', name: 'triplet', slots: ['s'] });
const res = await app.request('/records', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template: 'triplet', values: { s: 'x' } }),
}, makeEnv(db));
expect(res.status).toBe(400);
expect((await res.json() as { error: string }).error).toMatch(/owner_id required/);
});
});