export type SyncJobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'quarantined'; export type SyncJob = { id: string; dataset: 'assets'; characterId: number; status: SyncJobStatus; attempts: number; dueAt: number; createdAt: number; updatedAt: number; leaseExpiresAt: number | null; cancelRequested: boolean; error?: string; }; export type SyncSkip = { characterId: number; reason: 'authorization_required' | 'queue_full' | 'enqueue_failed' }; export type SyncEnqueueResponse = { queued: number; jobs: SyncJob[]; skipped: SyncSkip[]; partial?: boolean; demo?: boolean }; const JOB_STATUSES = new Set(['queued', 'running', 'succeeded', 'failed', 'cancelled', 'quarantined']); const SKIP_REASONS = new Set(['authorization_required', 'queue_full', 'enqueue_failed']); function parseJob(value: unknown): SyncJob | null { if (!value || typeof value !== 'object') return null; const row = value as Record; if (typeof row.id !== 'string' || typeof row.characterId !== 'number' || !Number.isSafeInteger(row.characterId) || typeof row.status !== 'string' || !JOB_STATUSES.has(row.status as SyncJobStatus)) return null; return { id: row.id, dataset: 'assets', characterId: row.characterId, status: row.status as SyncJobStatus, attempts: typeof row.attempts === 'number' ? row.attempts : 0, dueAt: typeof row.dueAt === 'number' ? row.dueAt : 0, createdAt: typeof row.createdAt === 'number' ? row.createdAt : 0, updatedAt: typeof row.updatedAt === 'number' ? row.updatedAt : 0, leaseExpiresAt: typeof row.leaseExpiresAt === 'number' ? row.leaseExpiresAt : null, cancelRequested: row.cancelRequested === true, ...(typeof row.error === 'string' ? { error: row.error } : {}), }; } export function parseSyncEnqueueBody(value: unknown): SyncEnqueueResponse | { error: string } { if (!value || typeof value !== 'object') return { error: 'Synchronization response was invalid.' }; const body = value as Record; if (!Array.isArray(body.jobs) || !Array.isArray(body.skipped)) return { error: typeof body.error === 'string' ? body.error : 'Synchronization response was invalid.' }; const jobs = body.jobs.map(parseJob); const skipped = body.skipped.map((entry: unknown) => { if (!entry || typeof entry !== 'object') return null; const row = entry as Record; return typeof row.characterId === 'number' && Number.isSafeInteger(row.characterId) && typeof row.reason === 'string' && SKIP_REASONS.has(row.reason as SyncSkip['reason']) ? { characterId: row.characterId, reason: row.reason as SyncSkip['reason'] } : null; }); if (jobs.some((job) => job === null) || skipped.some((entry) => entry === null)) return { error: 'Synchronization response was invalid.' }; const queued = typeof body.queued === 'number' && Number.isSafeInteger(body.queued) && body.queued >= 0 ? body.queued : jobs.length; return { queued, jobs: jobs as SyncJob[], skipped: skipped as SyncSkip[], ...(body.partial === true ? { partial: true } : {}), ...(body.demo === true ? { demo: true } : {}) }; } export async function enqueueSync(signal?: AbortSignal): Promise<{ response: Response; body: SyncEnqueueResponse | { error: string } }> { const response = await fetch('/api/sync', signal ? { method: 'POST', signal } : { method: 'POST' }); return { response, body: parseSyncEnqueueBody(await response.json().catch(() => null)) }; } export const SYNC_STATUS_TIMEOUT_MS = 15_000; export async function syncJobStatus(id: string, signal?: AbortSignal, timeoutMs = SYNC_STATUS_TIMEOUT_MS): Promise { const requestController = new AbortController(); let timeout: ReturnType | undefined; let onAbort: (() => void) | undefined; const aborted = new Promise((_, reject) => { onAbort = () => { requestController.abort(); reject(new DOMException('The operation was aborted', 'AbortError')); }; if (signal?.aborted) onAbort(); else signal?.addEventListener('abort', onAbort, { once: true }); }); const timedOut = new Promise((_, reject) => { timeout = setTimeout(() => { requestController.abort(); reject(new Error('Synchronization status timed out.')); }, timeoutMs); }); try { const response = await Promise.race([fetch(`/api/sync/status?job=${encodeURIComponent(id)}`, { signal: requestController.signal }), timedOut, aborted]); if (response.status === 404) return null; if (!response.ok) throw new Error('Synchronization status unavailable.'); return await response.json() as SyncJob; } finally { if (timeout !== undefined) clearTimeout(timeout); if (signal && onAbort) signal.removeEventListener('abort', onAbort); } } export async function cancelSyncJob(id: string): Promise { const response = await fetch('/api/sync/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jobId: id }), }); if (!response.ok) throw new Error('Could not cancel synchronization.'); return await response.json() as SyncJob; } export function syncTerminal(status: SyncJobStatus): boolean { return status === 'succeeded' || status === 'failed' || status === 'cancelled' || status === 'quarantined'; }