import supa, { supaReady } from '@/components/supadb/aksupainstance.uts' import AkSupaRealtime from '@/components/supadb/aksuparealtime.uts' import { WS_URL, SUPA_KEY } from '@/ak/config.uts' export type TrainingStreamEvent = { training_id: string event_type: string class_id?: string | null student_id?: string | null device_id?: string | null ack?: boolean | null status?: string | null metrics?: UTSJSONObject | null payload?: UTSJSONObject | null ingest_source?: string | null ingest_note?: string | null recorded_at?: string | null ingested_at?: string | null } export type TrainingRealtimeHandlers = { onAck?: (event: TrainingStreamEvent) => void onLiveMetrics?: (event: TrainingStreamEvent) => void onStateChange?: (event: TrainingStreamEvent) => void onEvent?: (event: TrainingStreamEvent) => void onError?: (err: any) => void } export type TrainingRealtimeSubscription = { dispose: () => void } export type TrainingRealtimeSubscriptionParams = { trainingId: string classId?: string | null } type SubscriptionGuard = { active: boolean } type ActiveSubscription = { params: TrainingRealtimeSubscriptionParams handlers: TrainingRealtimeHandlers guard: SubscriptionGuard } async function ensureSupaSession(): Promise { try { await supaReady } catch (err) { console.error('Supabase session initialization error', err) } } function extractRecord(payload: any): UTSJSONObject | null { try { if (payload == null) return null const wrapper = payload as UTSJSONObject const directNew = wrapper.get('new') as UTSJSONObject | null if (directNew != null) return directNew const record = wrapper.get('record') as UTSJSONObject | null if (record != null) return record const dataSection = wrapper.get('data') as UTSJSONObject | null if (dataSection != null) { const dataRecord = dataSection.get('record') as UTSJSONObject | null if (dataRecord != null) return dataRecord const dataNew = dataSection.get('new') as UTSJSONObject | null if (dataNew != null) return dataNew } } catch (err) { console.error('extractRecord error', err) } return null } function toStreamEvent(row: UTSJSONObject | null): TrainingStreamEvent | null { if (row == null) return null try { const trainingId = row.getString('training_id') as string | null const eventType = row.getString('event_type') as string | null if (trainingId == null || trainingId == '' || eventType == null || eventType == '') { return null } const studentId = row.getString('student_id') as string | null const classId = row.getString('class_id') as string | null const deviceId = row.getString('device_id') as string | null const ackRaw = row.get('ack') const status = row.getString('status') as string | null const metrics = row.get('metrics') as UTSJSONObject | null const payload = row.get('payload') as UTSJSONObject | null const ingestSource = row.getString('ingest_source') as string | null const ingestNote = row.getString('ingest_note') as string | null const recordedAt = row.getString('recorded_at') as string | null const ingestedAt = row.getString('ingested_at') as string | null const ack = typeof ackRaw == 'boolean' ? (ackRaw as boolean) : null return { training_id: trainingId, event_type: eventType, class_id: classId ?? null, student_id: studentId ?? null, device_id: deviceId ?? null, ack: ack, status: status ?? null, metrics: metrics ?? null, payload: payload ?? null, ingest_source: ingestSource ?? null, ingest_note: ingestNote ?? null, recorded_at: recordedAt ?? null, ingested_at: ingestedAt ?? null } } catch (err) { console.error('toStreamEvent error', err) return null } } export class TrainingRealtimeService { private static rt: AkSupaRealtime | null = null private static initializing: Promise | null = null private static initialized: boolean = false private static subscriptions: Array = [] private static reconnectAttempts: number = 0 private static reconnectTimer: number | null = null private static reconnecting: boolean = false private static async waitForRealtimeOpen(timeoutMs: number = 5000): Promise { const start = Date.now() while (true) { if (this.rt != null && this.rt.isOpen == true) { return } if (Date.now() - start > timeoutMs) { throw new Error('Realtime socket not ready') } await new Promise((resolve) => { setTimeout(() => resolve(), 50) }) } } private static clearReconnectTimer(): void { const timer = this.reconnectTimer if (timer != null) { clearTimeout(timer) this.reconnectTimer = null } } private static handleSocketClose(origin: string | null): void { this.initialized = false this.rt = null if (this.subscriptions.length == 0) { this.clearReconnectTimer() this.reconnectAttempts = 0 return } if (this.reconnecting == true) { return } console.warn('[TrainingRealtimeService] realtime closed, scheduling reconnect', origin ?? 'unknown') this.scheduleReconnect() } private static async scheduleReconnect(): Promise { if (this.reconnectTimer != null) return if (this.subscriptions.every((sub) => sub.guard.active !== true)) { this.subscriptions = [] this.reconnectAttempts = 0 return } const attempt = this.reconnectAttempts + 1 this.reconnectAttempts = attempt const baseDelay = Math.min(Math.pow(2, attempt - 1) * 1000, 10000) const jitter = Math.floor(Math.random() * 200) const delay = baseDelay + jitter console.log('[TrainingRealtimeService] reconnect scheduled in', delay, 'ms (attempt', attempt, ')') this.reconnectTimer = setTimeout(() => { const runner = async () => { this.reconnectTimer = null if (this.subscriptions.every((sub) => sub.guard.active !== true)) { this.subscriptions = [] this.reconnectAttempts = 0 return } this.reconnecting = true try { await this.ensureRealtime() await this.waitForRealtimeOpen(3000) this.reconnectAttempts = 0 await this.resubscribeAll() } catch (err) { console.error('reconnect attempt failed', err) this.scheduleReconnect() } finally { this.reconnecting = false } } runner().catch((err) => { console.error('reconnect timer runner error', err) }) }, delay) } private static async resubscribeAll(): Promise { let hadFailure = false for (let i = 0; i < this.subscriptions.length; i++) { const record = this.subscriptions[i] if (record.guard.active !== true) continue try { await this.performSubscription(record) } catch (err) { console.error('resubscribe error', err) record.handlers?.onError?.(err) hadFailure = true } } if (hadFailure) { this.scheduleReconnect() } } private static removeSubscription(record: ActiveSubscription): void { const index = this.subscriptions.indexOf(record) if (index >= 0) { this.subscriptions.splice(index, 1) } if (this.subscriptions.length == 0) { this.clearReconnectTimer() this.reconnectAttempts = 0 } } private static async performSubscription(record: ActiveSubscription): Promise { if (record.guard.active !== true) return const params = record.params const handlers = record.handlers const filter = `training_id=eq.${params.trainingId}` const maxAttempts = 2 let attempt = 0 while (attempt < maxAttempts) { attempt += 1 if (record.guard.active !== true) return try { if (this.rt == null || this.rt.isOpen !== true) { await this.ensureRealtime() await this.waitForRealtimeOpen(2000) } const realtime = this.rt if (realtime == null) { throw new Error('Realtime client not initialized') } console.log('[TrainingRealtimeService] realtime ready, preparing subscription', params) console.log('[TrainingRealtimeService] subscribing to training_stream_events', filter, 'attempt', attempt) realtime.subscribePostgresChanges({ event: '*', schema: 'public', table: 'training_stream_events', filter: filter, onChange: (payload: any) => { if (record.guard.active !== true) return const recordPayload = extractRecord(payload) const event = toStreamEvent(recordPayload) if (event == null) return console.log('[TrainingRealtimeService] incoming event', event?.event_type ?? 'unknown', event?.training_id ?? '') if (params.classId != null && params.classId !== '') { if ((event.class_id ?? '') !== params.classId) { return } } try { handlers?.onEvent?.(event) switch (event.event_type) { case 'ack': case 'ack_pending': case 'ack_fail': handlers?.onAck?.(event) break case 'telemetry': case 'metrics': handlers?.onLiveMetrics?.(event) break case 'state': case 'session': case 'summary': handlers?.onStateChange?.(event) break default: break } } catch (handlerErr) { console.error('training event handler error', handlerErr) } } }) console.log('[TrainingRealtimeService] subscription ready', params.trainingId) return } catch (subscribeErr) { console.error('performSubscription error', subscribeErr, 'attempt', attempt) if (attempt >= maxAttempts) { throw subscribeErr } await new Promise((resolve) => { setTimeout(() => resolve(), 200) }) } } } private static async ensureRealtime(): Promise { if (this.rt != null && this.rt.isOpen == true) { this.initialized = true return } if (this.initializing != null) { await this.initializing return } await ensureSupaSession() let token: string | null = null try { const session = supa.getSession() if (session != null) { token = session.session?.access_token ?? null } } catch (_) { } let resolveReady: ((value: void) => void) | null = null let rejectReady: ((reason: any) => void) | null = null const readyPromise = new Promise((resolve, reject) => { resolveReady = resolve as (value: void) => void rejectReady = reject }) this.initializing = readyPromise const realtime = new AkSupaRealtime({ url: WS_URL, channel: 'realtime:training_stream', apikey: SUPA_KEY, token: token, onMessage: (_data: any) => { }, onOpen: (_res: any) => { this.initialized = true this.reconnectAttempts = 0 this.clearReconnectTimer() this.reconnecting = false const resolver = resolveReady resolveReady = null rejectReady = null this.initializing = null resolver?.() }, onError: (err: any) => { if (resolveReady != null) { const rejector = rejectReady resolveReady = null rejectReady = null this.initializing = null rejector?.(err) } }, onClose: (_res: any) => { if (resolveReady != null) { const rejector = rejectReady resolveReady = null rejectReady = null this.initializing = null rejector?.(new Error('Realtime connection closed before ready')) } else { this.handleSocketClose('close') } } }) this.rt = realtime this.rt?.connect() try { await readyPromise } finally { this.initializing = null } } static async subscribeTrainingSession(params: TrainingRealtimeSubscriptionParams, handlers: TrainingRealtimeHandlers): Promise { if (params.trainingId == null || params.trainingId == '') { throw new Error('trainingId is required') } const guard: SubscriptionGuard = { active: true } const record: ActiveSubscription = { params: { trainingId: params.trainingId, classId: params.classId ?? null }, handlers: handlers, guard: guard } this.subscriptions.push(record) try { await this.performSubscription(record) } catch (err) { console.error('subscribeTrainingSession failed', err) guard.active = false this.removeSubscription(record) handlers?.onError?.(err) return { dispose: () => { } } } console.log('[TrainingRealtimeService] subscription active', params.trainingId) return { dispose: () => { if (guard.active !== true) return guard.active = false this.removeSubscription(record) console.log('[TrainingRealtimeService] subscription disposed', params.trainingId) } } } static closeRealtime(): void { try { if (this.rt != null) { this.rt.close({ code: 1000, reason: 'manual close' }) } } catch (err) { console.error('closeRealtime error', err) } finally { this.rt = null this.initialized = false this.initializing = null this.reconnecting = false this.clearReconnectTimer() this.reconnectAttempts = 0 } } } export default TrainingRealtimeService