From e57bbb2727d59c683ed145983455b444b94c6cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 13 Apr 2026 02:12:24 +0000 Subject: [PATCH] feat(api): add type_name to events + /schema endpoint (#15) --- packages/engine/src/engine.ts | 61 +++++++++++++++++++- packages/engine/src/index.test.ts | 93 ++++++++++++++++++++++++++++++- packages/engine/src/index.ts | 14 ++++- packages/engine/src/types.ts | 1 + 4 files changed, 161 insertions(+), 8 deletions(-) diff --git a/packages/engine/src/engine.ts b/packages/engine/src/engine.ts index ec30acc..98cf1ce 100644 --- a/packages/engine/src/engine.ts +++ b/packages/engine/src/engine.ts @@ -307,7 +307,7 @@ export async function createEvent( .run() } - const event: Event = { id, type_hash: typeHash, payload, created_at: createdAt } + const event: Event = { id, type_hash: typeHash, type_name: typeName, payload, created_at: createdAt } const { fired: reactionsFired, payloads: reactionResults } = await triggerReactionChain(db, event) return { event, reactions_fired: reactionsFired, reaction_results: reactionResults } @@ -319,7 +319,20 @@ export async function getEvent(db: D1Database, id: number): Promise() if (!row) return null - return { ...row, payload: JSON.parse(row.payload) } + + // Resolve type_name from event_def_names + const nameRow = await db + .prepare('SELECT name FROM event_def_names WHERE current_hash = ?') + .bind(row.type_hash) + .first<{ name: string }>() + + return { + id: row.id, + type_hash: row.type_hash, + type_name: nameRow?.name || undefined, + payload: JSON.parse(row.payload), + created_at: row.created_at, + } } export async function findEventsByRef( @@ -359,7 +372,25 @@ export async function findEventsByRef( const total = countResult?.count || 0 const rows = await dataQuery.all<{ id: number; type_hash: string; payload: string; created_at: number }>() - const events = (rows.results || []).map((row) => ({ ...row, payload: JSON.parse(row.payload) })) + + // Resolve type_names for all unique type_hashes + const typeHashes = [...new Set((rows.results || []).map((r) => r.type_hash))] + const typeNameMap = new Map() + for (const hash of typeHashes) { + const nameRow = await db + .prepare('SELECT name FROM event_def_names WHERE current_hash = ?') + .bind(hash) + .first<{ name: string }>() + if (nameRow) typeNameMap.set(hash, nameRow.name) + } + + const events = (rows.results || []).map((row) => ({ + id: row.id, + type_hash: row.type_hash, + type_name: typeNameMap.get(row.type_hash), + payload: JSON.parse(row.payload), + created_at: row.created_at, + })) return { events, total } } @@ -1287,6 +1318,30 @@ export function buildReactionPayload( } } +// ============================================ +// Schema (self-describing endpoint) +// ============================================ + +export async function getSchema(db: D1Database): Promise<{ + object_defs: Array<{ name: string }> + event_defs: Array<{ name: string; hash: string; schema: any }> + projection_defs: Array<{ name: string; params: any; value_schema: any }> +}> { + const objectDefs = await listObjectDefs(db) + const eventDefs = await listEventDefs(db) + const projectionDefs = await listProjectionDefs(db) + + return { + object_defs: objectDefs.map((d) => ({ name: d.name })), + event_defs: eventDefs.map((d) => ({ name: d.name, hash: d.hash, schema: d.schema })), + projection_defs: projectionDefs.map((d) => ({ + name: d.name, + params: d.params, + value_schema: d.value_schema, + })), + } +} + // ============================================ // API Keys // ============================================ diff --git a/packages/engine/src/index.test.ts b/packages/engine/src/index.test.ts index 864994c..133f106 100644 --- a/packages/engine/src/index.test.ts +++ b/packages/engine/src/index.test.ts @@ -444,6 +444,90 @@ describe('Auth', () => { }) }) +// ============================================ +// Schema +// ============================================ + +describe('Schema', () => { + it('GET /schema returns empty arrays on empty database', async () => { + const res = await app.fetch(req('GET', '/schema', undefined, ''), { DB: db, API_TOKEN: API_TOKEN }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.object_defs).toEqual([]) + expect(json.event_defs).toEqual([]) + expect(json.projection_defs).toEqual([]) + }) + + it('GET /schema returns full schema after definitions are created', async () => { + // Create object defs + await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN }) + await app.fetch(req('POST', '/object-defs', { name: 'task' }), { DB: db, API_TOKEN: API_TOKEN }) + + // Create event def + const eventSchema = { + properties: { + participant: { type: 'ref' as const, object_type: 'agent' }, + subject: { type: 'ref' as const, object_type: 'task' }, + }, + } + await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema: eventSchema }), { + DB: db, + API_TOKEN: API_TOKEN, + }) + + // Create projection def + const projDef = { + name: 'current_assignee', + sources: [ + { + event_def: 'task_assigned', + bindings: { subject: '$task_id' }, + expression: 'event.participant', + }, + ], + params: { task_id: { type: 'ref' } }, + value_schema: { type: 'ref' }, + initial_value: '', + } + await app.fetch(req('POST', '/projection-defs', projDef), { DB: db, API_TOKEN: API_TOKEN }) + + // GET /schema (no auth needed) + const res = await app.fetch(req('GET', '/schema', undefined, ''), { DB: db, API_TOKEN: API_TOKEN }) + expect(res.status).toBe(200) + const json = await res.json() + + // object_defs + expect(json.object_defs).toHaveLength(2) + expect(json.object_defs.map((d: any) => d.name).sort()).toEqual(['agent', 'task']) + + // event_defs + expect(json.event_defs).toHaveLength(1) + expect(json.event_defs[0].name).toBe('task_assigned') + expect(json.event_defs[0].hash).toBeDefined() + expect(json.event_defs[0].schema).toEqual(eventSchema) + + // projection_defs + expect(json.projection_defs).toHaveLength(1) + expect(json.projection_defs[0].name).toBe('current_assignee') + expect(json.projection_defs[0].params).toEqual({ task_id: { type: 'ref' } }) + expect(json.projection_defs[0].value_schema).toEqual({ type: 'ref' }) + }) + + it('GET /schema does not require auth', async () => { + // Request with no token at all + const request = new Request('http://test/schema', { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + const res = await app.fetch(request, { DB: db, API_TOKEN: API_TOKEN }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.object_defs).toBeDefined() + expect(json.event_defs).toBeDefined() + expect(json.projection_defs).toBeDefined() + }) +}) + // ============================================ // Object Defs // ============================================ @@ -615,7 +699,7 @@ describe('Events', () => { await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN }) }) - it('POST /events creates event with resolved type_hash', async () => { + it('POST /events creates event with resolved type_hash and type_name', async () => { const payload = { participant: agentId, subject: taskId } const res = await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), { DB: db, @@ -625,6 +709,7 @@ describe('Events', () => { const json = await res.json() expect(typeof json.event.id).toBe('number') expect(json.event.type_hash).toBeDefined() + expect(json.event.type_name).toBe('task_assigned') expect(json.event.payload).toEqual(payload) expect(tables.events).toHaveLength(1) expect(tables.event_refs).toHaveLength(2) @@ -639,7 +724,7 @@ describe('Events', () => { expect(res.status).toBe(201) }) - it('GET /events/:id returns event', async () => { + it('GET /events/:id returns event with type_name', async () => { const created = await app.fetch( req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }), { DB: db, API_TOKEN: API_TOKEN }, @@ -649,10 +734,11 @@ describe('Events', () => { expect(res.status).toBe(200) const json = await res.json() expect(json.id).toBe(event.id) + expect(json.type_name).toBe('task_assigned') expect(json.payload).toEqual({ participant: agentId, subject: taskId }) }) - it('GET /events?ref=X returns events by ref', async () => { + it('GET /events?ref=X returns events by ref with type_name', async () => { await app.fetch( req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }), { @@ -665,6 +751,7 @@ describe('Events', () => { const json = await res.json() expect(json.events).toHaveLength(1) expect(json.total).toBe(1) + expect(json.events[0].type_name).toBe('task_assigned') }) }) diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index aa735f0..d36f18f 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -38,6 +38,7 @@ import { listApiKeys, deleteApiKey, validateApiKey, + getSchema, } from './engine' import type { CreateObjectDefRequest, @@ -123,9 +124,18 @@ app.get('/health', (c) => { return c.json({ status: 'ok', version: '2.4.0' }) }) -// Auth middleware for all routes except health, ui, and POST /events (which has its own dual auth) +// ============================================ +// Schema (self-describing, no auth) +// ============================================ + +app.get('/schema', async (c) => { + const schema = await getSchema(c.env.DB) + return c.json(schema) +}) + +// Auth middleware for all routes except health, schema, ui, and POST /events (which has its own dual auth) app.use('*', async (c, next) => { - if (c.req.path === '/health' || c.req.path.startsWith('/ui')) return next() + if (c.req.path === '/health' || c.req.path === '/schema' || c.req.path.startsWith('/ui')) return next() if (c.req.method === 'POST' && c.req.path === '/events') return next() return bearerAuth(c.env.API_TOKEN)(c, next) }) diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index c2801ec..8c0dd5f 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -107,6 +107,7 @@ export interface Object { export interface Event { id: number type_hash: string + type_name?: string payload: Record created_at: number }