Query Every day
Defining endpoints
An endpoint is a value: method, path, types, tags, freshness and access, defined once in the feature that owns it and imported wherever it is used.
Markdown version of this page, for assistants (Markdown for AI)
On this page
Queries
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.
| Option | Meaning | Level |
|---|---|---|
get / post | The method and the path; {name} segments are params ({+name}: unencoded). | 1 |
params | Search params (GET) or the body (POST reads): type<T>() or a schema. | 1 |
result | type<T>(), page<T>(), file() or a Standard Schema validator. | 1 |
tags | What the entry provides: names, tag(name, id), or a function of the answer. | 1 |
access | Who may read it: a permission, a function, all / any / not. | 1 |
fresh | How long a value counts as fresh. Default from the client (0). | 1 |
entity | Every row (or the value) carries tag(entity, row id). | 3 |
keep | How long an unobserved entry stays. Default '5m'. | 2 |
poll | A period, a function of the value, or a job: { every, until, invalidates }. | 2 |
retry | Retries of a failed read. Default from the client (2). | 2 |
persist | Store the value with ./persist. | 3 |
map | Reshape the answer once per commit; receives the params. | 3 |
search | Params 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 |
pageParams | The page params createPaged / createInfinite send, renamed or left out. | 3 |
id | Unique id when one URL is read two ways. Default METHOD path. Two endpoints with one id are a development error. | 3 |
scope: false | A public endpoint outside the client scope. | 3 |
orphan | 'abort' (default) or 'finish' a request nobody waits for any more. | 3 |
rowId, share, maxEntries, transport | Row identity, identity reuse, a cap, transport options. | 3 |
Params and results
// `{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: 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 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]}// `{+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: `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()
});// 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 })
});// 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
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'
});| Option | Meaning | Level |
|---|---|---|
post / put / patch / delete | The method and the path; the input holds params and body. | 1 |
body | The body type: type<T>() or a schema. | 1 |
result | The answer; omitted means void. | 1 |
invalidates | Tags refetched after success: a list or a function of result and input. | 1 |
access | Who may run it; may depend on the input. | 1 |
queue | Writes 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 |
search | Input fields in the query string: true or a list of keys. | 3 |
map | Reshape the answer; receives the input. | 3 |
retry | Writes are not retried unless declared. | 3 |
// `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
// 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 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))]
});| Target | Reaches |
|---|---|
tag('Member', 7) | Entries that carry Member:7: the row in every page, the detail. |
'Members' | Every entry that provides Members, with any id. |
listMembers | Every entry of the endpoint. |
[listMembers, { team: 'Design' }] | Entries whose params contain these fields. |
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 endpointFreshness
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
// 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".
| Rule | true when | false when | otherwise |
|---|---|---|---|
all(…) | every check is true | any check is false | undefined |
any(…) | any check is true | every check is false | undefined |
not(…) | the check is false | the check is true | undefined |
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.
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