436 lines
14 KiB
Plaintext
436 lines
14 KiB
Plaintext
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<void> {
|
|
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<void> | null = null
|
|
private static initialized: boolean = false
|
|
private static subscriptions: Array<ActiveSubscription> = []
|
|
private static reconnectAttempts: number = 0
|
|
private static reconnectTimer: number | null = null
|
|
private static reconnecting: boolean = false
|
|
|
|
private static async waitForRealtimeOpen(timeoutMs: number = 5000): Promise<void> {
|
|
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<void>((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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void>((resolve) => {
|
|
setTimeout(() => resolve(), 200)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
private static async ensureRealtime(): Promise<void> {
|
|
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<void>((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<TrainingRealtimeSubscription> {
|
|
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
|