Feature Requests
Complete

Centralized API layer: createApiQuery / createApiMutation / paged helper

Decided spec, superseding the brainstorm post "Centralize API calls: mandatory error handling, TanStack Query, and more (all TBD)". Every open question from that post is now settled; the reasoning is summarised below so the decisions can be re-litigated on their merits rather than re-derived.

Why

Web audit, current main:

  • 71 files import $lib/api/client; 56 use resolveApiError (110 non-spec occurrences).
  • 27 files call the API and never resolve an error at all.
  • 109 $effect in .svelte, 38 error = $state, 29 loading = $state.
  • 0 AbortController anywhere in apps/web.

The 27 uncovered files are not a theoretical risk — three are live bugs:

  • routes/app/feed/+page.svelte:15getFeed().then(...).finally(...), no .catch(). On API failure: unhandled rejection, and the user sees the empty state ("nobody you follow has done anything") instead of an error.
  • lib/components/ProfileReviews.svelte:29.catch(() => (reviews = [])). A failure renders as "no reviews".
  • routes/app/media/+page.svelte:50toast.error("Mise à jour impossible"), a hardcoded French string that bypasses both the error-code convention and Paraglide.

Per-component discipline has already failed three times. The fix is to make the error path structural: helpers that resolve the error themselves, so there is no try/catch left to forget.

Approach

Thin wrappers over @tanstack/svelte-query (v6.1.38, runes-native, already a dependency, QueryClientProvider already mounted in the root layout). Not a bespoke useApiCall: cancellation, dedup, staleness, refetch-on-focus and retry all come from the library, and hand-rolling a subset now would mean migrating twice.

Two levels, not two paradigms: the helpers cover the simple 80%; screens with infinite scroll or polling (CommentThread.svelte) keep using TanStack directly.

Shared contract

Both helpers expose the exact same three fields:

  • data — typed result, null until there is one.
  • errorstring | null, already passed through resolveApiError. Never a raw ApiError. This is the guarantee: an untranslated error cannot escape a helper.
  • loadingboolean. Replaces saving / planSaving / roleSaving / verifySending / removing — one boolean under six names today.

Also shared: fieldErrors (per-field validation from err.details, always on, not an option — 15 files wire fieldError() by hand today), onError (extra side effect; the error is resolved either way), errorToast.

createApiQuery

Option

Default

Role

key

required

cache/invalidation key, from the factory (see below)

enabled

true

don't fetch until a condition holds

staleTime

30_000 (global)

also the refetch-on-focus knob

refetchInterval

false

polling

keepPreviousData

false

opt-in

retry

1

already the repo global

No separate refetchOnFocus option. staleTime already is that knob: Infinity = never refetch on focus, 0 = refetch every focus, 30_000 = refetch only if the data is older than 30s. One option instead of two.

Note on semantics: staleTime counts from the last successful fetch, not from when focus was lost. Practically identical for alt-tab behaviour.

keepPreviousData defaults to false because it is only correct when the key change means "same subject, different view" (filters, sort, page, search). When the key change means "different subject" it is a bug: in admin/users, selecting user B would show user A's sessions in B's panel — a panel carrying "Revoke" buttons. Opt in on roughly three screens: LibraryBrowser, the search panels, and the admin/users list (not the selected-user detail).

enabled has a concrete driver: admin/users/+page.svelte:178-190 fires 7 reads when a user is selected, each with a try/catch swallowing to []. Without enabled, every fetcher would need its own if (!selected) return — reintroducing by hand what the helper removes.

createApiMutation

Triggered with mutate(args). Options: onSuccess(data), invalidates, successToast (via m(), never a hardcoded string), resetErrorOnRun (default true).

mutate() ignores the call if one is already in flight — the double-submit guard (LibraryBrowser.svelte:155) moves into the helper and is never written by hand again.

invalidates replaces manual local patching. Today, admin/users/+page.svelte:271:

const res = await updateAdminUserPlan(selected.id, plan);
selected = { ...selected, plan: res.plan };
users = users.map((u) => (u.id === selected.id ? { ...u, plan: res.plan } : u));

Query keys and the invalidation rule

Key factory in lib/api/keys.ts, hierarchical arrays:

export const keys = {
  library: {
    all:   ()                     => ["library"] as const,
    list:  (p: LibraryListParams) => ["library", "list", p] as const,
    entry: (id: string)           => ["library", "entry", id] as const,
  },
} as const;

invalidates accepts only factory keys — no bare domain strings. ["library"] and keys.library.all() say the same thing; two syntaxes for one concept is redundancy, not flexibility.

Rule: invalidate only what is displayed at the same time as the mutation, not everything the mutation semantically touches. Navigating away unmounts the query; coming back refetches on mount whenever the data is stale, so cross-domain invalidation is mostly redundant with staleTime. The residual window (a stats → library → stats round trip under 30s) is narrow and low-stakes.

The exception is anything mounted in the layout, which never unmounts: app/+layout.svelte:19,56,69 mounts the notifications store (unread badge), so marking a notification read from anywhere must invalidate it. In practice invalidates will carry one or two precise keys.

