Files
eden/src/lib/client/sync-queue.ts
T

96 lines
5.2 KiB
TypeScript

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<SyncJobStatus>(['queued', 'running', 'succeeded', 'failed', 'cancelled', 'quarantined']);
const SKIP_REASONS = new Set<SyncSkip['reason']>(['authorization_required', 'queue_full', 'enqueue_failed']);
function parseJob(value: unknown): SyncJob | null {
if (!value || typeof value !== 'object') return null;
const row = value as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<SyncJob | null> {
const requestController = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const aborted = new Promise<never>((_, 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<never>((_, 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<SyncJob | null> {
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';
}