Harden dashboard realtime state and controls

This commit is contained in:
jjbrunton committed 2026-08-08 15:41:58 +01:00
1 parent 4b3f00729f
commit d108903174
21 files changed
+422 -29

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
import type { RealtimeEvent } from './event-types.js';
import type { DashboardEventPublisher } from './event-publisher.js';
type Published = RealtimeEvent;
type SyncInput =
| { phase: 'started'; id: string; kind: 'delta' | 'full' | 'media' }
| { phase: 'progress'; id: string; kind: 'delta' | 'full' | 'media'; current: number; total?: number; label?: string }
| { phase: 'completed'; id: string; kind: 'delta' | 'full' | 'media'; processed: number }
| { phase: 'failed'; id: string; kind: 'delta' | 'full' | 'media'; error: string; code?: string };
const syncKind = (kind: SyncInput['kind']) => kind === 'delta' ? 'conversations' as const : kind;
const errorReason = (error: unknown) => error instanceof Error ? error.message : String(error);
export function createDashboardEventAdapters(hub: DashboardEventPublisher) {
return {
bot(input: { running: boolean; dryRun: boolean; transitioning?: 'starting' | 'stopping'; reason?: string }): Published {
return hub.publish('bot.status.changed', { status: input.transitioning ?? (input.running ? 'running' : 'stopped'), dryRun: input.dryRun, reason: input.reason });
},
messageSent(input: { id: string; username: string; conversationId?: string }): Published {
return hub.publish('message.sent', { messageId: input.id, recipient: input.username, conversationId: input.conversationId });
},
messageFailed(input: { username: string; error: unknown; messageId?: string; conversationId?: string; code?: string; retryable?: boolean }): Published {
return hub.publish('message.failed', { recipient: input.username, reason: errorReason(input.error), messageId: input.messageId, conversationId: input.conversationId, code: input.code, retryable: input.retryable });
},
conversation(input: { id: string; state: 'new' | 'awaiting_reply' | 'replied' | 'closed'; unread?: number }): Published {
return hub.publish('conversation.updated', { conversationId: input.id, status: input.state === 'awaiting_reply' ? 'awaiting-reply' : input.state, unreadCount: input.unread });
},
reply(input: { conversationId: string; id: string; username: string; body?: string }): Published {
return hub.publish('reply.received', { conversationId: input.conversationId, replyId: input.id, sender: input.username, preview: input.body });
},
sync(input: SyncInput): Published {
const kind = syncKind(input.kind);
switch (input.phase) {
case 'started': return hub.publish('sync.started', { syncId: input.id, kind });
case 'progress': return hub.publish('sync.progress', { syncId: input.id, kind, completed: input.current, total: input.total, message: input.label });
case 'completed': return hub.publish('sync.completed', { syncId: input.id, kind, processed: input.processed });
case 'failed': return hub.publish('sync.failed', { syncId: input.id, kind, reason: input.error, code: input.code });
}
},
rateLimit(input: { blocked: boolean; remaining?: number; resetAt?: number; reason?: string }): Published {
return hub.publish('rate-limit.changed', { limited: input.blocked, remaining: input.remaining, resetsAt: input.resetAt === undefined ? undefined : new Date(input.resetAt).toISOString(), reason: input.reason });
},
redditSession(input: { state: 'unknown' | 'authenticating' | 'authenticated' | 'expired' | 'login-required' | 'unreachable'; username?: string; reason?: string }): Published {
const status = input.state === 'login-required' ? 'logged-out' : input.state === 'unreachable' ? 'error' : input.state;
return hub.publish('reddit.session.changed', { status, username: input.username, reason: input.reason });
},
log(input: { level: 'debug' | 'info' | 'warn' | 'error'; message: string; data?: unknown }): Published {
return hub.publish('log.created', input);
},
};
}
+10
View File
@@ -0,0 +1,10 @@
import type { RealtimeEvent, RealtimeEventMap, RealtimeEventType } from './event-types.js';
import type { RealtimeEventHub } from './realtime-event-hub.js';
export interface DashboardEventPublisher {
publish<TType extends RealtimeEventType>(type: TType, payload: RealtimeEventMap[TType]): RealtimeEvent<TType>;
}
export function createDashboardEventPublisher(hub: RealtimeEventHub): DashboardEventPublisher {
return { publish: (type, payload) => hub.publish(type, payload) };
}
+2
View File
@@ -21,3 +21,5 @@ export {
type RealtimeEventSubscription,
type SubscribeOptions,
} from './realtime-event-hub.js';
export { createDashboardEventAdapters } from './event-adapters.js';
export { createDashboardEventPublisher, type DashboardEventPublisher } from './event-publisher.js';