sveltelean Query
Version v0.2.0

Queries

src/lib/api/members.ts
import { defineMutation, defineQuery, page, tag, type } from '@svelte-lean/query';
import type { Member, MemberFilters, MemberInput, Team } from './types';

export const listMembers = defineQuery({
	get: 'api/members',
	params: type<MemberFilters>(),
	result: page<Member>(),
	entity: 'Member', // every row carries tag('Member', row.id)
	tags: ['Members'],
	fresh: '15s',
	access: 'members.view'
});

export const getMember = defineQuery({
	get: 'api/members/{id}',
	result: type<Member>(),
	entity: 'Member',
	access: 'members.view'
});

The params are the cache key. Two equal params objects are one entry, whatever their key order; an endpoint nobody imports is removed from the bundle.

OptionMeaningLevel
get / postThe method and the path; {name} segments are params ({+name}: unencoded).1
paramsSearch params (GET) or the body (POST reads): type<T>() or a schema.1
resulttype<T>(), page<T>(), file() or a Standard Schema validator.1
tagsWhat the entry provides: names, tag(name, id), or a function of the answer.1
accessWho may read it: a permission, a function, all / any / not.1
freshHow long a value counts as fresh. Default from the client (0).1
entityEvery row (or the value) carries tag(entity, row id).3
keepHow long an unobserved entry stays. Default '5m'.2
pollA period, a function of the value, or a job: { every, until, invalidates }.2
retryRetries of a failed read. Default from the client (2).2
persistStore the value with ./persist.3
mapReshape the answer once per commit; receives the params.3
searchParams in the query string whatever the method: true or a list of keys.3
priority'low': background work (figures) that queues behind the reads a page waits for.3
pageParamsThe page params createPaged / createInfinite send, renamed or left out.3
idUnique id when one URL is read two ways. Default METHOD path. Two endpoints with one id are a development error.3
scope: falseA public endpoint outside the client scope.3
orphan'abort' (default) or 'finish' a request nobody waits for any more.3
rowId, share, maxEntries, transportRow identity, identity reuse, a cap, transport options.3

Params and results

path params
// `{team}` and `{id}` are required params, typed string | number, encoded into the URL.
export const getTeamMember = defineQuery({
	get: 'api/teams/{team}/members/{id}',
	result: type<Member>(),
	entity: 'Member'
});
// getTeamMember.fetch({ team: 'design', id: 7 }) → GET api/teams/design/members/7
a read sent as POST
// A read sent as POST: the params are the JSON body, and still the cache key.
export const searchMembers = defineQuery({
	post: 'api/members/search',
	params: type<{ query: string; teams: string[] }>(),
	result: type<Member[]>(),
	tags: ['Members']
});
params in the query string
// Params in the query string whatever the method: `true` (all of them, no body) or these keys.
export const memberReport = defineQuery({
	post: 'api/members/report',
	params: type<{ team: string; from: string }>(),
	result: type<{ total: number }>(),
	search: true
});
// memberReport.fetch({ team: 'design', from: '2026-01-01' })
//   → POST api/members/report?team=design&from=2026-01-01, no body
export const reorderMembers = defineMutation({
	put: 'api/teams/{team}/order',
	body: type<{ ids: number[]; dry_run?: boolean }>(),
	search: ['dry_run']
});
// reorderMembers.run({ team: 'design', ids: [3, 1], dry_run: true })
//   → PUT api/teams/design/order?dry_run=true, body {"ids":[3,1]}
an address from the server
// `{+name}` is sent as given: an address the server handed over keeps its `/`, `?` and `&`;
// a full URL is requested as it is.
export const optionsFrom = defineQuery({
	get: '{+source}',
	params: type<{ search?: string }>(),
	result: type<{ id: number; name: string }[]>()
});
// optionsFrom.fetch({ source: 'api/teams/options?active=1', search: 'de' })
//   → GET api/teams/options?active=1&search=de
a download
// A download: `data` is { blob, filename, type }, the name from Content-Disposition. A JSON
// answer counts as a file only when it is an attachment; any other JSON rejects (kind 'parse').
export const exportMembers = defineQuery({
	get: 'api/members/export',
	params: type<{ format: 'csv' | 'xlsx' }>(),
	result: file()
});
a nested paginator
// The server nests its paginator: reshape the answer once per commit. `params` are the params the
// entry was asked with; `pageParams` names the page params createPaged / createInfinite send.
export const listTeamMembers = defineQuery({
	get: 'api/teams/{team}/members',
	params: type<{ role?: string }>(),
	result: page<Member>(),
	map: (raw: { members: Member[] }, { page, params }) =>
		page(params.role ? raw.members.filter((m) => m.role === params.role) : raw.members),
	pageParams: (n, size) => ({ p: n, size })
});
background figures
// A dashboard's figures are background work: they queue behind the reads a page waits for and
// never take the last request slot. Per use: createQuery(…, { priority }); per call: { priority }.
export const memberStats = defineQuery({
	get: 'api/members/stats',
	result: type<{ active: number; invited: number }>(),
	priority: 'low'
});

type<T>() types without checking. Any Standard Schema validator (Zod, Valibot, ArkType) in its place validates every answer; a failure is an error of kind parse.

