diff --git a/packages/engine/src/engine.ts b/packages/engine/src/engine.ts index e895c75..ec30acc 100644 --- a/packages/engine/src/engine.ts +++ b/packages/engine/src/engine.ts @@ -77,6 +77,11 @@ export async function listObjectDefs(db: D1Database): Promise { return (result.results || []).map((row) => ({ name: row.name })) } +export async function getObjectDef(db: D1Database, name: string): Promise { + const row = await db.prepare('SELECT name FROM object_defs WHERE name = ?').bind(name).first<{ name: string }>() + return row ? { name: row.name } : null +} + // ============================================ // Objects // ============================================ @@ -207,6 +212,25 @@ export async function listEventDefs(db: D1Database): Promise { })) } +export async function getEventDef(db: D1Database, name: string): Promise { + const row = await db + .prepare( + `SELECT n.name, n.current_hash, v.parent_hash, v.schema + FROM event_def_names n + JOIN event_def_versions v ON n.current_hash = v.hash + WHERE n.name = ?`, + ) + .bind(name) + .first<{ name: string; current_hash: string; parent_hash: string | null; schema: string }>() + if (!row) return null + return { + name: row.name, + hash: row.current_hash, + parent_hash: row.parent_hash, + schema: JSON.parse(row.schema), + } +} + // ============================================ // Events // ============================================ @@ -514,6 +538,48 @@ export async function listProjectionDefs(db: D1Database): Promise { + const row = await db + .prepare( + `SELECT n.name, n.current_hash, v.parent_hash, v.params, v.value_schema, v.initial_value + FROM projection_def_names n + JOIN projection_def_versions v ON n.current_hash = v.hash + WHERE n.name = ?`, + ) + .bind(name) + .first<{ + name: string + current_hash: string + parent_hash: string | null + params: string + value_schema: string + initial_value: string + }>() + + if (!row) return null + + const sourceRows = await db + .prepare('SELECT event_def_hash, bindings, expression FROM projection_def_sources WHERE projection_hash = ?') + .bind(row.current_hash) + .all<{ event_def_hash: string; bindings: string; expression: string }>() + + const sources: ProjectionDefSource[] = (sourceRows.results || []).map((s) => ({ + event_def_hash: s.event_def_hash, + bindings: JSON.parse(s.bindings), + expression: s.expression, + })) + + return { + name: row.name, + hash: row.current_hash, + parent_hash: row.parent_hash, + params: JSON.parse(row.params), + sources, + value_schema: JSON.parse(row.value_schema), + initial_value: JSON.parse(row.initial_value), + } +} + // ============================================ // Projections // ============================================ @@ -796,6 +862,40 @@ export async function deleteReaction(db: D1Database, id: number): Promise await db.prepare('DELETE FROM reactions WHERE id = ?').bind(id).run() } +export async function getReaction(db: D1Database, id: number): Promise { + const row = await db + .prepare( + 'SELECT id, projection_def_hash, params_hash, params, action, webhook_url, emit_event_type, emit_payload_template, handler_code, handler_timeout_ms, created_at FROM reactions WHERE id = ?', + ) + .bind(id) + .first<{ + id: number + projection_def_hash: string + params_hash: string + params: string + action: string + webhook_url: string | null + emit_event_type: string | null + emit_payload_template: string | null + handler_code: string | null + handler_timeout_ms: number | null + created_at: number + }>() + + if (!row) return null + + return { + ...row, + params: JSON.parse(row.params), + action: (row.action || 'webhook') as 'webhook' | 'emit_event' | 'handler', + webhook_url: row.webhook_url || undefined, + emit_event_type: row.emit_event_type || undefined, + emit_payload_template: row.emit_payload_template || undefined, + handler_code: row.handler_code || undefined, + handler_timeout_ms: row.handler_timeout_ms || undefined, + } +} + // ============================================ // Handler Execution // ============================================ diff --git a/packages/engine/src/index.test.ts b/packages/engine/src/index.test.ts index 9be8d76..864994c 100644 --- a/packages/engine/src/index.test.ts +++ b/packages/engine/src/index.test.ts @@ -473,6 +473,7 @@ describe('Object Defs', () => { expect(res.status).toBe(200) const json = await res.json() expect(json.object_defs).toHaveLength(2) + expect(json.total).toBe(2) expect(json.object_defs[0]).toEqual({ name: 'agent' }) }) }) @@ -582,6 +583,7 @@ describe('Event Defs', () => { expect(res.status).toBe(200) const json = await res.json() expect(json.event_defs).toHaveLength(1) + expect(json.total).toBe(1) expect(json.event_defs[0].name).toBe('task_assigned') expect(json.event_defs[0].hash).toBeDefined() expect(json.event_defs[0].parent_hash).toBeNull() @@ -752,6 +754,7 @@ describe('Projection Defs', () => { expect(res.status).toBe(200) const json = await res.json() expect(json.projection_defs).toHaveLength(1) + expect(json.total).toBe(1) expect(json.projection_defs[0].name).toBe('current_assignee') expect(json.projection_defs[0].value_schema).toEqual({ type: 'ref' }) expect(json.projection_defs[0].initial_value).toBe('') @@ -1041,19 +1044,153 @@ describe('E2E: Reaction Chain', () => { }) }) +// ============================================ +// GET Single Routes +// ============================================ + +describe('GET /object-defs/:name', () => { + it('returns object def by name', async () => { + await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN }) + const res = await app.fetch(req('GET', '/object-defs/agent'), { DB: db, API_TOKEN: API_TOKEN }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.name).toBe('agent') + }) + + it('returns 404 for nonexistent name', async () => { + const res = await app.fetch(req('GET', '/object-defs/nonexistent'), { DB: db, API_TOKEN: API_TOKEN }) + expect(res.status).toBe(404) + const json = await res.json() + expect(json.error.code).toBe('NOT_FOUND') + }) +}) + +describe('GET /event-defs/:name', () => { + it('returns event def with schema by name', async () => { + const schema = { + properties: { + participant: { type: 'ref' as const, object_type: 'agent' }, + }, + } + await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN }) + const res = await app.fetch(req('GET', '/event-defs/task_assigned'), { DB: db, API_TOKEN: API_TOKEN }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.name).toBe('task_assigned') + expect(json.hash).toBeDefined() + expect(json.schema).toEqual(schema) + }) + + it('returns 404 for nonexistent name', async () => { + const res = await app.fetch(req('GET', '/event-defs/nonexistent'), { DB: db, API_TOKEN: API_TOKEN }) + expect(res.status).toBe(404) + const json = await res.json() + expect(json.error.code).toBe('NOT_FOUND') + }) +}) + +describe('GET /projection-defs/:name', () => { + beforeEach(async () => { + const schema = { properties: { participant: { type: 'ref' as const } } } + await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN }) + }) + + it('returns projection def with sources/params/value_schema by name', async () => { + const body = { + 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', body), { DB: db, API_TOKEN: API_TOKEN }) + const res = await app.fetch(req('GET', '/projection-defs/current_assignee'), { DB: db, API_TOKEN: API_TOKEN }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.name).toBe('current_assignee') + expect(json.hash).toBeDefined() + expect(json.value_schema).toEqual({ type: 'ref' }) + expect(json.initial_value).toBe('') + expect(json.sources).toHaveLength(1) + expect(json.params).toEqual({ task_id: { type: 'ref' } }) + }) + + it('returns 404 for nonexistent name', async () => { + const res = await app.fetch(req('GET', '/projection-defs/nonexistent'), { DB: db, API_TOKEN: API_TOKEN }) + expect(res.status).toBe(404) + const json = await res.json() + expect(json.error.code).toBe('NOT_FOUND') + }) +}) + +describe('GET /reactions/:id', () => { + beforeEach(async () => { + const schema = { properties: { participant: { type: 'ref' as const } } } + await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN }) + 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 }) + }) + + it('returns reaction by id', async () => { + const created = await app.fetch( + req('POST', '/reactions', { + projection_def: 'current_assignee', + params: { task_id: 1 }, + webhook_url: 'https://hook.example.com', + }), + { DB: db, API_TOKEN: API_TOKEN }, + ) + const { id } = await created.json() + const res = await app.fetch(req('GET', `/reactions/${id}`), { DB: db, API_TOKEN: API_TOKEN }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.id).toBe(id) + expect(json.projection_def_hash).toBeDefined() + expect(json.params).toEqual({ task_id: 1 }) + expect(json.action).toBe('webhook') + }) + + it('returns 404 for nonexistent id', async () => { + const res = await app.fetch(req('GET', '/reactions/99999'), { DB: db, API_TOKEN: API_TOKEN }) + expect(res.status).toBe(404) + const json = await res.json() + expect(json.error.code).toBe('NOT_FOUND') + }) +}) + describe('Error Cases: Object Defs', () => { it('POST /object-defs missing name → 400', async () => { const res = await app.fetch(req('POST', '/object-defs', {}), { DB: db, API_TOKEN: API_TOKEN }) expect(res.status).toBe(400) const json = await res.json() - expect(json.error).toContain('Missing name') + expect(json.error.code).toBe('MISSING_FIELD') + expect(json.error.message).toContain('Missing name') }) it('POST /object-defs empty name → 400', async () => { const res = await app.fetch(req('POST', '/object-defs', { name: '' }), { DB: db, API_TOKEN: API_TOKEN }) expect(res.status).toBe(400) const json = await res.json() - expect(json.error).toContain('Missing name') + expect(json.error.code).toBe('MISSING_FIELD') + expect(json.error.message).toContain('Missing name') }) }) @@ -1062,12 +1199,15 @@ describe('Error Cases: Objects', () => { const res = await app.fetch(req('POST', '/objects', { type: 'nonexistent' }), { DB: db, API_TOKEN: API_TOKEN }) expect(res.status).toBe(500) const json = await res.json() - expect(json.error).toContain('Object type nonexistent not defined') + expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.message).toContain('Object type nonexistent not defined') }) it('GET /objects/:id not found → 404', async () => { const res = await app.fetch(req('GET', '/objects/99999'), { DB: db, API_TOKEN: API_TOKEN }) expect(res.status).toBe(404) + const json = await res.json() + expect(json.error.code).toBe('NOT_FOUND') }) it('GET /objects?type=nonexistent → returns empty array', async () => { @@ -1083,6 +1223,8 @@ describe('Error Cases: Event Defs', () => { it('POST /event-defs missing schema → 400', async () => { const res = await app.fetch(req('POST', '/event-defs', { name: 'test' }), { DB: db, API_TOKEN: API_TOKEN }) expect(res.status).toBe(400) + const json = await res.json() + expect(json.error.code).toBe('MISSING_FIELD') }) it('POST /event-defs schema without properties → 500', async () => { @@ -1092,7 +1234,8 @@ describe('Error Cases: Event Defs', () => { }) expect(res.status).toBe(500) const json = await res.json() - expect(json.error).toContain('properties') + expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.message).toContain('properties') }) it('POST /event-defs invalid property type → 500', async () => { @@ -1103,7 +1246,8 @@ describe('Error Cases: Event Defs', () => { }) expect(res.status).toBe(500) const json = await res.json() - expect(json.error).toContain('Invalid property type') + expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.message).toContain('Invalid property type') }) it('POST /event-defs duplicate schema → idempotent (same hash)', async () => { @@ -1145,7 +1289,8 @@ describe('Error Cases: Events', () => { }) expect(res.status).toBe(500) const json = await res.json() - expect(json.error).toContain('Event type nonexistent not defined') + expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.message).toContain('Event type nonexistent not defined') }) it('POST /events ref nonexistent object → 500', async () => { @@ -1155,7 +1300,8 @@ describe('Error Cases: Events', () => { }) expect(res.status).toBe(500) const json = await res.json() - expect(json.error).toContain('does not exist') + expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.message).toContain('does not exist') }) it('POST /events ref type mismatch → 500', async () => { @@ -1168,7 +1314,8 @@ describe('Error Cases: Events', () => { }) expect(res.status).toBe(500) const json = await res.json() - expect(json.error).toContain('type mismatch') + expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.message).toContain('type mismatch') }) it('POST /events payload property wrong type → 500', async () => { @@ -1178,12 +1325,15 @@ describe('Error Cases: Events', () => { ) expect(res.status).toBe(500) const json = await res.json() - expect(json.error).toContain('must be number') + expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.message).toContain('must be number') }) it('GET /events/:id not found → 404', async () => { const res = await app.fetch(req('GET', '/events/99999'), { DB: db, API_TOKEN: API_TOKEN }) expect(res.status).toBe(404) + const json = await res.json() + expect(json.error.code).toBe('NOT_FOUND') }) it('GET /events without ref → returns all events', async () => { @@ -1220,6 +1370,8 @@ describe('Error Cases: Projection Defs', () => { { DB: db, API_TOKEN: API_TOKEN }, ) expect(res.status).toBe(400) + const json = await res.json() + expect(json.error.code).toBe('MISSING_FIELD') }) it('POST /projection-defs missing initial_value → 400', async () => { @@ -1233,6 +1385,8 @@ describe('Error Cases: Projection Defs', () => { { DB: db, API_TOKEN: API_TOKEN }, ) expect(res.status).toBe(400) + const json = await res.json() + expect(json.error.code).toBe('MISSING_FIELD') }) it('POST /projection-defs missing sources → 400', async () => { @@ -1247,7 +1401,8 @@ describe('Error Cases: Projection Defs', () => { ) expect(res.status).toBe(400) const json = await res.json() - expect(json.error).toContain('sources') + expect(json.error.code).toBe('MISSING_FIELD') + expect(json.error.message).toContain('sources') }) it('POST /projection-defs invalid value_schema type → 500', async () => { @@ -1263,7 +1418,8 @@ describe('Error Cases: Projection Defs', () => { ) expect(res.status).toBe(500) const json = await res.json() - expect(json.error).toContain('Invalid value_schema type') + expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.message).toContain('Invalid value_schema type') }) it('POST /projection-defs initial_value type mismatch → 500', async () => { @@ -1279,7 +1435,8 @@ describe('Error Cases: Projection Defs', () => { ) expect(res.status).toBe(500) const json = await res.json() - expect(json.error).toContain('initial_value must be number') + expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.message).toContain('initial_value must be number') }) it('POST /projection-defs source with nonexistent event → 500', async () => { @@ -1295,7 +1452,8 @@ describe('Error Cases: Projection Defs', () => { ) expect(res.status).toBe(500) const json = await res.json() - expect(json.error).toContain('Event type nonexistent not defined') + expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.message).toContain('Event type nonexistent not defined') }) it('POST /projection-defs duplicate definition → idempotent (same hash)', async () => { @@ -1319,7 +1477,8 @@ describe('Error Cases: Projections', () => { const res = await app.fetch(req('GET', '/projections/nonexistent?param=value'), { DB: db, API_TOKEN: API_TOKEN }) expect(res.status).toBe(500) const json = await res.json() - expect(json.error).toContain('Projection def nonexistent not found') + expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.message).toContain('Projection def nonexistent not found') }) }) @@ -1335,7 +1494,8 @@ describe('Error Cases: Reactions', () => { ) expect(res.status).toBe(500) const json = await res.json() - expect(json.error).toContain('Projection def nonexistent not found') + expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.message).toContain('Projection def nonexistent not found') }) it('DELETE /reactions/:id nonexistent → 200 (idempotent)', async () => { @@ -1530,7 +1690,8 @@ describe('Reaction Handler', () => { const res = await app.fetch(req('POST', '/reactions', body), { DB: db, API_TOKEN: API_TOKEN }) expect(res.status).toBe(400) const json = await res.json() - expect(json.error).toContain('handler_code is required') + expect(json.error.code).toBe('MISSING_FIELD') + expect(json.error.message).toContain('handler_code is required') }) it('handler receives context (old_value, new_value, params, event)', async () => { @@ -1975,8 +2136,8 @@ describe('Request Logs', () => { const logsRes = await app.fetch(req('GET', '/request-logs'), { DB: db, API_TOKEN }) const logsJson = await logsRes.json() - expect(logsJson.logs.length).toBeGreaterThanOrEqual(1) - const postLog = logsJson.logs.find((l: any) => l.path === '/events' && l.method === 'POST') + expect(logsJson.request_logs.length).toBeGreaterThanOrEqual(1) + const postLog = logsJson.request_logs.find((l: any) => l.path === '/events' && l.method === 'POST') expect(postLog).toBeDefined() expect(postLog.status_code).toBe(201) }) @@ -1999,7 +2160,7 @@ describe('Request Logs', () => { const logsRes = await app.fetch(req('GET', '/request-logs'), { DB: db, API_TOKEN }) const logsJson = await logsRes.json() - const postLog = logsJson.logs.find((l: any) => l.path === '/events' && l.method === 'POST') + const postLog = logsJson.request_logs.find((l: any) => l.path === '/events' && l.method === 'POST') expect(postLog).toBeDefined() expect(postLog.api_key_name).toBe('log-test-key') expect(postLog.api_key_id).toBeDefined() @@ -2030,11 +2191,11 @@ describe('Request Logs', () => { const filteredRes = await app.fetch(req('GET', `/request-logs?api_key_id=${keyId}`), { DB: db, API_TOKEN }) const filteredJson = await filteredRes.json() - expect(filteredJson.logs.length).toBe(1) - expect(filteredJson.logs[0].api_key_id).toBe(keyId) + expect(filteredJson.request_logs.length).toBe(1) + expect(filteredJson.request_logs[0].api_key_id).toBe(keyId) const allRes = await app.fetch(req('GET', '/request-logs'), { DB: db, API_TOKEN }) const allJson = await allRes.json() - expect(allJson.logs.length).toBe(2) + expect(allJson.request_logs.length).toBe(2) }) }) diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 5023fdd..aa735f0 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -15,19 +15,23 @@ import UI_HTML from './ui.html' import { createObjectDef, listObjectDefs, + getObjectDef, createObject, getObject, listObjects, createEventDef, listEventDefs, + getEventDef, createEvent, getEvent, findEventsByRef, createProjectionDef, listProjectionDefs, + getProjectionDef, getProjection, createReaction, listReactions, + getReaction, deleteReaction, listReactionLogs, createApiKey, @@ -45,6 +49,7 @@ import type { CreateApiKeyRequest, ReactionPayload, } from './types' +import { ErrorCode } from './types' type Bindings = { DB: D1Database @@ -58,6 +63,10 @@ type Variables = { const app = new Hono<{ Bindings: Bindings; Variables: Variables }>() +function apiError(c: any, status: number, code: string, message: string) { + return c.json({ error: { code, message } }, status) +} + app.use('*', cors()) app.use('*', async (c, next) => { @@ -128,17 +137,24 @@ app.use('*', async (c, next) => { app.post('/object-defs', async (c) => { try { const body = await c.req.json() - if (!body.name) return c.json({ error: 'Missing name' }, 400) + if (!body.name) return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing name') const result = await createObjectDef(c.env.DB, body.name) return c.json(result, 201) } catch (err: any) { - return c.json({ error: err.message || 'Internal error' }, 500) + return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } }) +app.get('/object-defs/:name', async (c) => { + const name = c.req.param('name') + const def = await getObjectDef(c.env.DB, name) + if (!def) return apiError(c, 404, ErrorCode.NOT_FOUND, `Object def '${name}' not found`) + return c.json(def) +}) + app.get('/object-defs', async (c) => { const defs = await listObjectDefs(c.env.DB) - return c.json({ object_defs: defs }) + return c.json({ object_defs: defs, total: defs.length }) }) // ============================================ @@ -148,19 +164,19 @@ app.get('/object-defs', async (c) => { app.post('/objects', async (c) => { try { const body = await c.req.json() - if (!body.type) return c.json({ error: 'Missing type' }, 400) + if (!body.type) return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing type') const obj = await createObject(c.env.DB, body.type) return c.json(obj, 201) } catch (err: any) { - return c.json({ error: err.message || 'Internal error' }, 500) + return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } }) app.get('/objects/:id', async (c) => { const id = parseInt(c.req.param('id'), 10) - if (isNaN(id)) return c.json({ error: 'Invalid id' }, 400) + if (isNaN(id)) return apiError(c, 400, ErrorCode.INVALID_INPUT, 'Invalid id') const obj = await getObject(c.env.DB, id) - if (!obj) return c.json({ error: 'Not found' }, 404) + if (!obj) return apiError(c, 404, ErrorCode.NOT_FOUND, 'Not found') return c.json(obj) }) @@ -183,17 +199,24 @@ app.get('/objects', async (c) => { app.post('/event-defs', async (c) => { try { const body = await c.req.json() - if (!body.name || !body.schema) return c.json({ error: 'Missing name or schema' }, 400) + if (!body.name || !body.schema) return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing name or schema') const result = await createEventDef(c.env.DB, body.name, body.schema) return c.json(result, 201) } catch (err: any) { - return c.json({ error: err.message || 'Internal error' }, 500) + return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } }) +app.get('/event-defs/:name', async (c) => { + const name = c.req.param('name') + const def = await getEventDef(c.env.DB, name) + if (!def) return apiError(c, 404, ErrorCode.NOT_FOUND, `Event def '${name}' not found`) + return c.json(def) +}) + app.get('/event-defs', async (c) => { const defs = await listEventDefs(c.env.DB) - return c.json({ event_defs: defs }) + return c.json({ event_defs: defs, total: defs.length }) }) // ============================================ @@ -207,26 +230,26 @@ app.post('/events', async (c) => { const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null if (!bearerToken) { - return c.json({ error: 'Missing or invalid Authorization header' }, 401) + return apiError(c, 401, ErrorCode.UNAUTHORIZED, 'Missing or invalid Authorization header') } let body: CreateEventRequest try { body = await c.req.json() } catch { - return c.json({ error: 'Invalid JSON body' }, 400) + return apiError(c, 400, ErrorCode.INVALID_JSON, 'Invalid JSON body') } - if (!body.type || !body.payload) return c.json({ error: 'Missing type or payload' }, 400) + if (!body.type || !body.payload) return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing type or payload') // If the token is not API_TOKEN, treat it as an API key if (bearerToken !== c.env.API_TOKEN) { const result = await validateApiKey(c.env.DB, bearerToken, body.type) if (!result.valid) { if (result.error === 'event_not_allowed') { - return c.json({ error: 'Event type not allowed for this API key' }, 403) + return apiError(c, 403, ErrorCode.FORBIDDEN, 'Event type not allowed for this API key') } - return c.json({ error: 'Invalid API key' }, 401) + return apiError(c, 401, ErrorCode.UNAUTHORIZED, 'Invalid API key') } if (result.apiKey) { c.set('apiKeyId', result.apiKey.id) @@ -248,15 +271,15 @@ app.post('/events', async (c) => { return c.json({ event, reactions_fired, reaction_results }, 201) } catch (err: any) { - return c.json({ error: err.message || 'Internal error' }, 500) + return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } }) app.get('/events/:id', async (c) => { const id = parseInt(c.req.param('id'), 10) - if (isNaN(id)) return c.json({ error: 'Invalid id' }, 400) + if (isNaN(id)) return apiError(c, 400, ErrorCode.INVALID_INPUT, 'Invalid id') const event = await getEvent(c.env.DB, id) - if (!event) return c.json({ error: 'Not found' }, 404) + if (!event) return apiError(c, 404, ErrorCode.NOT_FOUND, 'Not found') return c.json(event) }) @@ -281,10 +304,10 @@ app.post('/projection-defs', async (c) => { try { const body = await c.req.json() if (!body.name || !body.value_schema || body.initial_value === undefined) { - return c.json({ error: 'Missing name, value_schema, or initial_value' }, 400) + return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing name, value_schema, or initial_value') } if (!body.sources || !Array.isArray(body.sources) || body.sources.length === 0) { - return c.json({ error: 'Missing or empty sources array' }, 400) + return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing or empty sources array') } const result = await createProjectionDef( c.env.DB, @@ -296,13 +319,20 @@ app.post('/projection-defs', async (c) => { ) return c.json(result, 201) } catch (err: any) { - return c.json({ error: err.message || 'Internal error' }, 500) + return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } }) +app.get('/projection-defs/:name', async (c) => { + const name = c.req.param('name') + const def = await getProjectionDef(c.env.DB, name) + if (!def) return apiError(c, 404, ErrorCode.NOT_FOUND, `Projection def '${name}' not found`) + return c.json(def) +}) + app.get('/projection-defs', async (c) => { const defs = await listProjectionDefs(c.env.DB) - return c.json({ projection_defs: defs }) + return c.json({ projection_defs: defs, total: defs.length }) }) // ============================================ @@ -320,7 +350,7 @@ app.get('/projections/:name', async (c) => { const value = await getProjection(c.env.DB, name, params) return c.json({ value }) } catch (err: any) { - return c.json({ error: err.message || 'Internal error' }, 500) + return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } }) @@ -333,16 +363,16 @@ app.post('/reactions', async (c) => { const body = await c.req.json() const action = body.action || 'webhook' if (!body.projection_def || !body.params) { - return c.json({ error: 'Missing projection_def or params' }, 400) + return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing projection_def or params') } if (action === 'webhook' && !body.webhook_url) { - return c.json({ error: 'webhook_url is required when action is webhook' }, 400) + return apiError(c, 400, ErrorCode.MISSING_FIELD, 'webhook_url is required when action is webhook') } if (action === 'emit_event' && !body.emit_event_type) { - return c.json({ error: 'emit_event_type is required when action is emit_event' }, 400) + return apiError(c, 400, ErrorCode.MISSING_FIELD, 'emit_event_type is required when action is emit_event') } if (action === 'handler' && !body.handler_code) { - return c.json({ error: 'handler_code is required when action is handler' }, 400) + return apiError(c, 400, ErrorCode.MISSING_FIELD, 'handler_code is required when action is handler') } const reaction = await createReaction(c.env.DB, body.projection_def, body.params, { action, @@ -354,10 +384,18 @@ app.post('/reactions', async (c) => { }) return c.json(reaction, 201) } catch (err: any) { - return c.json({ error: err.message || 'Internal error' }, 500) + return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } }) +app.get('/reactions/:id', async (c) => { + const id = parseInt(c.req.param('id'), 10) + if (isNaN(id)) return apiError(c, 400, ErrorCode.INVALID_INPUT, 'Invalid id') + const reaction = await getReaction(c.env.DB, id) + if (!reaction) return apiError(c, 404, ErrorCode.NOT_FOUND, `Reaction ${id} not found`) + return c.json(reaction) +}) + app.get('/reactions', async (c) => { const limitParam = c.req.query('limit') const offsetParam = c.req.query('offset') @@ -422,7 +460,7 @@ app.get('/request-logs', async (c) => { .first<{ total: number }>(), ]) - return c.json({ logs: rows.results, total: countRow?.total || 0 }) + return c.json({ request_logs: rows.results, total: countRow?.total || 0 }) }) // ============================================ @@ -432,11 +470,11 @@ app.get('/request-logs', async (c) => { app.post('/api-keys', async (c) => { try { const body = await c.req.json() - if (!body.name) return c.json({ error: 'Missing name' }, 400) + if (!body.name) return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing name') const result = await createApiKey(c.env.DB, body.name, body.role, body.allowed_events, body.rate_limit) return c.json(result, 201) } catch (err: any) { - return c.json({ error: err.message || 'Internal error' }, 500) + return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } }) diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index d80e07c..c2801ec 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -4,6 +4,22 @@ // projection_def 加 value_schema NOT NULL + initial_value NOT NULL // objects.type 直接存名字(不存 hash) +// ============================================ +// Error Codes +// ============================================ + +export const ErrorCode = { + MISSING_FIELD: 'MISSING_FIELD', + INVALID_INPUT: 'INVALID_INPUT', + NOT_FOUND: 'NOT_FOUND', + UNAUTHORIZED: 'UNAUTHORIZED', + FORBIDDEN: 'FORBIDDEN', + INTERNAL_ERROR: 'INTERNAL_ERROR', + INVALID_JSON: 'INVALID_JSON', +} as const + +export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode] + // ============================================ // Definition Layer // ============================================