@symbo.ls/fetch 3.6.4 → 3.6.6
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/README.md +724 -0
- package/adapters/local.js +148 -0
- package/adapters/rest.js +147 -0
- package/adapters/supabase.js +153 -0
- package/index.js +940 -68
- package/package.json +22 -18
- package/LICENSE +0 -21
- package/dist/cjs/index.js +0 -103
- package/dist/esm/index.js +0 -73
- package/dist/iife/index.js +0 -2992
package/README.md
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
# @symbo.ls/fetch
|
|
2
|
+
|
|
3
|
+
Declarative data fetching for DOMQL with pluggable adapters. Supports caching, stale-while-revalidate, pagination, infinite queries, retry, deduplication, optimistic updates, and more.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
Add `db` to `config.js`:
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
// Supabase
|
|
11
|
+
db: { adapter: 'supabase', projectId: '...', key: '...' }
|
|
12
|
+
|
|
13
|
+
// Supabase — config from state
|
|
14
|
+
db: { adapter: 'supabase', state: 'supabase' } // merges root state.supabase
|
|
15
|
+
|
|
16
|
+
// REST
|
|
17
|
+
db: {
|
|
18
|
+
adapter: 'rest',
|
|
19
|
+
url: 'https://api.example.com',
|
|
20
|
+
headers: { Authorization: 'Bearer token' },
|
|
21
|
+
fetchOptions: { credentials: 'include', mode: 'cors' },
|
|
22
|
+
auth: {
|
|
23
|
+
baseUrl: 'https://api.example.com/auth',
|
|
24
|
+
sessionUrl: '/me',
|
|
25
|
+
signInUrl: '/login',
|
|
26
|
+
signOutUrl: '/logout'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Local
|
|
31
|
+
db: { adapter: 'local', data: { articles: [] }, persist: true }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Declarative `fetch`
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
// Minimal
|
|
38
|
+
{ state: 'articles', fetch: true }
|
|
39
|
+
|
|
40
|
+
// With options
|
|
41
|
+
{ state: 'articles', fetch: { params: { status: 'published' }, cache: '5m', order: { by: 'created_at', asc: false }, limit: 20 } }
|
|
42
|
+
|
|
43
|
+
// String shorthand
|
|
44
|
+
{ state: 'data', fetch: 'blog_posts' }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### `as` — state key mapping
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
{ state: { articles: [], loading: false }, fetch: { from: 'articles', as: 'articles' } }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### RPC
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
{ state: { articles: [] }, fetch: { method: 'rpc', from: 'get_content_rows', params: { p_table: 'articles' }, as: 'articles', cache: '5m' } }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### `transform`
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
{
|
|
63
|
+
state: { featured: null, items: [] },
|
|
64
|
+
fetch: {
|
|
65
|
+
from: 'videos',
|
|
66
|
+
transform: (data) => ({
|
|
67
|
+
featured: data.find(v => v.is_featured) || data[0],
|
|
68
|
+
items: data.filter(v => !v.is_featured)
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### `select` — data selector
|
|
75
|
+
|
|
76
|
+
Like TanStack's `select`, pick or reshape data before it hits state. Runs after cache read and before `transform`:
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
{
|
|
80
|
+
state: { titles: [] },
|
|
81
|
+
fetch: {
|
|
82
|
+
from: 'articles',
|
|
83
|
+
select: (data) => data.map(a => a.title)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Dynamic params
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
{
|
|
92
|
+
state: { item: null },
|
|
93
|
+
fetch: {
|
|
94
|
+
method: 'rpc',
|
|
95
|
+
from: 'get_content_rows',
|
|
96
|
+
params: (el) => ({ p_table: 'articles', p_id: window.location.pathname.split('/').pop() }),
|
|
97
|
+
transform: (data) => ({ item: data && data[0] || null })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Array fetch (parallel)
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
{
|
|
106
|
+
state: { articles: [], events: [] },
|
|
107
|
+
fetch: [
|
|
108
|
+
{ method: 'rpc', from: 'get_content_rows', params: { p_table: 'articles' }, as: 'articles', cache: '5m' },
|
|
109
|
+
{ method: 'rpc', from: 'get_content_rows', params: { p_table: 'events' }, as: 'events', cache: '5m' }
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Triggers
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
{ fetch: { from: 'articles' } } // on: 'create' (default)
|
|
118
|
+
{ tag: 'form', fetch: { method: 'insert', from: 'contacts', on: 'submit' } } // on: 'submit'
|
|
119
|
+
{ fetch: { method: 'delete', from: 'items', params: (el) => ({ id: el.state.itemId }), on: 'click' } }
|
|
120
|
+
{ fetch: { from: 'articles', params: (el, s) => ({ title: { ilike: '%' + s.query + '%' } }), on: 'stateChange' } }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Enabled / disabled queries
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
// Boolean
|
|
127
|
+
{ fetch: { from: 'profile', enabled: false } }
|
|
128
|
+
|
|
129
|
+
// Function — resolves at fetch time
|
|
130
|
+
{ fetch: { from: 'profile', enabled: (el, state) => !!state.userId } }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Cache
|
|
134
|
+
|
|
135
|
+
Default: all queries cache with `staleTime: 1m`, `gcTime: 5m`.
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
cache: true // staleTime 1m, gcTime 5m (default)
|
|
139
|
+
cache: false // no caching
|
|
140
|
+
cache: '5m' // 5 min stale
|
|
141
|
+
cache: 30000 // 30s stale
|
|
142
|
+
cache: { stale: '1m', gc: '10m' }
|
|
143
|
+
cache: { staleTime: '30s', gcTime: '1h', key: 'custom-key' }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Stale-while-revalidate
|
|
147
|
+
|
|
148
|
+
When cached data exists but is stale, it's served immediately while a background refetch happens. Fresh data replaces it once the refetch completes — no loading spinner for stale data.
|
|
149
|
+
|
|
150
|
+
### Garbage collection
|
|
151
|
+
|
|
152
|
+
Unused cache entries (no active subscribers) are cleaned up after `gcTime` (default 5 minutes).
|
|
153
|
+
|
|
154
|
+
## Retry
|
|
155
|
+
|
|
156
|
+
Failed queries automatically retry with exponential backoff.
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
// Default: 3 retries with exponential backoff (1s, 2s, 4s... max 30s)
|
|
160
|
+
{ fetch: { from: 'articles' } }
|
|
161
|
+
|
|
162
|
+
// Disable retry
|
|
163
|
+
{ fetch: { from: 'articles', retry: false } }
|
|
164
|
+
|
|
165
|
+
// Custom count
|
|
166
|
+
{ fetch: { from: 'articles', retry: 5 } }
|
|
167
|
+
|
|
168
|
+
// Full control
|
|
169
|
+
{
|
|
170
|
+
fetch: {
|
|
171
|
+
from: 'articles',
|
|
172
|
+
retry: {
|
|
173
|
+
count: 3,
|
|
174
|
+
delay: (attempt, error) => Math.min(1000 * 2 ** attempt, 30000)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Query deduplication
|
|
181
|
+
|
|
182
|
+
Multiple elements fetching the same query simultaneously share a single network request. The cache key is built from `from`, `method`, and `params`.
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
// Both share one request
|
|
186
|
+
{ Header: { state: 'user', fetch: { from: 'profile', cache: '5m' } } }
|
|
187
|
+
{ Sidebar: { state: 'user', fetch: { from: 'profile', cache: '5m' } } }
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Refetch on window focus
|
|
191
|
+
|
|
192
|
+
Stale queries automatically refetch when the user returns to the tab. Enabled by default.
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
// Disable
|
|
196
|
+
{ fetch: { from: 'articles', refetchOnWindowFocus: false } }
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Refetch on reconnect
|
|
200
|
+
|
|
201
|
+
Queries refetch when the browser comes back online. Enabled by default.
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
// Disable
|
|
205
|
+
{ fetch: { from: 'articles', refetchOnReconnect: false } }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Polling / refetch interval
|
|
209
|
+
|
|
210
|
+
```js
|
|
211
|
+
// Poll every 30 seconds
|
|
212
|
+
{ fetch: { from: 'notifications', refetchInterval: 30000 } }
|
|
213
|
+
{ fetch: { from: 'notifications', refetchInterval: '30s' } }
|
|
214
|
+
|
|
215
|
+
// Also poll when tab is in background
|
|
216
|
+
{ fetch: { from: 'alerts', refetchInterval: '1m', refetchIntervalInBackground: true } }
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Placeholder data
|
|
220
|
+
|
|
221
|
+
Show temporary data immediately while the real query loads:
|
|
222
|
+
|
|
223
|
+
```js
|
|
224
|
+
{
|
|
225
|
+
state: { articles: [] },
|
|
226
|
+
fetch: {
|
|
227
|
+
from: 'articles',
|
|
228
|
+
placeholderData: [] // show empty array instead of undefined while loading
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Function form
|
|
233
|
+
{
|
|
234
|
+
fetch: {
|
|
235
|
+
from: 'article_detail',
|
|
236
|
+
placeholderData: (el, state) => state.articles?.find(a => a.id === state.currentId)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Initial data
|
|
242
|
+
|
|
243
|
+
Pre-populate the cache (counts as fresh data, won't trigger a refetch until stale):
|
|
244
|
+
|
|
245
|
+
```js
|
|
246
|
+
{
|
|
247
|
+
fetch: {
|
|
248
|
+
from: 'settings',
|
|
249
|
+
initialData: { theme: 'dark', lang: 'en' }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Function form
|
|
254
|
+
{
|
|
255
|
+
fetch: {
|
|
256
|
+
from: 'settings',
|
|
257
|
+
initialData: () => JSON.parse(localStorage.getItem('settings'))
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Keep previous data
|
|
263
|
+
|
|
264
|
+
Prevent UI flicker during page changes — keep showing current data while the next page loads:
|
|
265
|
+
|
|
266
|
+
```js
|
|
267
|
+
{
|
|
268
|
+
state: { items: [], page: 1 },
|
|
269
|
+
fetch: {
|
|
270
|
+
from: 'articles',
|
|
271
|
+
page: (el, s) => s.page,
|
|
272
|
+
keepPreviousData: true
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
## Pagination
|
|
278
|
+
|
|
279
|
+
### Offset-based
|
|
280
|
+
|
|
281
|
+
```js
|
|
282
|
+
// Page number — auto-calculates offset from pageSize
|
|
283
|
+
{
|
|
284
|
+
state: { items: [], currentPage: 1 },
|
|
285
|
+
fetch: {
|
|
286
|
+
from: 'articles',
|
|
287
|
+
page: 1,
|
|
288
|
+
pageSize: 20, // default: limit or 20
|
|
289
|
+
keepPreviousData: true
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Manual offset/limit
|
|
294
|
+
{
|
|
295
|
+
fetch: {
|
|
296
|
+
from: 'articles',
|
|
297
|
+
page: { offset: 0, limit: 20 }
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Cursor-based
|
|
303
|
+
|
|
304
|
+
```js
|
|
305
|
+
{
|
|
306
|
+
fetch: {
|
|
307
|
+
from: 'articles',
|
|
308
|
+
page: { cursor: 'abc123', limit: 20 }
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## Infinite queries
|
|
314
|
+
|
|
315
|
+
Load pages incrementally with automatic page tracking:
|
|
316
|
+
|
|
317
|
+
```js
|
|
318
|
+
{
|
|
319
|
+
state: { items: [] },
|
|
320
|
+
fetch: {
|
|
321
|
+
from: 'articles',
|
|
322
|
+
limit: 20,
|
|
323
|
+
infinite: true,
|
|
324
|
+
getNextPageParam: (lastPage, allPages) => {
|
|
325
|
+
if (lastPage.length < 20) return null // no more pages
|
|
326
|
+
return lastPage[lastPage.length - 1].id // cursor
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Fetching pages
|
|
333
|
+
|
|
334
|
+
After mount, use the imperative methods exposed on `element.__ref`:
|
|
335
|
+
|
|
336
|
+
```js
|
|
337
|
+
// In an event handler or callback
|
|
338
|
+
el.__ref.fetchNextPage() // loads next page, appends to state
|
|
339
|
+
el.__ref.fetchPreviousPage() // loads previous page, prepends to state
|
|
340
|
+
|
|
341
|
+
// Status
|
|
342
|
+
el.__ref.__hasNextPage // boolean
|
|
343
|
+
el.__ref.__hasPreviousPage // boolean
|
|
344
|
+
el.__ref.__pages // array of page arrays
|
|
345
|
+
el.__ref.__nextPageParam // current next cursor
|
|
346
|
+
el.__ref.__prevPageParam // current previous cursor
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Bidirectional infinite scroll
|
|
350
|
+
|
|
351
|
+
```js
|
|
352
|
+
{
|
|
353
|
+
fetch: {
|
|
354
|
+
from: 'messages',
|
|
355
|
+
infinite: true,
|
|
356
|
+
getNextPageParam: (lastPage) => lastPage[lastPage.length - 1]?.id,
|
|
357
|
+
getPreviousPageParam: (firstPage) => firstPage[0]?.id
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
## Mutations
|
|
363
|
+
|
|
364
|
+
Mutations (`insert`, `update`, `upsert`, `delete`) support optimistic updates, cache invalidation, and lifecycle callbacks.
|
|
365
|
+
|
|
366
|
+
```js
|
|
367
|
+
{ tag: 'form', fetch: { method: 'insert', from: 'articles', on: 'submit', fields: true } }
|
|
368
|
+
{ tag: 'form', fetch: { method: 'insert', from: 'contacts', on: 'submit', fields: ['name', 'email'] } }
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Optimistic updates
|
|
372
|
+
|
|
373
|
+
Update the UI immediately, roll back if the mutation fails:
|
|
374
|
+
|
|
375
|
+
```js
|
|
376
|
+
{
|
|
377
|
+
extends: 'Button',
|
|
378
|
+
text: 'Like',
|
|
379
|
+
fetch: {
|
|
380
|
+
method: 'update',
|
|
381
|
+
from: 'posts',
|
|
382
|
+
params: (el) => ({ id: el.state.postId }),
|
|
383
|
+
on: 'click',
|
|
384
|
+
optimistic: (mutationData, currentState) => ({
|
|
385
|
+
...currentState,
|
|
386
|
+
likes: currentState.likes + 1
|
|
387
|
+
}),
|
|
388
|
+
invalidates: ['posts']
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### Cache invalidation
|
|
394
|
+
|
|
395
|
+
After a mutation, invalidate related queries so they refetch:
|
|
396
|
+
|
|
397
|
+
```js
|
|
398
|
+
{
|
|
399
|
+
fetch: {
|
|
400
|
+
method: 'insert',
|
|
401
|
+
from: 'articles',
|
|
402
|
+
on: 'submit',
|
|
403
|
+
fields: true,
|
|
404
|
+
invalidates: true // invalidates all "articles:*" cache keys
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Invalidate specific keys
|
|
409
|
+
{ fetch: { method: 'delete', from: 'items', invalidates: ['items:select:'] } }
|
|
410
|
+
|
|
411
|
+
// Invalidate everything
|
|
412
|
+
{ fetch: { method: 'update', from: 'settings', invalidates: ['*'] } }
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
### Mutation callbacks
|
|
416
|
+
|
|
417
|
+
```js
|
|
418
|
+
{
|
|
419
|
+
fetch: {
|
|
420
|
+
method: 'insert',
|
|
421
|
+
from: 'contacts',
|
|
422
|
+
on: 'submit',
|
|
423
|
+
fields: true,
|
|
424
|
+
onMutate: (data, el) => console.log('Sending...', data),
|
|
425
|
+
onSuccess: (responseData, sentData, el) => console.log('Done!', responseData),
|
|
426
|
+
onError: (error, sentData, el) => console.error('Failed', error),
|
|
427
|
+
onSettled: (data, error, sentData, el) => console.log('Finished')
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
## Callbacks
|
|
433
|
+
|
|
434
|
+
```js
|
|
435
|
+
{
|
|
436
|
+
fetch: true,
|
|
437
|
+
onFetchComplete: (data, el) => {},
|
|
438
|
+
onFetchError: (error, el) => {},
|
|
439
|
+
onFetchStart: (el) => {}
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
## Fetch status
|
|
444
|
+
|
|
445
|
+
Every fetch exposes status on `element.__ref.__fetchStatus`:
|
|
446
|
+
|
|
447
|
+
```js
|
|
448
|
+
{
|
|
449
|
+
isFetching, // true while any request is in-flight (including background)
|
|
450
|
+
isLoading, // true only on first load (no cached data)
|
|
451
|
+
isStale, // true if data is past staleTime
|
|
452
|
+
isSuccess, // true after successful fetch
|
|
453
|
+
isError, // alias: !!error
|
|
454
|
+
error, // error object or null
|
|
455
|
+
status, // 'pending' | 'success' | 'error'
|
|
456
|
+
fetchStatus // 'fetching' | 'idle'
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Also available: `el.__ref.__fetching`, `el.__ref.__fetchError`.
|
|
461
|
+
|
|
462
|
+
## Imperative refetch
|
|
463
|
+
|
|
464
|
+
```js
|
|
465
|
+
// Refetch all queries on this element
|
|
466
|
+
el.__ref.refetch()
|
|
467
|
+
|
|
468
|
+
// Force (skip dedup)
|
|
469
|
+
el.__ref.refetch({ force: true })
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
## Query client
|
|
473
|
+
|
|
474
|
+
Global cache management, importable anywhere:
|
|
475
|
+
|
|
476
|
+
```js
|
|
477
|
+
import { queryClient } from '@symbo.ls/fetch'
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### Invalidate queries
|
|
481
|
+
|
|
482
|
+
```js
|
|
483
|
+
queryClient.invalidateQueries('articles') // all keys containing "articles"
|
|
484
|
+
queryClient.invalidateQueries(['articles', 'select'])
|
|
485
|
+
queryClient.invalidateQueries() // invalidate everything
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
### Get / set cache
|
|
489
|
+
|
|
490
|
+
```js
|
|
491
|
+
const articles = queryClient.getQueryData('articles:select:')
|
|
492
|
+
|
|
493
|
+
// Direct set
|
|
494
|
+
queryClient.setQueryData('articles:select:', newArticles)
|
|
495
|
+
|
|
496
|
+
// Updater function
|
|
497
|
+
queryClient.setQueryData('articles:select:', (old) => [...old, newArticle])
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
### Remove queries
|
|
501
|
+
|
|
502
|
+
```js
|
|
503
|
+
queryClient.removeQueries('articles')
|
|
504
|
+
queryClient.removeQueries() // clear all
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### Prefetch
|
|
508
|
+
|
|
509
|
+
Prefetch data before it's needed (e.g. on hover):
|
|
510
|
+
|
|
511
|
+
```js
|
|
512
|
+
await queryClient.prefetchQuery({
|
|
513
|
+
from: 'article_detail',
|
|
514
|
+
method: 'select',
|
|
515
|
+
params: { id: 42 },
|
|
516
|
+
cache: '5m'
|
|
517
|
+
}, context)
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
## Auth guard
|
|
521
|
+
|
|
522
|
+
```js
|
|
523
|
+
{ fetch: { from: 'profile', auth: true } }
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
## Subscribe (realtime)
|
|
527
|
+
|
|
528
|
+
```js
|
|
529
|
+
{ state: 'messages', fetch: { method: 'subscribe', from: 'messages', subscribeOn: 'INSERT' } }
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
## Per-request overrides (REST)
|
|
533
|
+
|
|
534
|
+
```js
|
|
535
|
+
{ fetch: { from: '/users', baseUrl: 'https://api.example.com/auth', headers: { 'X-Custom': 'value' } } }
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
## State inheritance
|
|
539
|
+
|
|
540
|
+
When `state` is a string, the element inherits that key from the parent state. Fetch uses the same string as the default `from` (table name), so declaring `state` is often enough:
|
|
541
|
+
|
|
542
|
+
```js
|
|
543
|
+
// Parent holds the data, child inherits and fetches into it
|
|
544
|
+
{
|
|
545
|
+
state: { articles: [], users: [] },
|
|
546
|
+
ArticleList: {
|
|
547
|
+
state: 'articles', // inherits parent.state.articles + fetches from "articles"
|
|
548
|
+
fetch: true,
|
|
549
|
+
children: '.'
|
|
550
|
+
},
|
|
551
|
+
UserList: {
|
|
552
|
+
state: 'users',
|
|
553
|
+
fetch: true,
|
|
554
|
+
children: '.'
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
### How it works
|
|
560
|
+
|
|
561
|
+
1. `state: 'articles'` tells DOMQL to bind this element's state to `parent.state.articles`
|
|
562
|
+
2. `fetch: true` resolves `from` using the same state key — equivalent to `fetch: { from: 'articles' }`
|
|
563
|
+
3. Fetched data flows into `parent.state.articles`, and all elements inheriting that key update automatically
|
|
564
|
+
|
|
565
|
+
### Nested paths
|
|
566
|
+
|
|
567
|
+
Use `/` to traverse deeper into the state tree:
|
|
568
|
+
|
|
569
|
+
```js
|
|
570
|
+
{
|
|
571
|
+
state: { dashboard: { stats: {} } },
|
|
572
|
+
Stats: {
|
|
573
|
+
state: 'dashboard/stats',
|
|
574
|
+
fetch: { from: 'get_dashboard_stats', method: 'rpc' }
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
### Root and parent references
|
|
580
|
+
|
|
581
|
+
```js
|
|
582
|
+
// ~/ resolves from root state
|
|
583
|
+
{ state: '~/articles', fetch: true }
|
|
584
|
+
|
|
585
|
+
// ../ goes up one level in the state tree
|
|
586
|
+
{ state: '../articles', fetch: true }
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
### Separate `from` and state key
|
|
590
|
+
|
|
591
|
+
When the table name differs from the state key, use `from` explicitly:
|
|
592
|
+
|
|
593
|
+
```js
|
|
594
|
+
{
|
|
595
|
+
state: { posts: [] },
|
|
596
|
+
Posts: {
|
|
597
|
+
state: 'posts',
|
|
598
|
+
fetch: { from: 'blog_posts' } // fetches from "blog_posts", stores in state.posts
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
### `as` with inherited state
|
|
604
|
+
|
|
605
|
+
Use `as` to place fetched data at a specific key when the element has its own object state:
|
|
606
|
+
|
|
607
|
+
```js
|
|
608
|
+
{
|
|
609
|
+
state: { articles: [], total: 0 },
|
|
610
|
+
Articles: {
|
|
611
|
+
state: 'articles',
|
|
612
|
+
fetch: true // replaces state.articles entirely
|
|
613
|
+
},
|
|
614
|
+
Dashboard: {
|
|
615
|
+
state: { items: [], loading: false },
|
|
616
|
+
fetch: { from: 'articles', as: 'items' } // sets state.items, preserves state.loading
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
## `getDB()`
|
|
622
|
+
|
|
623
|
+
```js
|
|
624
|
+
const db = await this.getDB()
|
|
625
|
+
const { data, error } = await db.select({ from: 'articles' })
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
## Adapter interface
|
|
629
|
+
|
|
630
|
+
All return `{ data, error }`.
|
|
631
|
+
|
|
632
|
+
```js
|
|
633
|
+
db.select({ from, select, params, limit, offset, order, single, headers, baseUrl })
|
|
634
|
+
db.insert({ from, data, select, headers, baseUrl })
|
|
635
|
+
db.update({ from, data, params, method, headers, baseUrl }) // method: 'PUT' | 'PATCH'
|
|
636
|
+
db.delete({ from, params, headers, baseUrl })
|
|
637
|
+
db.rpc({ from, params, headers, baseUrl })
|
|
638
|
+
|
|
639
|
+
// Auth
|
|
640
|
+
db.getSession()
|
|
641
|
+
db.signIn({ email, password })
|
|
642
|
+
db.signOut()
|
|
643
|
+
db.setToken(jwt) // REST
|
|
644
|
+
db.signUp({ email, password }) // Supabase
|
|
645
|
+
db.onAuthStateChange(callback) // Supabase
|
|
646
|
+
|
|
647
|
+
// Storage (Supabase)
|
|
648
|
+
db.upload({ bucket, path, file })
|
|
649
|
+
db.download({ bucket, path })
|
|
650
|
+
db.getPublicUrl({ bucket, path })
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
### Params
|
|
654
|
+
|
|
655
|
+
```js
|
|
656
|
+
params: { status: 'published' } // eq
|
|
657
|
+
params: { age: { gt: 18 } } // gt, gte, lt, lte, neq
|
|
658
|
+
params: { title: { ilike: '%search%' } } // like, ilike
|
|
659
|
+
params: { id: [1, 2, 3] } // in
|
|
660
|
+
params: { deleted_at: null } // is null
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
### Order
|
|
664
|
+
|
|
665
|
+
```js
|
|
666
|
+
order: 'created_at' // string
|
|
667
|
+
order: { by: 'created_at', asc: false } // object
|
|
668
|
+
order: [{ by: 'col1' }, { by: 'col2', asc: false }] // array
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
## Custom adapter
|
|
672
|
+
|
|
673
|
+
```js
|
|
674
|
+
import { createAdapter } from '@symbo.ls/fetch'
|
|
675
|
+
|
|
676
|
+
const db = createAdapter({
|
|
677
|
+
name: 'custom',
|
|
678
|
+
select: async ({ from, params }) => { /* { data, error } */ },
|
|
679
|
+
insert: async ({ from, data }) => { /* { data, error } */ },
|
|
680
|
+
update: async ({ from, data, params }) => { /* { data, error } */ },
|
|
681
|
+
delete: async ({ from, params }) => { /* { data, error } */ }
|
|
682
|
+
})
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
## All `fetch` options
|
|
686
|
+
|
|
687
|
+
| Option | Type | Default | Description |
|
|
688
|
+
|--------|------|---------|-------------|
|
|
689
|
+
| `from` | string | state key or element key | Table/endpoint name |
|
|
690
|
+
| `method` | string | `'select'` | `select`, `rpc`, `insert`, `update`, `upsert`, `delete`, `subscribe` |
|
|
691
|
+
| `params` | object/function | — | Filter params or function `(el, state) => params` |
|
|
692
|
+
| `cache` | boolean/string/number/object | `true` (1m stale) | Cache configuration |
|
|
693
|
+
| `retry` | boolean/number/object | `3` | Retry on failure |
|
|
694
|
+
| `transform` | function | — | Reshape data before state update |
|
|
695
|
+
| `select` | function | — | Pick/reshape data (runs before transform) |
|
|
696
|
+
| `as` | string | — | Target state key |
|
|
697
|
+
| `on` | string | `'create'` | Trigger: `create`, `click`, `submit`, `stateChange` |
|
|
698
|
+
| `enabled` | boolean/function | `true` | Enable/disable query |
|
|
699
|
+
| `placeholderData` | any/function | — | Temporary data while loading |
|
|
700
|
+
| `initialData` | any/function | — | Pre-populate cache |
|
|
701
|
+
| `keepPreviousData` | boolean | `false` | Keep current data during refetch |
|
|
702
|
+
| `page` | number/object | — | Pagination: page number or `{ offset, limit, cursor }` |
|
|
703
|
+
| `pageSize` | number | `limit` or `20` | Items per page |
|
|
704
|
+
| `infinite` | boolean | `false` | Enable infinite query mode |
|
|
705
|
+
| `getNextPageParam` | function | — | `(lastPage, allPages) => cursor \| null` |
|
|
706
|
+
| `getPreviousPageParam` | function | — | `(firstPage, allPages) => cursor \| null` |
|
|
707
|
+
| `refetchInterval` | number/string | — | Polling interval |
|
|
708
|
+
| `refetchIntervalInBackground` | boolean | `false` | Poll when tab hidden |
|
|
709
|
+
| `refetchOnWindowFocus` | boolean | `true` | Refetch on tab focus |
|
|
710
|
+
| `refetchOnReconnect` | boolean | `true` | Refetch on online |
|
|
711
|
+
| `optimistic` | any/function | — | Optimistic update data |
|
|
712
|
+
| `invalidates` | string/array/boolean | — | Cache keys to invalidate after mutation |
|
|
713
|
+
| `onMutate` | function | — | Before mutation fires |
|
|
714
|
+
| `onSuccess` | function | — | After successful mutation |
|
|
715
|
+
| `onError` | function | — | After failed mutation |
|
|
716
|
+
| `onSettled` | function | — | After mutation completes (success or error) |
|
|
717
|
+
| `auth` | boolean | `false` | Require authentication |
|
|
718
|
+
| `fields` | boolean/array | — | Collect form fields for mutations |
|
|
719
|
+
| `single` | boolean | `false` | Return single row |
|
|
720
|
+
| `limit` | number | — | Row limit |
|
|
721
|
+
| `offset` | number | — | Row offset |
|
|
722
|
+
| `order` | string/object/array | — | Sort order |
|
|
723
|
+
| `headers` | object | — | Per-request headers (REST) |
|
|
724
|
+
| `baseUrl` | string | — | Per-request base URL (REST) |
|