Mutations

src/lib/api/members.ts
export const createMember = defineMutation({
	post: 'api/members',
	body: type<MemberInput>(),
	result: type<Member>(),
	invalidates: ['Members', 'Teams'],
	access: 'members.edit'
});

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'
});

export const deleteMember = defineMutation({
	delete: 'api/members/{id}',
	invalidates: ['Members', 'Teams'],
	access: 'members.delete'
});
OptionMeaningLevel
post / put / patch / deleteThe method and the path; the input holds params and body.1
bodyThe body type: type<T>() or a schema.1
resultThe answer; omitted means void.1
invalidatesTags refetched after success: a list or a function of result and input.1
accessWho may run it; may depend on the input.1
queueWrites with the same key run one after the other.2
encode'json', 'multipart' or 'auto' (multipart when the body holds a file); json and multipart always send a body.3
searchInput fields in the query string: true or a list of keys.3
mapReshape the answer; receives the input.3
retryWrites are not retried unless declared.3
an empty JSON body
// `encode: 'json'` always sends a body: `{}` when the input holds only path params.
export const archiveMember = defineMutation({
	post: 'api/members/{id}/archive',
	encode: 'json'
});

The input is one object: path params and body fields. A FormData input is sent as it is (its path params are its own properties or entries). With laravel(), a multipart PUT, PATCH or DELETE is sent as POST with _method; transport: { methodSpoof: true } spoofs a JSON one too.

After a successful write, observed entries that carry an invalidated tag refetch once; the others are marked stale. When the outcome is unknown (network error, timeout), the static targets are invalidated anyway. Using mutations: Writing.

Tags

src/lib/api/members.ts
// The tag and permission registries: a typo in a tag or a permission is a compile error.
declare module '@svelte-lean/query' {
	interface Tags {
		Member: number;
		Members: void;
		Teams: void;
	}
	interface Permissions {
		'members.view': true;
		'members.edit': true;
		'members.delete': true;
	}
}

Declared names make a misspelled tag or permission a compile error. An entry provides tags (tags, entity); a write or a call targets them.

tags from the answer
// Tags computed from the answer: a change to any of its members refetches the team.
export const getTeam = defineQuery({
	get: 'api/teams/{id}',
	result: type<Team & { memberIds: number[] }>(),
	tags: (team) => ['Teams', ...team.memberIds.map((id) => tag('Member', id))]
});
TargetReaches
tag('Member', 7)Entries that carry Member:7: the row in every page, the detail.
'Members'Every entry that provides Members, with any id.
listMembersEvery entry of the endpoint.
[listMembers, { team: 'Design' }]Entries whose params contain these fields.
invalidate()
invalidate(tag('Member', id)); // entries that carry Member:<id>: rows in pages, the detail
invalidate('Members'); // every entry that provides Members
invalidate(listMembers); // every entry of one endpoint
invalidate([listMembers, { team: 'Design' }]); // entries whose params contain these fields
listMembers.invalidate({ team: 'Design' }); // the same, from the endpoint

Freshness

fresh says how long a value is served without asking again; keep how long it stays once nothing shows it; poll refetches while it is on screen. A stale value is still shown: it is revalidated in the background and the binding reads refreshing. Durations are a number of milliseconds or a string such as '15s', '5m', '1h', or 'never'.

Access

access rules
// One permission, resolved by the client's `can`.
export const listAll = defineQuery({
	get: 'api/members/all',
	result: type<Member[]>(),
	access: 'members.view'
});

// One of them is enough.
export const listMine = defineQuery({
	get: 'api/members/mine',
	result: type<Member[]>(),
	access: any('members.view', 'members.view-own')
});

// Depends on the input: promoting to owner needs one more permission.
export const setRole = defineMutation({
	put: 'api/members/{id}/role',
	body: type<{ role: Member['role'] }>(),
	access: all('members.edit', (input) => (input.role === 'owner' ? 'members.owners' : true))
});

// Any code: a read-only session may not delete.
export const removeAll = defineMutation({
	delete: 'api/members',
	access: all(
		'members.delete',
		not(() => session.userId === null)
	)
});

Rules are three-valued: undefined means "not known yet", so rights that are still loading never turn into "forbidden".

Ruletrue whenfalse whenotherwise
all(…)every check is trueany check is falseundefined
any(…)any check is trueevery check is falseundefined
not(…)the check is falsethe check is trueundefined

false sends nothing, removes the cached values and rejects writes with kind forbidden. A state condition that is not about rights (a closed dialog, a missing id) belongs where the query is read: Reading.

Imperative calls

For code outside components: loaders, event handlers, prefetch on hover.

endpoint methods
const member = await getMember.fetch({ id }); // cached and fresh, or one request
getMember.prefetch({ id: id + 1 }); // low priority; an error stays on the entry
const cached = getMember.get({ id }); // synchronous; undefined when not cached
getMember.set({ id }, { ...member, pinned: true }); // a local write, shown everywhere
getMember.invalidate({ id }); // stale now; refetched where it is on screen
const visible = listMembers.allowed(); // the access verdict, without a request
await updateMember.run({ id, name: 'Mei Tanaka' }); // a write without reactive state