From aef4cbb6562901a682d42806dc6821edf317a86e Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 8 Aug 2026 14:53:47 +0100 Subject: [PATCH] fix: align realtime event contracts --- packages/api-contracts/src/events.ts | 136 ++++++++++++++++-- .../api-contracts/tests/contracts.test.ts | 33 +++-- 2 files changed, 151 insertions(+), 18 deletions(-) diff --git a/packages/api-contracts/src/events.ts b/packages/api-contracts/src/events.ts index 146c052..980c141 100644 --- a/packages/api-contracts/src/events.ts +++ b/packages/api-contracts/src/events.ts @@ -3,33 +3,151 @@ import { SyncProgressSchema } from './chats.js'; const EventBase = { id: z.string(), - occurredAt: z.number(), + occurredAt: z.string().datetime({ offset: true }), }; +export const BotRuntimeStatusSchema = z.union([ + z.literal('starting'), + z.literal('running'), + z.literal('stopping'), + z.literal('stopped'), +]); +export const ConversationStatusSchema = z.union([ + z.literal('new'), + z.literal('awaiting-reply'), + z.literal('replied'), + z.literal('closed'), +]); +export const RealtimeSyncKindSchema = z.union([ + z.literal('media'), + z.literal('messages'), + z.literal('conversations'), + z.literal('full'), +]); +export const RealtimeRedditSessionStatusSchema = z.union([ + z.literal('unknown'), + z.literal('authenticating'), + z.literal('authenticated'), + z.literal('expired'), + z.literal('logged-out'), + z.literal('error'), +]); + export const DashboardEventSchema = z.discriminatedUnion('type', [ z.strictObject({ ...EventBase, - type: z.literal('stream.connected'), - payload: z.strictObject({}), + type: z.literal('bot.status.changed'), + payload: z.strictObject({ + status: BotRuntimeStatusSchema, + dryRun: z.boolean(), + reason: z.string().optional(), + }), }), z.strictObject({ ...EventBase, - type: z.literal('log.created'), + type: z.literal('message.sent'), payload: z.strictObject({ - level: z.union([z.literal('info'), z.literal('warn'), z.literal('error'), z.literal('debug')]), - message: z.string(), - data: z.unknown().optional(), + messageId: z.string(), + recipient: z.string(), + conversationId: z.string().optional(), + }), + }), + z.strictObject({ + ...EventBase, + type: z.literal('message.failed'), + payload: z.strictObject({ + recipient: z.string(), + reason: z.string(), + messageId: z.string().optional(), + conversationId: z.string().optional(), + code: z.string().optional(), + retryable: z.boolean().optional(), + }), + }), + z.strictObject({ + ...EventBase, + type: z.literal('conversation.updated'), + payload: z.strictObject({ + conversationId: z.string(), + status: ConversationStatusSchema, + unreadCount: z.number().optional(), + }), + }), + z.strictObject({ + ...EventBase, + type: z.literal('reply.received'), + payload: z.strictObject({ + conversationId: z.string(), + replyId: z.string(), + sender: z.string(), + preview: z.string().optional(), + }), + }), + z.strictObject({ + ...EventBase, + type: z.literal('sync.started'), + payload: z.strictObject({ + syncId: z.string(), + kind: RealtimeSyncKindSchema, }), }), z.strictObject({ ...EventBase, type: z.literal('sync.progress'), - payload: SyncProgressSchema, + payload: z.strictObject({ + syncId: z.string(), + kind: RealtimeSyncKindSchema, + completed: z.number(), + total: z.number().optional(), + message: z.string().optional(), + }), }), z.strictObject({ ...EventBase, type: z.literal('sync.completed'), - payload: z.strictObject({ kind: z.union([z.literal('full'), z.literal('delta')]) }), + payload: z.strictObject({ + syncId: z.string(), + kind: RealtimeSyncKindSchema, + processed: z.number(), + }), + }), + z.strictObject({ + ...EventBase, + type: z.literal('sync.failed'), + payload: z.strictObject({ + syncId: z.string(), + kind: RealtimeSyncKindSchema, + reason: z.string(), + code: z.string().optional(), + }), + }), + z.strictObject({ + ...EventBase, + type: z.literal('rate-limit.changed'), + payload: z.strictObject({ + limited: z.boolean(), + remaining: z.number().optional(), + resetsAt: z.string().optional(), + reason: z.string().optional(), + }), + }), + z.strictObject({ + ...EventBase, + type: z.literal('reddit.session.changed'), + payload: z.strictObject({ + status: RealtimeRedditSessionStatusSchema, + username: z.string().optional(), + reason: z.string().optional(), + }), + }), + z.strictObject({ + ...EventBase, + type: z.literal('log.created'), + payload: z.strictObject({ + level: z.union([z.literal('debug'), z.literal('info'), z.literal('warn'), z.literal('error')]), + message: z.string(), + data: z.unknown().optional(), + }), }), ]); diff --git a/packages/api-contracts/tests/contracts.test.ts b/packages/api-contracts/tests/contracts.test.ts index f32fb31..742e35d 100644 --- a/packages/api-contracts/tests/contracts.test.ts +++ b/packages/api-contracts/tests/contracts.test.ts @@ -181,17 +181,32 @@ describe('dashboard endpoint contracts', () => { it('validates the common error response and typed realtime event envelope', () => { expect(ErrorResponseSchema.safeParse({ error: 'Conversation not found' }).success).toBe(true); + const occurredAt = '2026-08-08T13:55:00.000Z'; + const events = [ + { id: '1', occurredAt, type: 'bot.status.changed', payload: { status: 'running', dryRun: false, reason: 'startup complete' } }, + { id: '2', occurredAt, type: 'message.sent', payload: { messageId: 'm1', recipient: 'alice', conversationId: 'c1' } }, + { id: '3', occurredAt, type: 'message.failed', payload: { recipient: 'alice', reason: 'rate limited', messageId: 'm2', conversationId: 'c1', code: 'limited', retryable: true } }, + { id: '4', occurredAt, type: 'conversation.updated', payload: { conversationId: 'c1', status: 'replied', unreadCount: 1 } }, + { id: '5', occurredAt, type: 'reply.received', payload: { conversationId: 'c1', replyId: 'r1', sender: 'alice', preview: 'Hi' } }, + { id: '6', occurredAt, type: 'sync.started', payload: { syncId: 's1', kind: 'media' } }, + { id: '7', occurredAt, type: 'sync.progress', payload: { syncId: 's1', kind: 'media', completed: 3, total: 10, message: 'Downloading' } }, + { id: '8', occurredAt, type: 'sync.completed', payload: { syncId: 's1', kind: 'media', processed: 10 } }, + { id: '9', occurredAt, type: 'sync.failed', payload: { syncId: 's1', kind: 'media', reason: 'network', code: 'offline' } }, + { id: '10', occurredAt, type: 'rate-limit.changed', payload: { limited: true, remaining: 0, resetsAt: occurredAt, reason: 'hourly' } }, + { id: '11', occurredAt, type: 'reddit.session.changed', payload: { status: 'authenticated', username: 'bot-user', reason: 'login completed' } }, + { id: '12', occurredAt, type: 'log.created', payload: { level: 'info', message: 'Started', data: { port: 3000 } } }, + ]; + + for (const event of events) { + expect(DashboardEventSchema.safeParse(event).success, event.type).toBe(true); + } expect(DashboardEventSchema.safeParse({ - id: 'event-1', - type: 'sync.progress', - occurredAt: 1_700_000_000_000, - payload: { - kind: 'delta', phase: 'done', current: 1, total: 1, - startedAt: 1_699_999_999_000, - }, - }).success).toBe(true); + id: 'event-13', type: 'log.created', occurredAt: 1_700_000_000_000, + payload: { level: 'info', message: 'Started' }, + }).success).toBe(false); expect(DashboardEventSchema.safeParse({ - id: 'event-2', type: 'sync.progress', occurredAt: 'now', payload: null, + id: 'event-14', type: 'sync.completed', occurredAt, + payload: { syncId: 's1', kind: 'media' }, }).success).toBe(false); expect(LegacyDashboardStreamEventSchema.safeParse({ type: 'connected', timestamp: 1_700_000_000_000,