feat: add durable asset synchronization worker
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user