feat: add durable asset synchronization worker

This commit is contained in:
jb
2026-09-19 22:15:23 -04:00
parent 2675ea8114
commit fe7d218195
36 changed files with 2218 additions and 189 deletions
+48 -8
View File
@@ -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} />