import { createTRPCClient, httpBatchLink } from '@trpc/client'; import superjson from 'superjson'; import type { AppRouter } from '@repo/api'; import { db } from './db'; import { broadcast } from './broadcast'; let _client: ReturnType> | null = null; function getClient() { if (!_client) { _client = createTRPCClient({ links: [httpBatchLink({ url: '/api/trpc', transformer: superjson })], }); } return _client; } function httpStatus(err: unknown): number | null { if (typeof err === 'object' && err !== null && 'data' in err) { const d = (err as { data?: { httpStatus?: number } }).data; return d?.httpStatus ?? null; } return null; } function isNetworkErr(err: unknown): boolean { return err instanceof TypeError; } let _running = false; export async function runSync(): Promise { if (_running || !navigator.onLine) return; _running = true; try { const items = await db.pending.orderBy('queuedAt').toArray(); if (!items.length) return; const client = getClient(); for (const item of items) { try { let photoKey: string | undefined; if (item.photoBlob) { const { uploadUrl, photoKey: key } = await client.storage.signPhotoUpload.mutate({ contentType: 'image/jpeg', byteSize: item.photoBlob.size, }); const res = await fetch(uploadUrl, { method: 'PUT', body: item.photoBlob, headers: { 'Content-Type': 'image/jpeg' }, }); if (!res.ok) throw new Error(`Photo PUT ${res.status}`); photoKey = key; } await client.maintenanceRequest.create.mutate({ workstationId: item.workstationId, description: item.description, photoKey, clientRequestId: item.clientRequestId, }); await db.pending.delete(item.clientRequestId); broadcast({ type: 'synced', clientRequestId: item.clientRequestId }); } catch (err) { if (isNetworkErr(err)) break; // no connectivity — stop this cycle const status = httpStatus(err); if (status === 409) { // Already exists (duplicate clientRequestId) — idempotent success await db.pending.delete(item.clientRequestId); broadcast({ type: 'synced', clientRequestId: item.clientRequestId }); continue; } if (status !== null && status >= 400 && status < 500) { // 4xx other than 409 — permanent failure, move to dead-letter await db.deadLetters.put({ clientRequestId: item.clientRequestId, error: err instanceof Error ? err.message : String(err), failedAt: Date.now(), }); await db.pending.delete(item.clientRequestId); broadcast({ type: 'dead-letter', clientRequestId: item.clientRequestId }); continue; } // 5xx / unknown — increment retries, leave in queue await db.pending.update(item.clientRequestId, { retries: (item.retries ?? 0) + 1, lastError: err instanceof Error ? err.message : String(err), }); } } const remaining = await db.pending.count(); broadcast({ type: 'queue-update', pendingCount: remaining }); } finally { _running = false; } }