# Defining endpoints

> defineQuery and defineMutation: method and path, params, result, tags, entities, freshness, access rules and the tag registry.

Page: https://svelte-lean.vercel.app/query/endpoints · Markdown: https://svelte-lean.vercel.app/query/endpoints.md · Index: https://svelte-lean.vercel.app/llms.txt

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.

## Queries

`src/lib/api/members.ts`

```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.

| 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

`path params`

```ts
// `{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`

```ts
// 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`

```ts
// 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`

```ts
// `{+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`

```ts
// 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`

```ts
// 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`

```ts
// 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`

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

| 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 |

`an empty JSON body`

```ts
// `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](https://svelte-lean.vercel.app/query/writing.md).

## Tags

`src/lib/api/members.ts`

```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`

```ts
// 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()`

```ts
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`

```ts
// 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](https://svelte-lean.vercel.app/query/reading.md).

## Imperative calls

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

`endpoint methods`

```ts
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
```
