96 lines
5.2 KiB
TypeScript
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';
|
|
}
|