sveltelean Query
Version v0.2.0

Mutations

edit-member.svelte
<script lang="ts">
	import { createMutation } from '@svelte-lean/query';
	import { updateMember } from '$api/members';
	import type { Member } from '$api/types';

	let { member, onsaved }: { member: Member; onsaved: () => void } = $props();
	const save = createMutation(updateMember, { onSuccess: () => onsaved() });
</script>

<form
	onsubmit={(event) => {
		event.preventDefault();
		const form = new FormData(event.currentTarget);
		// The input is one object: path params and body fields together.
		void save.run({
			id: member.id,
			name: String(form.get('name')),
			team: String(form.get('team'))
		});
	}}
>
	<label>Name <input name="name" value={member.name} required /></label>
	<label>Team <input name="team" value={member.team} required /></label>
	<!-- busy covers queued and running; a second submit while busy returns the same promise. -->
	<button disabled={save.busy || !save.allowed()}>Save</button>
	{#if save.error}<p role="alert">{save.error.message || 'The member was not saved.'}</p>{/if}
</form>
FieldMeaning
run(input, options?)Sends the write; resolves the result, rejects a QueryError.
status'idle', 'queued' (behind a write with the same queue key), 'running', 'success', 'error'.
busyqueued or running: disable the button on it.
errorA QueryError; its field errors are typed by the body.
dataThe last result.
allowed(input?)The access verdict: without input for the button, with input for one row.
progressUpload progress when the endpoint asks for it.
slowThe write has run longer than slowAfter.
reset()Back to idle.
  • A second run() while one is busy returns the same promise (exclusive), whatever the body (JSON, files, a FormData); exclusive: false runs them side by side, queue one after the other.
  • With settle: 'refetched', the run stays busy and onSuccess waits until the lists it invalidated show the change.
  • A new run clears error, so field marks leave while saving.
  • onSuccess belongs to its run: a newer run or leaving the page does not drop it.
  • Writes are not retried unless the definition says so.

Changing cached values

CallWhat it does
invalidate(targets)Refetch: observed entries ask the server again, the others go stale.
patch(target, update)Change the cached value now, without a request.
optimistic: (input) => patch(…)A patch shown from run() and undone if the write fails.

Invalidation

src/lib/api/members.ts
export const updateMember = defineMutation({
	patch: 'api/members/{id}',
	body: type<Partial<MemberInput>>(),
	result: type<Member>(),
	invalidates: (member) => [tag('Member', member.id), 'Members'],
	access: 'members.edit'
});

After success, the observed entries that carry Member:<id> or Members refetch once, even when a poll or a live event lands at the same time; the others are only marked stale. To wait for those refetches:

settle
// run() resolves once the invalidated lists on screen have refetched, not on the answer.
const save = createMutation(updateMember, { settle: 'refetched' });

From code

invalidate()
// Anything the definition cannot know: an import, a websocket message, a button.
invalidate('Members');
invalidate([tag('Member', 7), 'Teams'], { reason: 'bulk-import' });
listMembers.invalidate({ team: 'Design' });

A server may name what changed in its answer; laravel({ hints }) reads it, and the client refetches without a second rule in the application.

Patch

patch the saved row
// The server answers with the saved row: replace it in every cached page and detail.
const save = createMutation(updateMember, {
	onSuccess: (member) => patch(tag('Member', member.id), (): Member => member)
});

On an entity tag, patch reaches every cached copy of the row: each page that holds it and its detail, without a request. Endpoints that list members declare entity: 'Member'. An endpoint, [endpoint, params] or a tag name patches whole values; undefined from the update changes nothing.

Removing an entity

after a delete
const remove = createMutation(deleteMember, {
	// null removes the entity: unpaged arrays lose the row at once; pages and the detail refetch.
	onSuccess: (_, { id }) => patch(tag('Member', id), () => null)
});

Optimistic updates

Pin a member, then refuse the next write and pin another: only that row goes back.

Optimistic pin

The example runs in the browser.

Requests the server received

  1. None yet.
Code
<script lang="ts">
	import { createMutation, createQuery, patch, tag } from '@svelte-lean/query';
	import { listMembers, pinMember } from '$api/members';
	import type { Member } from '$api/types';

	const members = createQuery(listMembers, () => ({}));
	const pin = createMutation(pinMember, {
		// Written like a permanent patch; shown from run() until the server confirms, and a
		// failure removes only this layer. The tag reaches every cached page and the detail.
		optimistic: ({ id, pinned }) => patch(tag('Member', id), (m: Member) => ({ ...m, pinned }))
	});
</script>

<ul>
	{#each members.data?.rows ?? [] as member (member.id)}
		<li>
			{member.name}
			<button
				aria-pressed={member.pinned}
				onclick={() => pin.run({ id: member.id, pinned: !member.pinned }).catch(() => {})}
			>
				Pinned
			</button>
		</li>
	{/each}
</ul>
{#if pin.error}<p role="alert">Not saved: {pin.error.message}</p>{/if}
The layer is a function over the server value, not a snapshot: a refetch underneath does not lose it.
EventWhat happens
run() is calledThe patch is applied to every entry of the target, also while queued.
A poll or a refetch lands meanwhileThe patch is applied over the new server value.
The write fails or is abortedIts own layer is removed; nothing else is touched.
The write succeedsThe layer stays until the invalidated entry commits a newer answer, then it is removed.
patch(…, { until }) is givenAfter success it stays until the server value satisfies until.

Queues

src/lib/api/members.ts
export const pinMember = defineMutation({
	put: 'api/members/{id}/pin',
	body: type<{ pinned: boolean }>(),
	result: type<Member>(),
	invalidates: (member) => [tag('Member', member.id)],
	queue: (input) => `member:${input.id}` // one write per member at a time, in order
});

Writes with the same queue key run in order; the later ones read queued and their optimistic patches are already on screen. Rapid clicks on one switch end in the state of the last click.

Errors

A failed write stores its error and rejects the promise; a component that renders save.error needs no try. A write the access rule refuses rejects with kind forbidden and sends nothing. Field errors into a form: Forms.