sveltelean Query
Version v0.2.0

Install

npm install @svelte-lean/query

Client

Create it once in the root layout. Components, classes and modules reach the same client; on the server each request gets its own (Server rendering).

src/routes/+layout.svelte
<!-- src/routes/+layout.svelte — one client for the application -->
<script lang="ts">
	import { createClient, setClient } from '@svelte-lean/query';
	import { http } from '@svelte-lean/query/http';
	import { can, session } from '$api/session.svelte';

	let { children } = $props();

	setClient(
		createClient({
			transport: http({
				base: '/',
				headers: () => ({ authorization: `Bearer ${session.token}` })
			}),
			scope: () => [session.workspace], // part of every cache key
			can // resolves the permission strings of `access` rules
		})
	);
</script>

{@render children()}

Transport

The core never calls fetch itself; http() does.

src/lib/api/transport.ts
// src/lib/api/transport.ts
import { http } from '@svelte-lean/query/http';
import { laravel } from '@svelte-lean/query/laravel';
import { refreshToken, session } from '$api/session.svelte';

export const transport = http({
	base: '/',
	headers: () => ({ authorization: `Bearer ${session.token}` }),
	timeout: '30s',
	// The platform's conventions: envelope, refusals, 422 field errors, paginators.
	profile: laravel(),
	// The last word on a response: refresh the token once on a 401 and send again.
	intercept: async (response, _request, retry) => {
		if (response.status !== 401) return response;
		await refreshToken();
		return retry();
	}
});
OptionDefaultUse
base'/' or a function of the requestPrefix of every path; a function picks an origin per request.
headers(request) => HeadersInitRead per request: a token that changes is sent fresh.
timeout'30s'Per attempt; a timeout is a typed error and is retried for queries.
profilenone: JSON in, JSON outThe platform conventions (laravel(), or your own).
interceptnoneThe last word on a response: refresh and retry, sign out.
fetchglobalThis.fetchSvelteKit's fetch in a load, a fake in tests.

Profile

A profile states the server's conventions once. laravel() unwraps the { success, data, message } envelope, turns success: false into a typed refusal that is never retried, reads 422 field errors and paginators, and writes query strings and multipart bodies the way Laravel reads them. Any server can have its own profile: the profile contract.

Scope

The scope prefixes every cache key, so the data of one workspace, tenant or region never answers for another.

createClient options
// Part of every cache key: entries of one workspace never answer another.
scope: () => [session.workspace],
// On a change, the previous workspace's entries are dropped (the default) or kept.
onScopeChange: 'drop',

Access

Endpoints name permissions; the client resolves them. Three answers: true sends, false sends nothing and removes cached data, undefined (rights still loading) waits and keeps what is shown.

createClient options
// Resolves the permission strings of `access` rules. `undefined` = not known yet.
can,
// One more rule for every endpoint, combined with each endpoint's own `access`.
access: () => (session.userId === null ? false : true),

Defaults and limits

createClient options
defaults: {
	fresh: '10s', // default 0: stale at once, revalidated on the next use
	keep: '10m', // default '5m': how long an unobserved entry stays
	retry: 2, // queries only; writes are never retried unless they say so
	slowAfter: '8s', // `info.slow` turns true after this
	focus: true // revalidate stale observed entries when the tab is shown again
},
limits: {
	concurrency: 'auto', // 6 per origin; 'auto' raises it once HTTP/2 or HTTP/3 is seen
	inactiveEntries: 2000 // with inactiveBytes: the budget of unobserved entries
},
OptionDefaultMeaning
defaults.fresh0How long a value counts as fresh; stale values are revalidated on use.
defaults.keep'5m'How long an entry nobody shows stays cached.
defaults.retry2Retries of a failed read (network, timeout, 5xx, 408, 429). Writes: 0.
defaults.slowAfter'8s'When the slow flag of a fetch or a write turns on.
defaults.loadingDelay0With data on screen, a fetch reads as refreshing until it has run this long.
defaults.focustrueRevalidate stale entries on screen when the tab is shown again.
limits.concurrency6Requests at once per origin; 'auto' raises it to 24 once an answer reports HTTP/2 or HTTP/3, which a cross-origin API shows only with Timing-Allow-Origin (otherwise set a number).
limits.inactiveBytes33554432Byte budget of unobserved entries; the oldest leave first.
limits.inactiveEntries2000Count budget of unobserved entries.

An endpoint's own fresh, keep and retry win over these.

Whatever the cap, background work never takes an origin's last slot: invalidations, polls, prefetches and endpoints declared priority: 'low' (a dashboard's figures) queue behind the reads a page waits for. A use or a call can say priority too.

Modules

Optional behavior is a module, imported from its own entry point and set up with the client.

createClient options
// Optional behavior, each its own import: nothing else is in the bundle.
modules: [devtools(), tabs({ session: () => String(session.userId) })]
Entry pointModuleGuide
./livelive(source, rules)Realtime
./tabstabs({ session })Realtime
./persistpersist({ store, session })Persistence
./ssrkitInvalidate({ invalidate })Server rendering
./devtoolsdevtools({ panel: true })Devtools

A module of your own

src/lib/api/audit-module.ts
// Reports every failed answer; setup returns the teardown.
export const reportFailures = defineModule({
	name: 'report-failures',
	setup(_client, hooks) {
		return hooks.onCommit((entry) => {
			if (entry.status === 'error') console.warn(entry.endpoint.id, entry.error?.kind);
		});
	}
});
// createClient({ transport, modules: [reportFailures] })

defineModule brings the hook set with the module, so a client without modules carries none of it. Hooks: ModuleHooks.

Engine parts

Polling, connectivity, access rules, memory collection, the request limiter, identity reuse and the param index are optional parts of the engine.

  • Small apps: nothing to do. Each part loads the first time the client needs it; an application that never polls never downloads the polling code.
  • Large apps: include the parts statically. An admin panel that polls, checks rights and runs long sessions uses every part anyway; listing them puts them in the first load, with no extra chunk request and no loading step.
src/routes/+layout.svelte
// Every engine part in the first load: no chunk request, no loading step.
createClient({ transport: http({ base: '/' }), parts: allParts });
only some
import { access, limiter, polling } from '@svelte-lean/query/parts';

// Only some; the others still load on first use.
createClient({ transport: http({ base: '/' }), parts: [access, limiter, polling] });

Sizes per part and of allParts: Performance. Reference: @svelte-lean/query/parts.

Outside components

src/lib/session.ts
// A class or a plain module reaches the same client: the browser has one per page.
import { getClient, invalidate, send } from '@svelte-lean/query';

export function signOut() {
	getClient().clear(); // abort everything, drop every entry and optimistic layer
}

export function afterImport() {
	invalidate(['Members', 'Teams']); // the free form of getClient().invalidate(…)
}

In the browser the first client created is the realm client: components, classes, modules and code after an await reach it. setClient(client) makes another one the realm client (in a component it also sets the context; in a test's beforeEach it only sets the realm), and disposing it hands the realm back to the client it replaced. onSuccess and optimistic always act on their mutation's client.

One request without the cache

For an endpoint the definitions cannot describe: the transport's headers, envelope and errors apply.

src/lib/health.ts
// One request through the transport (headers, envelope, typed errors) without the cache.
export async function ping(): Promise<{ ok: boolean }> {
	return send<{ ok: boolean }>({ method: 'GET', path: 'api/health' });
}