feat: add durable asset synchronization worker
This commit is contained in:
@@ -1,20 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import AssetResult from '$lib/components/AssetResult.svelte';
|
||||
import { cancelSyncJob, enqueueSync, syncJobStatus, syncTerminal, type SyncJob, type SyncSkip } from '$lib/client/sync-queue';
|
||||
let { data } = $props();
|
||||
let busy = $state(false); let failure = $state('');
|
||||
async function sync() {
|
||||
busy = true; failure = '';
|
||||
let jobs = $state<SyncJob[]>([]); let skipped = $state<SyncSkip[]>([]);
|
||||
let pollController: AbortController | null = null;
|
||||
let enqueueController: AbortController | null = null;
|
||||
let pollTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
function stopPolling() { pollController?.abort(); pollController = null; if (pollTimer !== undefined) clearTimeout(pollTimer); pollTimer = undefined; }
|
||||
async function poll(ids: string[]) {
|
||||
stopPolling();
|
||||
const controller = new AbortController(); pollController = controller;
|
||||
try {
|
||||
const response = await fetch('/api/sync', { method: 'POST' });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Synchronization failed');
|
||||
await invalidateAll();
|
||||
} catch (error) { failure = error instanceof Error ? error.message : 'Synchronization failed'; }
|
||||
finally { busy = false; }
|
||||
for (let attempt = 0; attempt < 120 && !controller.signal.aborted; attempt += 1) {
|
||||
const next = await Promise.all(ids.map((id) => syncJobStatus(id, controller.signal)));
|
||||
if (controller.signal.aborted) return;
|
||||
if (next.some((job) => job === null)) { busy = false; failure = 'A synchronization job disappeared before completion; no report retry was performed.'; return; }
|
||||
jobs = next as SyncJob[];
|
||||
if (jobs.every((job) => syncTerminal(job.status))) {
|
||||
busy = false;
|
||||
if (jobs.length > 0 && jobs.every((job) => job.status === 'succeeded') && skipped.every((entry) => entry.reason === 'authorization_required')) await invalidateAll();
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const done = () => { if (pollTimer !== undefined) clearTimeout(pollTimer); pollTimer = undefined; controller.signal.removeEventListener('abort', done); resolve(); };
|
||||
pollTimer = setTimeout(done, 1_000); controller.signal.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
}
|
||||
if (!controller.signal.aborted) { busy = false; failure = 'Synchronization is still running; this page will retain its queued status.'; }
|
||||
} catch (error) { if (!controller.signal.aborted) { busy = false; failure = error instanceof Error ? error.message : 'Synchronization status unavailable.'; } }
|
||||
finally { if (pollController === controller) pollController = null; }
|
||||
}
|
||||
onDestroy(() => { stopPolling(); enqueueController?.abort(); enqueueController = null; });
|
||||
async function sync() {
|
||||
stopPolling(); enqueueController?.abort(); const controller = new AbortController(); enqueueController = controller; busy = true; failure = '';
|
||||
try {
|
||||
const { response, body } = await enqueueSync(controller.signal);
|
||||
if ('jobs' in body) { jobs = body.jobs; skipped = body.skipped; }
|
||||
if (!response.ok || !('jobs' in body)) throw new Error(('error' in body && body.error) || (skipped.some((entry) => entry.reason === 'queue_full') ? 'Synchronization queue is full.' : 'Synchronization could not be queued.'));
|
||||
if (jobs.length > 0) await poll(jobs.map((job) => job.id));
|
||||
else busy = false;
|
||||
} catch (error) { if (!controller.signal.aborted) { busy = false; failure = error instanceof Error ? error.message : 'Synchronization failed'; } }
|
||||
finally { if (enqueueController === controller) enqueueController = null; }
|
||||
}
|
||||
async function cancel(id: string) {
|
||||
try { const job = await cancelSyncJob(id); if (job) jobs = jobs.map((entry) => entry.id === job.id ? job : entry); }
|
||||
catch (error) { failure = error instanceof Error ? error.message : 'Could not cancel synchronization.'; }
|
||||
}
|
||||
function skipLabel(reason: SyncSkip['reason']): string {
|
||||
return reason === 'authorization_required' ? 'authorization required' : reason === 'queue_full' ? 'queue full' : 'enqueue failed';
|
||||
}
|
||||
</script>
|
||||
<div class="row spread"><div><p class="eyebrow">SHARED DOMAIN VIEW</p><h1>Assets</h1></div><button class="quiet" disabled={busy} onclick={sync}>{busy ? 'Synchronizing…' : 'Synchronize characters'}</button></div>
|
||||
<p class="muted">All connected characters. The same deterministic report used by the assistant.</p>
|
||||
{#if failure}<p class="error" role="alert">{failure}</p>{/if}
|
||||
{#if jobs.length || skipped.length}<section class="panel" aria-live="polite"><h2>Synchronization status</h2>{#each jobs as job}<p class="row spread"><span>Character {job.characterId}: {job.status}{job.error ? ` — ${job.error}` : ''}</span>{#if job.status === 'queued' || job.status === 'running'}<button class="quiet" onclick={() => cancel(job.id)}>Cancel</button>{/if}</p>{/each}{#each skipped as entry}<p class="muted">Character {entry.characterId}: {skipLabel(entry.reason)}</p>{/each}</section>{/if}
|
||||
<AssetResult result={data.result} onSync={sync} />
|
||||
|
||||
Reference in New Issue
Block a user