@symbo.ls/fetch 3.14.661 → 3.14.662

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # @symbo.ls/fetch
2
2
 
3
+ ## 3.14.662
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixes for the 42 issues reported while building xma.info (FW-XMA-EXCEPTIONS-UPSTREAM-1).
8
+
9
+ Behaviour changes (check these first):
10
+
11
+ - CSS precedence across `extends`: a later layer's shorthand that states a longhand now beats a longhand an earlier layer declared (`borderWidth: '0'` no longer loses to a base `borderBottomWidth`; a derived `border: 'hairline solid …'` now paints over a base `borderWidth: '0'`). Positional shorthands (`background`, `transition`, `font`, `flex`, `grid`…) never remove a base longhand. A `:focus-visible` longhand (`outlineColor`) now paints over Link/Focusable's ring.
12
+ - Built-in `Img` no longer copies `alt` into `title`. `Link` leaves Ctrl/Cmd/Shift/Alt, middle, `target` and `download` clicks to the browser.
13
+ - `X<` breakpoints start at N.02px, so no width matches both `X` and `X<`.
14
+ - Helmet renders the head as the merge of mounted layers: keys a page leaves out fall back instead of staying from the previous page.
15
+ - `smbls build` with `"brender": true` pre-renders again (it rendered nothing since 3.14.6): every static route is written, `/*` becomes `404.html`, `:param` routes are skipped and listed, and the build exits 1 when no route renders.
16
+ - `onStateInit`, `onStateCreated` and `onBeforeStateUpdate` run again (`onBeforeStateUpdate` returning `false` cancels the update).
17
+ - `onWindowLoad` runs once right after creation when the page had already loaded.
18
+ - The first render no longer forces a style+layout pass per microtask.
19
+ - socket.io-client loads only when a sync transport is configured; the ESM build ships it as `dist/smbls.socket-io-client.esm.js` (keep it beside `smbls.esm.js`).
20
+
21
+ New, opt-in:
22
+
23
+ - `designSystem.focusableRing`; `vars` and top-level `'--x'` keys set reactive custom properties; `fill`/`stroke` are CSS outside `<svg>`; attributes inside `.isX` / `'!isX'` blocks apply.
24
+ - Design-system tokens resolve inside `style: {}` blocks; `sizes` tokens negate (`-hairline2`) and publish `--size-<name>`.
25
+ - `fetchpriority` and every multi-word attribute work flat in both spellings.
26
+ - `inertOutside`, the `Bridge` host for DOM-owning libraries, element events with `{ capture, passive, once, selector, handler }`, `TooltipLayer` for `[data-tooltip]` nodes.
27
+ - Router `scrollRestoration: 'restore'`; guards that refuse Back/Forward put the address back; lazy route groups (`context.lazy`, `el.call('loadLazy')`).
28
+ - Fetch: `el.call('invalidateQueries')` and friends, `invalidates` on rpc, `fetch.early` pre-boot requests, `fetch.reads` anonymous GET reads without a CORS preflight.
29
+ - Polyglot `setLang` switches text, `<html lang>` and fonts in one frame; `getPendingLang`.
30
+ - `s.$settled()` / `settled()` await the DOM a state write produces; `onMediaQueryChange` takes design-system media keys; the `animation` shorthand resolves tokens.
31
+ - `smbls/ssr` entry for server rendering; `smbls push` publishes `new URL('<path>', import.meta.url)` assets.
32
+ - frank-audit: FA105 per-tag, FA410 (history writes), wider FA701; opt-in FA211, FA810, FA811, FA812; unknown `--rule` ids are reported.
33
+
34
+ - Updated dependencies [13c4922]
35
+ - Updated dependencies
36
+ - @symbo.ls/utils@3.14.784
37
+ - @symbo.ls/signal@3.14.786
38
+
3
39
  ## 3.14.656
4
40
 
5
41
  ### Patch Changes
