// server/cloudflare-worker/src/push-drain.ts // Cloudflare Cron-triggered drain for the Supabase push outbox. // // Supabase owns the durable outbox and the send-push Edge Function; nothing in // the repository scheduled the drain, so transactionally enqueued notifications // (team invites, transcription completion, billing) never left the queue. This // worker drains them on a cron schedule. export interface PushDrainEnv { SUPABASE_URL?: string SUPABASE_SERVICE_ROLE_KEY?: string PUSH_DRAIN_BATCH_LIMIT?: string } export interface PushDrainResult { ok: boolean status: number claimed?: number completed?: number pending?: number error?: string } const DEFAULT_BATCH_LIMIT = 20 const MAX_BATCH_LIMIT = 100 const REQUEST_TIMEOUT_MS = 30_000 export function resolveBatchLimit(raw: string | undefined): number { const parsed = Number(raw ?? DEFAULT_BATCH_LIMIT) if (!Number.isSafeInteger(parsed) || parsed < 1) return DEFAULT_BATCH_LIMIT return Math.min(parsed, MAX_BATCH_LIMIT) } export async function drainPushDispatches( env: PushDrainEnv, fetchImpl: typeof fetch = fetch, ): Promise { const baseUrl = env.SUPABASE_URL?.trim().replace(/\/+$/, '') ?? '' const serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY?.trim() ?? '' if (!baseUrl || serviceRoleKey.length < 20) { return { ok: false, status: 503, error: 'push_drain_not_configured' } } const limit = resolveBatchLimit(env.PUSH_DRAIN_BATCH_LIMIT) let response: Response try { response = await fetchImpl( `${baseUrl}/functions/v1/send-push?mode=drain&limit=${limit}`, { method: 'POST', headers: { Authorization: `Bearer ${serviceRoleKey}`, 'Content-Type': 'application/json', }, body: '{}', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }, ) } catch { return { ok: false, status: 502, error: 'push_drain_unreachable' } } if (!response.ok) { return { ok: false, status: response.status, error: `push_drain_http_${response.status}` } } let body: Record = {} try { const parsed = await response.json() if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { body = parsed as Record } } catch { body = {} } return { ok: true, status: response.status, claimed: typeof body.claimed === 'number' ? body.claimed : undefined, completed: typeof body.completed === 'number' ? body.completed : undefined, pending: typeof body.pending === 'number' ? body.pending : undefined, } }