Third helper: pagination

10 files hand-roll loadMore / hasMore / nextCursor. They use two incompatible styles: cursor (feed, CommentThread, ProfileActivity) and page number (admin/users does hasMore = res.users.length === PAGE_SIZE). The helper supports both — the choice depends on the data being displayed. Name TBD (createApiPagedQuery?). Specified after the two pilots land.

Also in scope

requestId → GlitchTip. ApiError.requestId (core.ts:26) is parsed and then dropped; hooks.client.ts only wires Sentry to handleError, i.e. unhandled SvelteKit errors — so none of the caught API failures are visible in monitoring. Report from inside request() (single choke point, covers non-helper callers too), 5xx and network failures only — 401/404 would be noise. No UI surfacing of the id.

Resorbed

  • lib/components/stats/stats-resource.svelte.ts (statsResource()) — reads. It also has no loading and no stale-response guard today; the migration fixes both.
  • lib/library-entry.ts (createLibraryEntryActions()) — mutations on the three detail pages.

Plan

  1. requestId → GlitchTip in request(). Independent of everything else, shippable on its own.
  2. keys.ts + createApiQuery + pilot: one /stats section (validates that statsResource is resorbable).
  3. createApiMutation + pilot: one detail page (validates that createLibraryEntryActions is resorbable).
  4. Full migration of all call sites once both pilots are validated — the goal is a single unified pattern, so the 56 already-correct files are converted too, not just the 27 broken ones.
  5. Paged helper, specified from what the migration reveals.

Out of scope

Route duplication — 215 route decorators on the API, 210 request() calls on the web, declared twice. Query keys cannot fix it: a route only yields the key prefix (params carry the rest), and invalidation is a semantic graph, not a URL tree (library.service.ts:186 emits activity → feed, stats.service.ts:542 reads episodeWatch — one POST /library/... touches three domains, and no route encodes that). The real answer is generating the web client from OpenAPI; @nestjs/swagger is already a prod dependency and /docs is already served in dev, so the document exists. Separate ticket.

2 Comments

Sign in to comment

Logan·about 2 hours ago

Third helper: decided, replacing the "name TBD (createApiPagedQuery?)" line in the spec.

Name: createApiInfiniteQuery. No mode flag.

Two axes were being conflated:

  • How the server pages: cursor (feed, CommentThread, ProfileActivity) vs page number (admin/users derives hasMore from res.users.length === PAGE_SIZE). Both genuinely exist, so the helper supports both — but that is one option computing the next page param, not a flag.
  • How the UI consumes pages: accumulate vs replace. Every paginated list in the app accumulates — MediaSearchPanel:169, LibraryBrowser:183, admin/users:145, admin/cache:80, admin/security:69, feed:29, all doing [...items, ...res.items]. There is no classic prev/next replace-pagination anywhere (the "Précédent/Suivant" matches are Wizard.svelte's form steps, and books' currentPage is a page inside a book).

admin/users proves the axes are independent: page-number paging that accumulates.

So a mode flag would encode a distinction with a single value today. And it should not be added later either: replace-pagination is createQuery while accumulation is createInfiniteQuery, two different TanStack primitives, so a flag would switch implementations behind one API. If a classic paginated table ever appears it needs no helper at all — createApiQuery with the page in the key plus keepPreviousData: true already is that.

"Infinite" here means accumulating pages, not a scroll mechanism: 4 files drive it with an IntersectionObserver sentinel and the others with a "load more" button. The helper is indifferent — the trigger stays the component's business.

Posting anonymously

Logan·about 2 hours ago

Superseded by "Centralized API layer: createApiQuery / createApiMutation / paged helper" (post_01m14vjtqeecw9tc2ajm79dqcc), which carries the decided spec.

Outcome of the discussion, for the record:

  • Option A (a bespoke useApiCall) was dropped: it would reimplement a subset of TanStack Query, which is already a dependency and already mounted, and would mean migrating twice.
  • Direction: two thin wrappers over TanStack — createApiQuery and createApiMutation — both exposing the same { data, error, loading }, with error already passed through resolveApiError so no call site can surface a raw one. A third helper covers pagination.
  • TanStack adoption is therefore not a separate incremental migration as this post assumed: it is the substrate of the wrapper, and all call sites are unified in one pass after two pilots.
  • Request cancellation: comes from TanStack rather than being its own effort.
  • requestId: stays in this effort but lives in request() (core.ts), not in the wrapper — GlitchTip only, 5xx and network failures only, no UI surfacing.
  • Refetch-on-focus is wanted broadly, governed by a global staleTime of 30s rather than a dedicated option.
  • Scope: all call sites, not only new code. statsResource() and createLibraryEntryActions() are resorbed.
  • Route duplication (215 API decorators vs 210 web request() calls) was raised and ruled out of scope: query keys cannot fix it, and the real answer is OpenAPI client generation. Its own ticket.

Posting anonymously