feat(api): add type_name to events + /schema endpoint (#15)

This commit is contained in:
小橘 2026-04-13 02:12:24 +00:00
parent eb7edd8141
commit e57bbb2727
4 changed files with 161 additions and 8 deletions

View File

@ -307,7 +307,7 @@ export async function createEvent(
.run() .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) const { fired: reactionsFired, payloads: reactionResults } = await triggerReactionChain(db, event)
return { event, reactions_fired: reactionsFired, reaction_results: reactionResults } return { event, reactions_fired: reactionsFired, reaction_results: reactionResults }
@ -319,7 +319,20 @@ export async function getEvent(db: D1Database, id: number): Promise<Event | null
.bind(id) .bind(id)
.first<{ id: number; type_hash: string; payload: string; created_at: number }>() .first<{ id: number; type_hash: string; payload: string; created_at: number }>()
if (!row) return null 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( export async function findEventsByRef(
@ -359,7 +372,25 @@ export async function findEventsByRef(
const total = countResult?.count || 0 const total = countResult?.count || 0
const rows = await dataQuery.all<{ id: number; type_hash: string; payload: string; created_at: number }>() 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<string, string>()
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 } 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 // API Keys
// ============================================ // ============================================

View File

@ -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 // 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 }) 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 payload = { participant: agentId, subject: taskId }
const res = await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), { const res = await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db, DB: db,
@ -625,6 +709,7 @@ describe('Events', () => {
const json = await res.json() const json = await res.json()
expect(typeof json.event.id).toBe('number') expect(typeof json.event.id).toBe('number')
expect(json.event.type_hash).toBeDefined() expect(json.event.type_hash).toBeDefined()
expect(json.event.type_name).toBe('task_assigned')
expect(json.event.payload).toEqual(payload) expect(json.event.payload).toEqual(payload)
expect(tables.events).toHaveLength(1) expect(tables.events).toHaveLength(1)
expect(tables.event_refs).toHaveLength(2) expect(tables.event_refs).toHaveLength(2)
@ -639,7 +724,7 @@ describe('Events', () => {
expect(res.status).toBe(201) 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( const created = await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }), req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ DB: db, API_TOKEN: API_TOKEN }, { DB: db, API_TOKEN: API_TOKEN },
@ -649,10 +734,11 @@ describe('Events', () => {
expect(res.status).toBe(200) expect(res.status).toBe(200)
const json = await res.json() const json = await res.json()
expect(json.id).toBe(event.id) expect(json.id).toBe(event.id)
expect(json.type_name).toBe('task_assigned')
expect(json.payload).toEqual({ participant: agentId, subject: taskId }) 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( await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }), req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ {
@ -665,6 +751,7 @@ describe('Events', () => {
const json = await res.json() const json = await res.json()
expect(json.events).toHaveLength(1) expect(json.events).toHaveLength(1)
expect(json.total).toBe(1) expect(json.total).toBe(1)
expect(json.events[0].type_name).toBe('task_assigned')
}) })
}) })

View File

@ -38,6 +38,7 @@ import {
listApiKeys, listApiKeys,
deleteApiKey, deleteApiKey,
validateApiKey, validateApiKey,
getSchema,
} from './engine' } from './engine'
import type { import type {
CreateObjectDefRequest, CreateObjectDefRequest,
@ -123,9 +124,18 @@ app.get('/health', (c) => {
return c.json({ status: 'ok', version: '2.4.0' }) 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) => { 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() if (c.req.method === 'POST' && c.req.path === '/events') return next()
return bearerAuth(c.env.API_TOKEN)(c, next) return bearerAuth(c.env.API_TOKEN)(c, next)
}) })

View File

@ -107,6 +107,7 @@ export interface Object {
export interface Event { export interface Event {
id: number id: number
type_hash: string type_hash: string
type_name?: string
payload: Record<string, any> payload: Record<string, any>
created_at: number created_at: number
} }