@symbo.ls/fetch 3.14.660 → 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 +36 -0
- package/README.md +222 -36
- package/adapters/rest.js +173 -31
- package/dist/cjs/adapters/rest.js +1 -1
- package/dist/cjs/early.js +1 -0
- package/dist/cjs/earlyScript.js +53 -0
- package/dist/cjs/index.js +1 -1
- package/dist/esm/adapters/rest.js +1 -1
- package/dist/esm/early.js +1 -0
- package/dist/esm/earlyScript.js +53 -0
- package/dist/esm/index.js +1 -1
- package/early.js +160 -0
- package/earlyScript.js +204 -0
- package/index.js +490 -119
- package/package.json +9 -2
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
|
|
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,
|
|
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 //
|
|
432
|
+
invalidates: true // every "articles:*" key (the write's own `from`)
|
|
427
433
|
}
|
|
428
434
|
}
|
|
429
435
|
|
|
430
|
-
//
|
|
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
|
-
//
|
|
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')
|
|
506
|
-
queryClient.invalidateQueries(['articles', 'select'])
|
|
507
|
-
queryClient.invalidateQueries()
|
|
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
|
|
@@ -596,7 +789,7 @@ await db.select({ from: '/public/feed', headers: { Authorization: null } }) //
|
|
|
596
789
|
| `'stream'` | the body `ReadableStream`, returned before the body is read (`null` when there is no body) |
|
|
597
790
|
| `'response'` | the raw `Response`, unread (use it when you need the response headers) |
|
|
598
791
|
|
|
599
|
-
A failed response keeps the `{ data, error, status }` contract for every type. The adapter reads the body (JSON by `content-type`, else text) for the message. `error` is its `message` or `error` field, else the status text
|
|
792
|
+
A failed response keeps the `{ data, error, status }` contract for every type. The adapter reads the body (JSON by `content-type`, else text) for the message. `error` is its `message` or `error` field, else the status text, else `HTTP <status>` (an HTTP/2 or HTTP/3 response has no status text), so a failed response always has a truthy `error`. `data` is the error body. With `'response'`, `data` stays the unread `Response`, and the message comes from a copy. An unknown `responseType` throws before the request is sent.
|
|
600
793
|
|
|
601
794
|
Read a streamed (SSE) answer chunk by chunk:
|
|
602
795
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
1024
|
+
// state.js
|
|
1025
|
+
export default { lang: 'ka' }
|
|
830
1026
|
```
|
|
831
1027
|
|
|
832
|
-
|
|
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
|
-
|
|
842
|
-
|
|
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 | — |
|
|
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 |
|