Query Recipes
Watch a job until it ends
A server job polled until it finishes: the definition says how often and when to stop, invalidates what the job changed once, and the result downloads as a file.
Markdown version of this page, for assistants (Markdown for AI)
On this page
Example
Start an export and wait for it; the file downloads as members.csv.
The example runs in the browser.
Requests the server received
- None yet.
Code
<!-- src/lib/members/ExportButton.svelte -->
<script lang="ts">
import { createMutation, createQuery } from '@svelte-lean/query';
import { downloadExport, getJob, startExport } from '$api/jobs';
let jobId = $state<string | null>(null);
const start = createMutation(startExport, {
onSuccess: (job) => {
getJob.set({ id: job.id }, job); // seed the entry: the end is a transition even if fast
jobId = job.id;
}
});
// getJob polls every 2 s until the job ends, then invalidates 'Exports' once.
const job = createQuery(getJob, () => (jobId ? { id: jobId } : undefined));
async function download(id: string) {
const file = await downloadExport.fetch({ id });
const url = URL.createObjectURL(file.blob);
const link = Object.assign(document.createElement('a'), {
href: url,
download: file.filename ?? 'export.csv'
});
link.click();
URL.revokeObjectURL(url);
}
</script>
<button
type="button"
disabled={start.busy || job.data?.state === 'running'}
onclick={() => start.run({}).catch(() => {})}
>
Export members
</button>
{#if start.error}<p role="alert">{start.error.message}</p>{/if}
{#if job.data}
<label>
{job.data.state === 'done' ? 'Ready' : 'Exporting'}
<progress max="100" value={job.data.progress}></progress>
</label>
{#if job.data.state === 'done'}
<button type="button" onclick={() => download(job.data!.id)}>Download {job.data.file}</button>
{/if}
{/if}src/lib/api/jobs.ts
import { defineMutation, defineQuery, file, tag, type } from '@svelte-lean/query';
import type { Job, MemberFilters } from './types';
declare module '@svelte-lean/query' {
interface Tags {
Job: string;
Exports: void;
}
}
export const startExport = defineMutation({
post: 'api/exports',
body: type<MemberFilters>(),
result: type<Job>()
});
export const listExports = defineQuery({
get: 'api/exports',
result: type<Job[]>(),
tags: ['Exports']
});
export const getJob = defineQuery({
get: 'api/jobs/{id}',
result: type<Job>(),
tags: (job) => [tag('Job', job.id)],
// Poll while the job runs; on the commit that finishes it, stop and invalidate once.
poll: {
every: '2s',
until: (job) => job.state === 'done' || job.state === 'failed',
invalidates: ['Exports']
}
});
export const downloadExport = defineQuery({
get: 'api/jobs/{id}/file',
result: file() // a FileResult: blob, filename from Content-Disposition, type
});How it works
| Moment | Request |
|---|---|
| Export | POST api/exports; onSuccess seeds the job entry with the answer |
| While the job runs | GET api/jobs/export-1 on a fixed grid, cause poll, shown as refreshing |
| Tab hidden | no poll; one request when the tab is visible again |
The commit where until holds | polling stops; invalidates: ['Exports'] runs once |
| Download | GET api/jobs/export-1/file; the answer is a FileResult |
invalidatesruns on the transition to done only: a page that mounts a finished job invalidates nothing. Seeding the entry with the start answer makes a job that ends before the first poll a transition too.- Polling belongs to the definition, so every screen that shows the job polls it the same way; a
screen can add its own
poll, and the shortest active interval wins. file()makeshttp()read the body as a blob and the file name fromContent-Disposition.
Variations
A period that depends on the value: poll: (batch) => (batch.active ? '3s' : false) on the definition. Polling while the tab is hidden: background: true in the object
form; browsers throttle hidden timers, so freshness that matters in the background belongs to a realtime source.