package/README.md CHANGED
@@ -133,6 +133,8 @@ For an RPC bound to `on: 'submit'`, collected form data is used as the RPC param
133
133
  { fetch: { from: 'articles', params: (el, s) => ({ title: { ilike: '%' + s.query + '%' } }), on: 'stateChange' } }
134
134
  ```
135
135
 
136
+ `on: 'stateChange'` runs the query when the state it reads changes: the values its `params`, `enabled`, `skip` and `page` resolve to. It does not run on create (add an `on: 'create'` config for a first load), and its own rows landing in state do not run it again. It stops when the element is removed.
137
+
136
138
  ### Enabled / disabled queries
137
139
 
138
140
  ```js
@@ -172,7 +174,7 @@ Unused cache entries (no active subscribers) are cleaned up after `gcTime` (defa
172
174
 
173
175
  ## Retry
174
176
 
175
- Failed queries automatically retry with exponential backoff.
177
+ Failed queries automatically retry with exponential backoff. A write — `insert`, `update`, `upsert`, `delete` or an [rpc action](#actions-rpc-that-writes) — is sent once unless `retry` asks for more: a write that failed by timing out may have been applied, and sending it again applies it twice.
176
178
 
177
179
  ```js
178
180
  // Default: 3 retries with exponential backoff (1s, 2s, 4s... max 30s)
@@ -198,7 +200,7 @@ Failed queries automatically retry with exponential backoff.
198
200
 
199
201
  ## Query deduplication
200
202
 
201
- Multiple elements fetching the same query simultaneously share a single network request. The cache key is built from `from`, `method`, and `params`.
203
+ Multiple elements fetching the same query simultaneously share a single network request (the same [cache key](#cache-keys)). If that request fails, every element that shares it gets the error (`onFetchError`, an error status), not empty rows.
202
204
 
203
205
  ```js
204
206
  // Both share one request
@@ -217,7 +219,7 @@ Stale queries automatically refetch when the user returns to the tab. Enabled by
217
219
 
218
220
  ## Refetch on reconnect
219
221
 
220
- Queries refetch when the browser comes back online. Enabled by default.
222
+ Queries refetch when the browser comes back online, fresh cache or not. Enabled by default.
221
223
 
222
224
  ```js
223
225
  // Disable
@@ -226,6 +228,8 @@ Queries refetch when the browser comes back online. Enabled by default.
226
228
 
227
229
  ## Polling / refetch interval
228
230
 
231
+ Each tick sends a request, also while the cached entry is still fresh.
232
+
229
233
  ```js
230
234
  // Poll every 30 seconds
231
235
  { fetch: { from: 'notifications', refetchInterval: 30000 } }
@@ -368,6 +372,8 @@ el.__ref.__nextPageParam // current next cursor
368
372
  el.__ref.__prevPageParam // current previous cursor
369
373
  ```
370
374
 
375
+ Each page is cached under its own key (the cursor is part of it), and the next / previous page is always asked from the network. A first page served from the cache keeps its cursors, so `fetchNextPage()` goes on from it. A refetch (`el.__ref.refetch()`, an invalidation) starts the list again from the first page. `getNextPageParam` receives the last page and all pages, `getPreviousPageParam` the first page and all pages.
376
+
371
377
  ### Bidirectional infinite scroll
372
378
 
373
379
  ```js
@@ -414,7 +420,7 @@ Update the UI immediately, roll back if the mutation fails:
414
420
 
415
421
  ### Cache invalidation
416
422
 
417
- After a mutation, invalidate related queries so they refetch:
423
+ After a successful mutation, `invalidates` marks the matching cache entries stale. The queries mounted on those keys refetch at once; the others refetch on their next mount (stale-while-revalidate: the old rows show until the new ones arrive).
418
424
 
419
425
  ```js
420
426
  {
@@ -423,17 +429,42 @@ After a mutation, invalidate related queries so they refetch:
423
429
  from: 'articles',
424
430
  on: 'submit',
425
431
  fields: true,
426
- invalidates: true // invalidates all "articles:*" cache keys
432
+ invalidates: true // every "articles:*" key (the write's own `from`)
427
433
  }
428
434
  }
429
435
 
430
- // Invalidate specific keys
431
- { fetch: { method: 'delete', from: 'items', invalidates: ['items:select:'] } }
436
+ // Keys that contain a string (same matches as queryClient.invalidateQueries)
437
+ { fetch: { method: 'delete', from: 'items', invalidates: ['items:select:', 'item_counts'] } }
432
438
 
433
- // Invalidate everything
439
+ // `'*'` is the same as `true`: every key of the write's own `from`
434
440
  { fetch: { method: 'update', from: 'settings', invalidates: ['*'] } }
435
441
  ```
436
442
 
443
+ Each item of an `invalidates` list is a match: a string selects every key that contains it, a RegExp tests the key, and a function `(key) => boolean` decides. `true` and `'*'` select the keys of the write's own `from`. (Earlier releases compared a string with whole keys, so `invalidates: ['posts']` matched nothing, and mounted queries were only marked stale.)
444
+
445
+ A reply that was already on its way when the keys were invalidated never replaces the refetched rows, and the cache never serves it as fresh.
446
+
447
+ ### Actions (`rpc` that writes)
448
+
449
+ An `rpc` can read or write. It is an **action** when a user event fires it (`on: 'submit'`, `on: 'click'`), when it declares `invalidates`, or with `mutation: true`. An action:
450
+
451
+ - sends one request on every trigger — it is never answered from the cache, written to it, or shared with another call;
452
+ - runs its `invalidates` after a successful call (also when the element that fired it was removed meanwhile, for example a dialog that closes on submit);
453
+ - never refetches on window focus, reconnect or an interval, and an invalidation never fires it again.
454
+
455
+ ```js
456
+ // A save button: every click sends the RPC, then the article lists refetch
457
+ { extends: 'Button', fetch: { method: 'rpc', from: 'save_article', params: (el, s) => ({ p_id: s.id }), on: 'click', invalidates: ['get_content_rows'] } }
458
+
459
+ // An RPC that writes when the element is created
460
+ { fetch: { method: 'rpc', from: 'mark_seen', params: { p_id: 7 }, mutation: true, invalidates: ['notifications'] } }
461
+
462
+ // An RPC fired by a click that only reads, and may be cached
463
+ { fetch: { method: 'rpc', from: 'get_quote', on: 'click', mutation: false, as: 'quote' } }
464
+ ```
465
+
466
+ An `rpc` without a submit/click trigger, `invalidates` or `mutation: true` stays a cached query. An action reports through the query callbacks (`onFetchStart`, `onFetchComplete`, `onFetchError`); the mutation callbacks (`onMutate`, `onSuccess`, `onError`, `onSettled`) and `optimistic` are for `insert` / `update` / `upsert` / `delete`.
467
+
437
468
  ### Mutation callbacks
438
469
 
439
470
  ```js
@@ -481,8 +512,38 @@ Every fetch exposes status on `element.__ref.__fetchStatus`:
481
512
 
482
513
  Also available: `el.__ref.__fetching`, `el.__ref.__fetchError`.
483
514
 
515
+ The status is written before the fetched rows reach state, so a render that the rows trigger reads the final status. It is **not reactive**: writing it re-renders nothing, and `if:` / `text:` functions that read only the status never re-run. The plugin writes no loading or error key into state (no `s.__loading`, no `s.__error`) — a `__`-prefixed state key is non-reactive, and a shared state would carry one fetch's flag for every fetch on it.
516
+
517
+ ### Loading and error state in the UI
518
+
519
+ Keep the flags in the element's own state and set them from the callbacks:
520
+
521
+ ```js
522
+ export const DataList = {
523
+ state: { items: [], loading: false, error: null },
524
+ fetch: { from: 'items', as: 'items', cache: '5m' },
525
+ onFetchStart: (el, s) => s.update({ loading: true, error: null }),
526
+ onFetchComplete: (data, el, s) => s.update({ loading: false }),
527
+ onFetchError: (error, el, s) => s.update({ loading: false, error: error?.message || String(error) }),
528
+ Loader: { if: (el, s) => s.loading, text: 'Loading…' },
529
+ Failure: { if: (el, s) => Boolean(s.error), text: (el, s) => s.error },
530
+ Items: {
531
+ if: (el, s) => !s.loading && !s.error,
532
+ children: (el, s) => s.items,
533
+ childrenAs: 'state',
534
+ childProps: { text: (el, s) => s.title }
535
+ }
536
+ }
537
+ ```
538
+
539
+ - Start with `loading: false`: a fresh cache hit sends no request, so neither `onFetchStart` nor `onFetchComplete` runs.
540
+ - The REST adapter's `error` is a string; other adapters may give an object with `message`.
541
+ - The same three callbacks also work inside the `fetch` object (`fetch: { …, onFetchStart }`), with the same arguments.
542
+
484
543
  ## Imperative refetch
485
544
 
545
+ `el.__ref.refetch()` sends the request even while the cached entry is fresh.
546
+
486
547
  ```js
487
548
  // Refetch all queries on this element
488
549
  el.__ref.refetch()
@@ -499,14 +560,24 @@ Global cache management, importable anywhere:
499
560
  import { queryClient } from '@symbo.ls/fetch'
500
561
  ```
501
562
 
563
+ Project code (components, pages, functions) cannot import it — frank serializes an import of `@symbo.ls/fetch` to a stub. It calls the same controls through `el.call` instead (see [From project code](#from-project-code-elcall)).
564
+
565
+ ### Cache keys
566
+
567
+ A query's key is `from:method:` + its params as JSON, then `:infinite`, `:p<page>` and `:c<cursor>` when set (a `page` or `cursor` written as a function is resolved first), then `:<lang>` when a language is active, e.g. `get_content_rows:rpc:{"p_table":"articles"}:ka`. A tenant scope (`state.root.__fetchScope`) sits after `from:method:`. `cache: { key }` replaces the whole key.
568
+
502
569
  ### Invalidate queries
503
570
 
504
571
  ```js
505
- queryClient.invalidateQueries('articles') // all keys containing "articles"
506
- queryClient.invalidateQueries(['articles', 'select'])
507
- queryClient.invalidateQueries() // invalidate everything
572
+ queryClient.invalidateQueries('articles') // every key that contains "articles"
573
+ queryClient.invalidateQueries(['articles', 'select']) // every key that contains "articles:select"
574
+ queryClient.invalidateQueries(/^articles:select:/) // a RegExp tests the key
575
+ queryClient.invalidateQueries((key) => key.endsWith(':en')) // a predicate decides
576
+ queryClient.invalidateQueries() // every key
508
577
  ```
509
578
 
579
+ The matching entries become stale, and the queries mounted on them refetch now (one request per key, shared by every element on it). The returned promise resolves when those refetches settle, so a page can wait for fresh rows before it moves on. A query that is not mounted refetches on its next mount and shows the stale rows meanwhile; use `removeQueries` when it must load without them. Invalidation also drops the [early answers](#early-requests-fetchearly) that no call has taken yet.
580
+
510
581
  ### Get / set cache
511
582
 
512
583
  ```js
@@ -522,10 +593,12 @@ queryClient.setQueryData('articles:select:', (old) => [...old, newArticle])
522
593
  ### Remove queries
523
594
 
524
595
  ```js
525
- queryClient.removeQueries('articles')
596
+ queryClient.removeQueries('articles') // same matches as invalidateQueries
526
597
  queryClient.removeQueries() // clear all
527
598
  ```
528
599
 
600
+ A removed key loads afresh on its next mount (no stale rows first). A mounted query keeps the rows it shows.
601
+
529
602
  ### Raw cache access
530
603
 
531
604
  ```js
@@ -554,6 +627,120 @@ await queryClient.prefetchQuery({
554
627
  }, context)
555
628
  ```
556
629
 
630
+ The prefetched entry has the key an element of that context builds for the same query — with its language and tenant scope from `context.state.root` — and the request carries the same `Accept-Language`, so a later mount reads it without a request.
631
+
632
+ ## From project code (`el.call`)
633
+
634
+ When `fetch` is configured, smbls registers the query client's controls as project functions (a project function of the same name wins):
635
+
636
+ | `el.call(…)` | Does |
637
+ |---|---|
638
+ | `el.call('invalidateQueries', match)` | `queryClient.invalidateQueries(match)` — returns its promise |
639
+ | `el.call('removeQueries', match)` | `queryClient.removeQueries(match)` |
640
+ | `el.call('getQueryData', keyOrConfig)` | the cached rows |
641
+ | `el.call('setQueryData', keyOrConfig, valueOrUpdater)` | writes the cached rows (seeds the next mount; a mounted query keeps its state) |
642
+
643
+ `getQueryData` / `setQueryData` take a key, or a fetch config — `{ from, method, params }` — whose key is built the way the calling element would build it (params resolved with that element, its language and tenant scope).
644
+
645
+ A write made through `getDB()` does not know which queries it changed — invalidate them after it:
646
+
647
+ ```js
648
+ // functions/saveArticle.js
649
+ export const saveArticle = async function saveArticle (form) {
650
+ const db = await this.getDB()
651
+ const res = await db.rpc({ from: 'save_article', params: form })
652
+ if (res.error) throw new Error(res.error)
653
+ await this.call('invalidateQueries', 'get_content_rows') // mounted lists refetch, now
654
+ this.router('/blog/' + res.data.slug, this.getRoot()) // the page shows the saved rows
655
+ }
656
+ ```
657
+
658
+ The adapter returned by `getDB()` is the transport itself (shared by every element, and sometimes handed across frames), so it takes no `invalidates` option: cache policy stays in the plugin.
659
+
660
+ ## Anonymous reads (`reads`)
661
+
662
+ Every RPC is a POST with a JSON body, and every call carries the configured headers. A cross-origin browser sends neither as a "simple" request, so each read waits for a CORS preflight first (one more round trip per read). With `reads` (REST adapter, opt-in) an **anonymous** read goes as a simple GET:
663
+
664
+ ```js
665
+ // config.js
666
+ fetch: {
667
+ adapter: 'rest',
668
+ url: 'https://<project>.supabase.co/rest/v1',
669
+ headers: { apikey: '<publishable key>' },
670
+ reads: {
671
+ // Configured headers an anonymous read carries as query parameters (header → parameter).
672
+ // A PUBLIC key only: a URL lands in server logs and the browser history. Only for a
673
+ // backend that reads it from the query string (Supabase's API gateway reads `apikey`).
674
+ query: { apikey: 'apikey' },
675
+ // The backend answers read-only RPCs as GETs with the arguments in the query string
676
+ // (PostgREST does, for STABLE/IMMUTABLE functions).
677
+ rpc: 'get'
678
+ }
679
+ }
680
+ ```
681
+
682
+ - `query` applies to every anonymous `select` and every anonymous read RPC. The named headers leave the request and become query parameters. A configured `Content-Type` is not sent on these GETs (there is no body).
683
+ - `rpc: 'get'` sends an RPC as `GET <url>/rpc/<from>?<params>` when the call is a read: a declarative `fetch` with `method: 'rpc'` that is a query (not an action — no `on: 'submit'` / `'click'`, no `invalidates`, no `mutation: true`), a `prefetchQuery` of one, or `db.rpc({ …, read: true })`. Set `read: false` on a declarative rpc query to keep it a POST. A `getDB().rpc()` without `read: true` stays a POST.
684
+ - Only arguments that read back the same from a query string go as a GET: strings, finite numbers and booleans. A `null`, an object or an array keeps the call a POST.
685
+ - A **signed-in** call is not anonymous: with the adapter's token (`setToken`, `auth.token`) or a per-call `Authorization` header the call keeps the header path exactly as without `reads`. A session token never goes into a URL.
686
+ - A GET answered `405` (PostgREST refuses a GET for a VOLATILE function — before anything is written) is sent again as the POST. Other errors are the answer.
687
+ - A relative `url` (same origin) keeps RPCs as POSTs.
688
+
689
+ Other configured headers stay headers (and still cost a preflight). Without `reads` nothing changes. [`fetch.early`](#early-requests-fetchearly) uses the same two options and takes them from `reads` when it does not set its own, so one declaration drives both: the GET the pre-boot script sends is the GET the call sends, and the call adopts its answer.
690
+
691
+ ## Early requests (`fetch.early`)
692
+
693
+ A page's first view waits for its data until the app bundle has downloaded, booted and mounted. With `fetch.early` (REST adapter, opt-in) the build adds a small script that sends the first view's GETs while the HTML is still parsing; the adapter then **adopts** each answer for the first call that asks for exactly that request, instead of sending it again.
694
+
695
+ ```js
696
+ // config.js
697
+ fetch: {
698
+ adapter: 'rest',
699
+ url: 'https://<project>.supabase.co/rest/v1',
700
+ headers: { apikey: '<publishable key>' },
701
+ early: {
702
+ // Configured headers the early GETs carry as query parameters (header → parameter).
703
+ // Any custom header makes a cross-origin GET non-simple (a CORS preflight, a round
704
+ // trip before the request). Only for a backend that reads it from the query string
705
+ // (Supabase's API gateway reads `apikey`).
706
+ query: { apikey: 'apikey' },
707
+ // The backend answers read-only RPCs as GETs with the arguments in the query string
708
+ // (PostgREST does, for STABLE/IMMUTABLE functions). Without it only `select` goes early.
709
+ rpc: 'get',
710
+ // Requests every route's first view makes.
711
+ always: (params, ctx) => [
712
+ { from: 'site_settings', select: 'key,value', lang: false }
713
+ ],
714
+ // Route pattern (as in pages) → that page's first-view requests.
715
+ routes: {
716
+ '/': [
717
+ { method: 'rpc', from: 'get_content_rows', params: { p_table: 'articles', p_limit: 20 } }
718
+ ],
719
+ '/blog/:id': ({ id }, ctx) => [
720
+ { method: 'rpc', from: 'get_content_rows', params: { p_table: 'articles', p_slug: id, p_limit: 1 } }
721
+ ]
722
+ }
723
+ }
724
+ }
725
+ ```
726
+
727
+ `early.query` and `early.rpc` default to the adapter's [`reads`](#anonymous-reads-reads) — declare them once there, and the adapter's own reads match the early GETs.
728
+
729
+ A request is the adapter call it stands for: `{ method: 'select' | 'rpc', from, params, select, limit, offset, order, single }` (`method` defaults to `'select'`). Write a page's requests as a list, or as a function `(params, ctx) => list` of the route params and `ctx = { path, query, lang, storage(key) }` (`query` is a `URLSearchParams`, `storage` reads `localStorage`). A function runs before the app: it must stand alone — no imports, no project functions, nothing outside its arguments and the browser's globals. Falsy items are skipped.
730
+
731
+ **Language.** A request asks in the active language (`Accept-Language`), the one a declarative `fetch` sends: polyglot's stored choice (`polyglot.storageLangKey`), else `state.lang`, else `polyglot.defaultLang`. Set `lang: false` for a request that a `getDB()` call or polyglot's own loader makes — they send no language.
732
+
733
+ **Adoption.** A call takes an early answer only when it is the same request: the same address (the order of params does not matter), the same `Accept-Language`, and no other header — a signed-in call (the adapter's token, a per-call `Authorization`) or one with its own headers never takes the anonymous answer. Each answer is taken once. Everything else is a normal request:
734
+
735
+ - an early request that failed or came back not ok (a refused GET, a key the backend does not read from the query);
736
+ - an answer older than `early.maxAge` (ms, default 30000, counted from when the script sent it);
737
+ - any answer still waiting after an invalidation (a write may have happened since);
738
+ - an RPC argument that is not a string, number or boolean (`null`, objects and arrays read back differently from a query string), so such an RPC never goes early.
739
+
740
+ Early GETs skip the HTTP cache (`cache: 'no-store'`) unless the adapter's `fetchOptions.cache` says otherwise; they copy `credentials`, `mode`, `redirect`, `referrerPolicy` and `priority` from `fetchOptions`.
741
+
742
+ **Build.** `smbls build` (and `smbls deploy` to Cloudflare or Netlify) writes the script next to the built page as `early.<hash>.js` and references it first thing in `<head>` — `<script async data-symbols-early src="…">`. A file, not an inline script, so a Content-Security-Policy without `'unsafe-inline'` allows it. Without `fetch.early` the build output is unchanged. Other build paths can generate the script with `createEarlyScript({ fetch, polyglot, state })` from `@symbo.ls/fetch/early-script`. The script never runs on the server (brender), and an adapter without `early` never looks for its answers.
743
+
557
744
  ## Auth guard
558
745
 
559
746
  ```js
@@ -566,6 +753,12 @@ await queryClient.prefetchQuery({
566
753
  { state: 'messages', fetch: { method: 'subscribe', from: 'messages', subscribeOn: 'INSERT' } }
567
754
  ```
568
755
 
756
+ An element holds one subscription: a re-run (`el.__ref.refetch()`) closes the old one before it opens the new one, and window focus, reconnect and invalidation do not re-open it. It is closed when the element is removed.
757
+
758
+ ## Element removal
759
+
760
+ When an element is removed, the plugin releases what it bound for it: the `refetchInterval` timer, the window focus / online listeners, the submit / click listeners, an `on: 'stateChange'` watcher and a realtime subscription.
761
+
569
762
  ## Per-request overrides (REST)
570
763
 
571
764
  ```js
@@ -749,7 +942,9 @@ db.insert({ from, data, select, headers, baseUrl, responseType })
749
942
  db.update({ from, data, params, method, headers, baseUrl, responseType }) // method: 'PUT' | 'PATCH'
750
943
  db.upsert({ from, data, params, method, headers, baseUrl, responseType }) // method defaults to 'PUT'
751
944
  db.delete({ from, params, headers, baseUrl, responseType })
752
- db.rpc({ from, params, headers, baseUrl, responseType })
945
+ db.rpc({ from, params, headers, baseUrl, responseType, read })
946
+ // read: true — the call only reads; the REST adapter may send it as a GET (see `reads`).
947
+ // A declarative rpc query passes it; an action never does. Other adapters ignore it.
753
948
  // responseType (REST): 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'stream' | 'response'
754
949
 
755
950
  // Auth (adapter-dependent — implemented by adapters that manage sessions)
@@ -819,39 +1014,29 @@ import { setup as local } from '@symbo.ls/fetch/local'
819
1014
 
820
1015
  ## Language / i18n
821
1016
 
822
- Fetch automatically injects the current language into every request — both as a `lang` query parameter and as an `Accept-Language` header.
1017
+ A declarative `fetch` (queries and writes) sends the active language — the root state's `lang` — as the `Accept-Language` header, and a query's cache key ends in `:<lang>`, so each language keeps its own cached answer (`queryClient.dropNonLang(keep)` evicts the others). It adds no `lang` parameter: a backend that picks the language by an argument gets it in `params`. A `getDB()` call sends no language unless you pass the header.
823
1018
 
824
1019
  ### Setting the language
825
1020
 
826
- Set the language in root state:
1021
+ Set `lang` in the project's state (`state.js` — the root state), or let the polyglot plugin manage it (`setLang`).
827
1022
 
828
1023
  ```js
829
- state: { root: { lang: 'ka' } }
1024
+ // state.js
1025
+ export default { lang: 'ka' }
830
1026
  ```
831
1027
 
832
- Or use the polyglot plugin which manages `state.root.lang` automatically.
833
-
834
- ### Per-request override
835
-
836
- Override the language for a specific fetch by including `lang` in params:
1028
+ ### An argument for the language
837
1029
 
838
1030
  ```js
839
1031
  {
840
1032
  fetch: {
841
- from: 'articles',
842
- params: { lang: 'de' } // overrides global language for this request
1033
+ method: 'rpc',
1034
+ from: 'get_content_rows',
1035
+ params: (el, s) => ({ p_table: 'articles', p_lang: s.root.lang })
843
1036
  }
844
1037
  }
845
1038
  ```
846
1039
 
847
- ### How it works
848
-
849
- 1. `lang` is added to `params` (sent as query parameter / RPC argument)
850
- 2. `Accept-Language` header is set on the request
851
- 3. If `params.lang` is already set explicitly, it is not overwritten
852
-
853
- This follows the same pattern as XMA's `t()` function where `state.root.lang` is the source of truth for the current language.
854
-
855
1040
  ## Disabling fetch
856
1041
 
857
1042
  Fetch is included by default in smbls. To disable it:
@@ -885,7 +1070,7 @@ When used as a plugin, fetch hooks into the `create` lifecycle to auto-execute f
885
1070
  | `method` | string | `'select'` | `select`, `rpc`, `insert`, `update`, `upsert`, `delete`, `subscribe` |
886
1071
  | `params` | object/function | — | Filter params or function `(el, state) => params` |
887
1072
  | `cache` | boolean/string/number/object | `true` (1m stale) | Cache configuration |
888
- | `retry` | boolean/number/object | `3` | Retry on failure |
1073
+ | `retry` | boolean/number/object | `3` (reads), none (writes) | Retry on failure |
889
1074
  | `transform` | function | — | Reshape data before state update |
890
1075
  | `select` | function | — | Pick/reshape data (runs before transform) |
891
1076
  | `as` | string | — | Target state key |
@@ -895,8 +1080,8 @@ When used as a plugin, fetch hooks into the `create` lifecycle to auto-execute f
895
1080
  | `placeholderData` | any/function | — | Temporary data while loading |
896
1081
  | `initialData` | any/function | — | Pre-populate cache |
897
1082
  | `keepPreviousData` | boolean | `false` | Keep current data during refetch |
898
- | `page` | number/object | — | Pagination: page number or `{ offset, limit, cursor }` |
899
- | `cursor` | string | — | Cursor passed through to `params.cursor` |
1083
+ | `page` | number/object/function | — | Pagination: page number or `{ offset, limit, cursor }`, or `(el, state) => …` |
1084
+ | `cursor` | string/function | — | Cursor passed through to `params.cursor` (part of the cache key) |
900
1085
  | `pageSize` | number | `limit` or `20` | Items per page |
901
1086
  | `infinite` | boolean | `false` | Enable infinite query mode |
902
1087
  | `getNextPageParam` | function | — | `(lastPage, allPages) => cursor \| null` |
@@ -906,7 +1091,8 @@ When used as a plugin, fetch hooks into the `create` lifecycle to auto-execute f
906
1091
  | `refetchOnWindowFocus` | boolean | `true` (`false` for `'stream'` / `'response'`) | Refetch on tab focus |
907
1092
  | `refetchOnReconnect` | boolean | `true` (`false` for `'stream'` / `'response'`) | Refetch on online |
908
1093
  | `optimistic` | any/function | — | Optimistic update data |
909
- | `invalidates` | string/array/boolean | — | Cache keys to invalidate after mutation |
1094
+ | `invalidates` | string/RegExp/function/array/boolean | — | Keys to invalidate after a successful write or action (`true` / `'*'`: the write's own `from`); mounted queries on them refetch |
1095
+ | `mutation` | boolean | — | `true`: the config is an action (never cached, `invalidates` runs). `false`: an `rpc` fired by a click/submit stays a cached query |
910
1096
  | `onMutate` | function | — | Before mutation fires |
911
1097
  | `onSuccess` | function | — | After successful mutation |
912
1098
  | `onError` | function | — | After failed mutation |