@symbo.ls/fetch 3.14.7 → 3.14.8

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/index.js +1141 -0
  3. package/package.json +3 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @symbo.ls/fetch
2
2
 
3
+ ## 3.14.8
4
+
5
+ ### Patch Changes
6
+
7
+ - Manual patch bump triggered via workflow_dispatch (scope: @symbo.ls).
8
+ No source change behind this bump — released to refresh dist or
9
+ coordinate a cross-package version line.
10
+ - Updated dependencies
11
+ - @symbo.ls/utils@3.14.11
12
+
3
13
  ## 3.14.7
4
14
 
5
15
  ### Patch Changes
package/index.js ADDED
@@ -0,0 +1,1141 @@
1
+ 'use strict'
2
+
3
+ import { isArray, isFunction, isObject, isString, exec } from '@symbo.ls/utils'
4
+
5
+ // --- Adapter resolution ---
6
+
7
+ const ADAPTER_METHODS = ['select', 'rpc', 'insert', 'update', 'delete', 'subscribe']
8
+
9
+ const BUILTIN_ADAPTERS = {
10
+ supabase: () => import('./adapters/supabase.js'),
11
+ rest: () => import('./adapters/rest.js'),
12
+ local: () => import('./adapters/local.js')
13
+ }
14
+
15
+ export const registerAdapter = (name, loader) => {
16
+ BUILTIN_ADAPTERS[name] = loader
17
+ }
18
+
19
+ export const createAdapter = (config) => {
20
+ const adapter = {}
21
+ for (const method of ADAPTER_METHODS) {
22
+ if (config[method]) adapter[method] = config[method]
23
+ }
24
+ adapter.name = config.name || 'custom'
25
+ return adapter
26
+ }
27
+
28
+ export const resolveDb = async (config) => {
29
+ if (!config) return null
30
+ if (typeof config.select === 'function') return config
31
+
32
+ const { adapter, ...options } = config
33
+ const name = typeof adapter === 'string' ? adapter : typeof config === 'string' ? config : null
34
+ if (!name) return null
35
+
36
+ const loader = BUILTIN_ADAPTERS[name]
37
+ if (!loader) throw new Error(`Unknown db adapter: "${name}". Use registerAdapter() for adapters with optional deps.`)
38
+
39
+ const mod = await loader()
40
+ return mod.setup(options)
41
+ }
42
+
43
+ // --- Duration parsing ---
44
+
45
+ const parseDuration = (val) => {
46
+ if (!val) return 0
47
+ if (typeof val === 'number') return val
48
+ const match = val.match(/^(\d+)(ms|s|m|h)$/)
49
+ if (!match) return 0
50
+ const n = parseInt(match[1])
51
+ const unit = match[2]
52
+ if (unit === 'ms') return n
53
+ if (unit === 's') return n * 1000
54
+ if (unit === 'm') return n * 60000
55
+ if (unit === 'h') return n * 3600000
56
+ return 0
57
+ }
58
+
59
+ // --- Query cache ---
60
+
61
+ const cacheStore = new Map()
62
+ const querySubscribers = new Map() // key -> Set of elements
63
+ const activeQueries = new Map() // key -> Promise (deduplication)
64
+
65
+ // Cache key includes lang when provided so different-locale responses don't
66
+ // collide. The fetch plugin injects Accept-Language on requests, so the same
67
+ // (from, method, params) tuple yields different bodies per lang. queryClient.
68
+ // dropNonLang(keepLang) leans on this suffix to evict stale-language entries
69
+ // on a setLang() event without touching the kept lang's cache.
70
+ const buildCacheKey = (config, lang) => {
71
+ if (config.cache?.key) return config.cache.key
72
+ const params = resolveParamsSync(config.params)
73
+ const base = `${config.from}:${config.method}:${JSON.stringify(params || '')}`
74
+ return lang ? `${base}:${lang}` : base
75
+ }
76
+
77
+ const getCacheEntry = (key) => cacheStore.get(key) || null
78
+
79
+ const setCacheEntry = (key, data, error) => {
80
+ const existing = cacheStore.get(key)
81
+ const entry = {
82
+ data,
83
+ error,
84
+ time: Date.now(),
85
+ stale: false
86
+ }
87
+ cacheStore.set(key, entry)
88
+
89
+ // Notify all subscribers of this query
90
+ const subs = querySubscribers.get(key)
91
+ if (subs) {
92
+ for (const sub of subs) {
93
+ if (isFunction(sub)) sub(entry)
94
+ }
95
+ }
96
+
97
+ return entry
98
+ }
99
+
100
+ const invalidateCache = (key) => {
101
+ if (key) {
102
+ const entry = cacheStore.get(key)
103
+ if (entry) entry.stale = true
104
+ return
105
+ }
106
+ // Invalidate all
107
+ for (const [, entry] of cacheStore) {
108
+ entry.stale = true
109
+ }
110
+ }
111
+
112
+ const removeCache = (key) => {
113
+ if (key) {
114
+ cacheStore.delete(key)
115
+ return
116
+ }
117
+ cacheStore.clear()
118
+ }
119
+
120
+ const subscribeToCacheKey = (key, callback) => {
121
+ if (!querySubscribers.has(key)) querySubscribers.set(key, new Set())
122
+ querySubscribers.get(key).add(callback)
123
+ return () => {
124
+ const subs = querySubscribers.get(key)
125
+ if (subs) {
126
+ subs.delete(callback)
127
+ if (subs.size === 0) querySubscribers.delete(key)
128
+ }
129
+ }
130
+ }
131
+
132
+ // --- Cache config resolution ---
133
+
134
+ const parseCacheConfig = (cache) => {
135
+ if (!cache && cache !== false) return { staleTime: 60000, gcTime: 300000 }
136
+ if (cache === false) return null
137
+ if (cache === true) return { staleTime: 60000, gcTime: 300000 }
138
+ if (typeof cache === 'number') return { staleTime: cache, gcTime: Math.max(cache * 5, 300000) }
139
+ if (isString(cache)) return { staleTime: parseDuration(cache), gcTime: 300000 }
140
+ return {
141
+ staleTime: parseDuration(cache.stale || cache.staleTime) || 60000,
142
+ gcTime: parseDuration(cache.gc || cache.gcTime || cache.expire) || 300000,
143
+ key: cache.key
144
+ }
145
+ }
146
+
147
+ // --- GC ---
148
+
149
+ let gcTimer = null
150
+
151
+ const startGC = () => {
152
+ if (gcTimer) return
153
+ gcTimer = setInterval(() => {
154
+ const now = Date.now()
155
+ for (const [key, entry] of cacheStore) {
156
+ const subs = querySubscribers.get(key)
157
+ const hasSubscribers = subs && subs.size > 0
158
+ if (!hasSubscribers && (now - entry.time) > (entry.gcTime || 300000)) {
159
+ cacheStore.delete(key)
160
+ }
161
+ }
162
+ }, 30000)
163
+ if (gcTimer.unref) gcTimer.unref()
164
+ }
165
+
166
+ startGC()
167
+
168
+ // --- Retry logic ---
169
+
170
+ const DEFAULT_RETRY = 3
171
+ const DEFAULT_RETRY_DELAY = (attempt) => Math.min(1000 * 2 ** attempt, 30000)
172
+
173
+ const resolveRetryConfig = (config) => {
174
+ const retry = config.retry
175
+ if (retry === false) return { count: 0 }
176
+ if (retry === true || retry === undefined) return { count: DEFAULT_RETRY, delay: DEFAULT_RETRY_DELAY }
177
+ if (typeof retry === 'number') return { count: retry, delay: DEFAULT_RETRY_DELAY }
178
+ return {
179
+ count: retry.count ?? DEFAULT_RETRY,
180
+ delay: isFunction(retry.delay) ? retry.delay : (typeof retry.delay === 'number' ? () => retry.delay : DEFAULT_RETRY_DELAY)
181
+ }
182
+ }
183
+
184
+ const withRetry = async (fn, retryConfig) => {
185
+ const { count, delay } = retryConfig
186
+ let lastError
187
+ for (let attempt = 0; attempt <= count; attempt++) {
188
+ try {
189
+ const result = await fn()
190
+ if (result?.error) {
191
+ lastError = result.error
192
+ if (attempt < count) {
193
+ const ms = isFunction(delay) ? delay(attempt, lastError) : delay
194
+ await new Promise(r => setTimeout(r, ms))
195
+ continue
196
+ }
197
+ return result
198
+ }
199
+ return result
200
+ } catch (e) {
201
+ lastError = e
202
+ if (attempt < count) {
203
+ const ms = isFunction(delay) ? delay(attempt, e) : delay
204
+ await new Promise(r => setTimeout(r, ms))
205
+ continue
206
+ }
207
+ throw e
208
+ }
209
+ }
210
+ return { data: null, error: lastError }
211
+ }
212
+
213
+ // --- Refetch on window focus / reconnect ---
214
+
215
+ const globalListeners = { focus: new Set(), online: new Set() }
216
+ let globalListenersAttached = false
217
+
218
+ const attachGlobalListeners = () => {
219
+ if (globalListenersAttached || typeof window === 'undefined') return
220
+ globalListenersAttached = true
221
+
222
+ const onFocus = () => {
223
+ for (const fn of globalListeners.focus) fn()
224
+ }
225
+ const onOnline = () => {
226
+ for (const fn of globalListeners.online) fn()
227
+ }
228
+
229
+ window.addEventListener('visibilitychange', () => {
230
+ if (document.visibilityState === 'visible') onFocus()
231
+ })
232
+ window.addEventListener('focus', onFocus)
233
+ window.addEventListener('online', onOnline)
234
+ }
235
+
236
+ // --- Fetch config resolution ---
237
+
238
+ const resolveFetchConfig = (fetchProp, element) => {
239
+ const ref = element.__ref
240
+
241
+ if (fetchProp === true) {
242
+ return { from: ref.__state || element.key, method: 'select', query: true, on: 'create' }
243
+ }
244
+
245
+ if (isString(fetchProp)) {
246
+ return { from: fetchProp, method: 'select', query: true, on: 'create' }
247
+ }
248
+
249
+ if (isObject(fetchProp)) {
250
+ const config = { ...fetchProp }
251
+ config.from = config.from || ref.__state || element.key
252
+ config.method = config.method || 'select'
253
+ config.on = config.on || 'create'
254
+ if (config.query === undefined) config.query = true
255
+ return config
256
+ }
257
+
258
+ return null
259
+ }
260
+
261
+ const resolveParamsSync = (params) => {
262
+ if (!params || isFunction(params)) return params
263
+ return params
264
+ }
265
+
266
+ const resolveLanguage = (element, context) => {
267
+ const root = element?.state?.root || context?.state?.root
268
+ if (root?.lang) return root.lang
269
+ return undefined
270
+ }
271
+
272
+ const resolveParams = (params, element, context) => {
273
+ let resolved
274
+ if (!params) {
275
+ resolved = undefined
276
+ } else if (isFunction(params)) {
277
+ resolved = params(element, element.state)
278
+ } else {
279
+ resolved = {}
280
+ for (const key in params) {
281
+ const val = params[key]
282
+ resolved[key] = isFunction(val) ? val(element, element.state) : val
283
+ }
284
+ }
285
+ return resolved
286
+ }
287
+
288
+ export const initAdapterAuth = async (adapter, context) => {
289
+ if (!adapter || adapter.__authInitialized) return
290
+ adapter.__authInitialized = true
291
+ if (!adapter.getSession) return
292
+ const updateAuth = (user, session) => {
293
+ const root = context.state?.root
294
+ if (root?.update) {
295
+ root.update({ auth: { user, session, loading: false } })
296
+ }
297
+ }
298
+ try {
299
+ const session = await adapter.getSession()
300
+ updateAuth(session?.user || null, session)
301
+ } catch (e) {
302
+ console.warn('[fetch] Failed to restore auth session:', e.message)
303
+ }
304
+ if (adapter.onAuthStateChange) {
305
+ adapter.onAuthStateChange((event, session) => {
306
+ updateAuth(session?.user || null, session)
307
+ })
308
+ }
309
+ }
310
+
311
+ const resolveAdapter = async (db, context) => {
312
+ // When adapter is already resolved (e.g. by getDB()), still init auth
313
+ if (isFunction(db.select)) {
314
+ if (db.auth !== false) await initAdapterAuth(db, context)
315
+ return db
316
+ }
317
+ if (db.__resolved) {
318
+ if (db.auth !== false) await initAdapterAuth(db.__resolved, context)
319
+ return db.__resolved
320
+ }
321
+ if (db.__resolving) return db.__resolving
322
+
323
+ db.__resolving = resolveDb(db)
324
+ const resolved = await db.__resolving
325
+ delete db.__resolving
326
+ if (!resolved) return null
327
+ resolved.auth = db.auth
328
+ resolved.url = db.url
329
+ resolved.key = db.key
330
+ db.__resolved = resolved
331
+ context.fetch = resolved
332
+
333
+ // Auto-init auth when adapter supports it and db.auth is enabled
334
+ if (db.auth !== false) await initAdapterAuth(resolved, context)
335
+
336
+ return resolved
337
+ }
338
+
339
+ const triggerCallback = (element, name, ...args) => {
340
+ const fn = isFunction(element[name]) ? element[name] : null
341
+ if (fn) fn.call(element, ...args, element, element.state, element.context)
342
+ }
343
+
344
+ const triggerConfigCallback = (config, element, name, ...args) => {
345
+ const fn = config && isFunction(config[name]) ? config[name] : null
346
+ if (fn) fn.call(element, ...args, element, element.state, element.context)
347
+ }
348
+
349
+ const collectFormData = (element) => {
350
+ const data = {}
351
+ const node = element.node
352
+ if (!node) return data
353
+
354
+ if (node.tagName === 'FORM') {
355
+ const formData = new FormData(node)
356
+ for (const [key, value] of formData.entries()) {
357
+ data[key] = value
358
+ }
359
+ return data
360
+ }
361
+
362
+ const inputs = node.querySelectorAll('input, textarea, select')
363
+ for (let i = 0; i < inputs.length; i++) {
364
+ const input = inputs[i]
365
+ const name = input.name || input.getAttribute('name')
366
+ if (!name) continue
367
+ if (input.type === 'checkbox') {
368
+ data[name] = input.checked
369
+ } else if (input.type === 'file') {
370
+ data[name] = input.files
371
+ } else {
372
+ data[name] = input.value
373
+ }
374
+ }
375
+ return data
376
+ }
377
+
378
+ // --- State update helpers ---
379
+
380
+ const updateElementState = (element, data, stateKey, options = {}) => {
381
+ const isRoot = stateKey && stateKey.startsWith('~/')
382
+ const target = isRoot ? (element.state?.root || element.state) : element.state
383
+ if (!target?.update) return
384
+
385
+ if (isRoot) {
386
+ const key = stateKey.slice(2)
387
+ // If transform returned an object containing the key itself, spread on root
388
+ // e.g. transform returns { transactions: [...], totalSaved: 755 } for as: '~/transactions'
389
+ if (data && typeof data === 'object' && !Array.isArray(data) && key in data) {
390
+ target.update(data, { preventFetch: true, ...options })
391
+ } else {
392
+ target.update({ [key]: data }, { preventFetch: true, ...options })
393
+ }
394
+ } else {
395
+ const stateData = stateKey ? { [stateKey]: data } : data
396
+ target.update(stateData, { preventFetch: true, ...options })
397
+ }
398
+ }
399
+
400
+ const setFetchStatus = (element, status) => {
401
+ // Element may have been torn down (route change, modal close, parent
402
+ // unmount) between the runFetch entry guard and this call site —
403
+ // `runFetch` checks `__ref` once at start, but a long-lived async fetch
404
+ // can outlive the element. Without this null-guard the framework throws
405
+ // `TypeError: Cannot set properties of null` from every status write.
406
+ const ref = element?.__ref
407
+ if (!ref) return
408
+ ref.__fetchStatus = status
409
+ ref.__fetching = status.isFetching
410
+ ref.__fetchError = status.error
411
+ }
412
+
413
+ // --- Core fetch runner ---
414
+
415
+ const runFetch = async (config, element, context, opts = {}) => {
416
+ const db = context?.fetch
417
+ if (!db) return
418
+
419
+ // Element may have been torn down (unmounted, navigated away, modal
420
+ // closed) while a reactive dependency on its fetch config was still
421
+ // scheduling this run. `__ref` is the framework's marker for "alive" —
422
+ // if it's gone, bail before anything else touches `element`.
423
+ if (!element || !element.__ref) return
424
+
425
+ // enabled check
426
+ if (config.enabled === false) return
427
+ if (isFunction(config.enabled) && !config.enabled(element, element.state)) return
428
+ // Also honor `skip` as an alias — some call sites already use it.
429
+ if (config.skip === true) return
430
+ if (isFunction(config.skip) && config.skip(element, element.state)) return
431
+
432
+ const adapter = await resolveAdapter(db, context)
433
+ if (!adapter) return
434
+
435
+ // Re-check after the await — a slow adapter-resolve can outlive the
436
+ // element that triggered the fetch.
437
+ if (!element.__ref) return
438
+
439
+ const ref = element.__ref
440
+ const {
441
+ from, method, query, params: rawParams, cache: cacheRaw, transform,
442
+ single, auth, fields, as: stateKey, limit, offset, order,
443
+ headers, baseUrl,
444
+ // Pagination
445
+ page, cursor, getNextPageParam, getPreviousPageParam, infinite,
446
+ // TanStack-style options
447
+ placeholderData, initialData, select: selectTransform,
448
+ keepPreviousData
449
+ } = config
450
+
451
+ let select = isString(selectTransform) ? selectTransform : undefined
452
+ if (query && isFunction(element.getQuery)) {
453
+ const q = element.getQuery(adapter.name || 'paths')
454
+ if (q) select = q.select || (q.length && q.join(',')) || select
455
+ }
456
+
457
+ const params = resolveParams(rawParams, element, context)
458
+ const cacheConfig = parseCacheConfig(cacheRaw)
459
+ const retryConfig = resolveRetryConfig(config)
460
+ const lang = resolveLanguage(element, context)
461
+
462
+ // Build cache key — lang suffix lets queryClient.dropNonLang(keepLang)
463
+ // evict stale-language entries on setLang() without flushing the rest.
464
+ const langSuffix = lang ? `:${lang}` : ''
465
+ const cacheKey = cacheConfig
466
+ ? (cacheRaw?.key || `${from}:${method}:${JSON.stringify(params || '')}${infinite ? ':infinite' : ''}${page ? ':p' + JSON.stringify(page) : ''}${langSuffix}`)
467
+ : null
468
+
469
+ // Apply initial/placeholder data on first mount
470
+ if (!ref.__fetchInitialized && cacheKey) {
471
+ ref.__fetchInitialized = true
472
+ if (initialData !== undefined) {
473
+ const initData = isFunction(initialData) ? initialData() : initialData
474
+ setCacheEntry(cacheKey, initData, null)
475
+ }
476
+ }
477
+
478
+ // Check cache (stale-while-revalidate)
479
+ if ((method === 'select' || method === 'rpc') && cacheKey && cacheConfig) {
480
+ const cached = getCacheEntry(cacheKey)
481
+ if (cached && !cached.error) {
482
+ const age = Date.now() - cached.time
483
+ const isStale = cached.stale || age > (cacheConfig.staleTime || 0)
484
+ let data = cached.data
485
+ if (isFunction(selectTransform)) data = selectTransform(data, element, element.state)
486
+ if (transform) data = transform(data, element, element.state)
487
+ updateElementState(element, data, stateKey)
488
+ if (!isStale) {
489
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
490
+ return
491
+ }
492
+ // Stale — continue to background refetch
493
+ } else if (placeholderData !== undefined) {
494
+ const phData = isFunction(placeholderData) ? placeholderData(element, element.state) : placeholderData
495
+ if (phData !== undefined) {
496
+ updateElementState(element, phData, stateKey)
497
+ }
498
+ }
499
+ }
500
+
501
+ // Deduplication — if same query is already in-flight, share the promise (with timeout)
502
+ if (cacheKey && activeQueries.has(cacheKey) && !opts.force) {
503
+ const existing = activeQueries.get(cacheKey)
504
+ try {
505
+ const result = await Promise.race([existing, new Promise((_, r) => setTimeout(() => r('DEDUP_TIMEOUT'), 15000))])
506
+ if (result?.data !== undefined) {
507
+ let finalData = result.data
508
+ if (isFunction(selectTransform)) finalData = selectTransform(finalData, element, element.state)
509
+ if (transform) finalData = transform(finalData, element, element.state)
510
+ updateElementState(element, finalData, stateKey)
511
+ triggerCallback(element, 'onFetchComplete', finalData)
512
+ triggerConfigCallback(config, element, 'onFetchComplete', finalData)
513
+ }
514
+ } catch (e) {
515
+ // Error handled by the original caller
516
+ }
517
+ return
518
+ }
519
+
520
+ // Set loading status
521
+ const isFirstLoad = !getCacheEntry(cacheKey)?.data
522
+ setFetchStatus(element, {
523
+ isFetching: true,
524
+ isLoading: isFirstLoad,
525
+ isStale: false,
526
+ isSuccess: false,
527
+ error: null,
528
+ status: 'pending',
529
+ fetchStatus: 'fetching'
530
+ })
531
+ triggerCallback(element, 'onFetchStart')
532
+ triggerConfigCallback(config, element, 'onFetchStart')
533
+
534
+ const _emitNet = context?.analyze?.emitNetwork
535
+ const __analyzeStart = _emitNet ? (typeof performance !== 'undefined' ? performance.now() : Date.now()) : 0
536
+ if (_emitNet) _emitNet({ phase: 'start', mode: 'query', from, method, cacheKey, lang })
537
+
538
+ const doFetch = async () => {
539
+ if (auth !== false && adapter.auth !== false && adapter.getSession) {
540
+ const session = await adapter.getSession()
541
+ if (auth === true && !session) {
542
+ const err = { message: 'Not authenticated' }
543
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error: err, status: 'error', fetchStatus: 'idle' })
544
+ triggerCallback(element, 'onFetchError', err)
545
+ triggerConfigCallback(config, element, 'onFetchError', err)
546
+ return { data: null, error: err }
547
+ }
548
+ }
549
+
550
+ const fn = adapter[method]
551
+ if (!isFunction(fn)) return { data: null, error: { message: `Method "${method}" not found on adapter` } }
552
+
553
+ const request = { from, select, params, single, limit, offset, order, headers, baseUrl }
554
+
555
+ // Inject language into request headers
556
+ const lang = resolveLanguage(element, context)
557
+ if (lang) {
558
+ request.headers = { ...request.headers, 'Accept-Language': lang }
559
+ }
560
+
561
+ // Pagination support
562
+ if (page !== undefined) {
563
+ if (isObject(page)) {
564
+ if (page.offset !== undefined) request.offset = page.offset
565
+ if (page.limit !== undefined) request.limit = page.limit
566
+ if (page.cursor !== undefined) request.params = { ...request.params, cursor: page.cursor }
567
+ } else if (typeof page === 'number') {
568
+ const pageSize = config.pageSize || limit || 20
569
+ request.offset = (page - 1) * pageSize
570
+ request.limit = pageSize
571
+ }
572
+ }
573
+
574
+ if (cursor !== undefined) {
575
+ request.params = { ...request.params, cursor }
576
+ }
577
+
578
+ if (method === 'insert' || method === 'update' || method === 'upsert') {
579
+ let data
580
+ if (fields === true || config.on === 'submit') {
581
+ data = collectFormData(element)
582
+ } else if (isArray(fields)) {
583
+ const formData = collectFormData(element)
584
+ data = {}
585
+ for (const f of fields) data[f] = formData[f]
586
+ } else if (element.state?.parse) {
587
+ data = element.state.parse()
588
+ } else if (isObject(element.state)) {
589
+ data = { ...element.state }
590
+ }
591
+
592
+ if (transform) data = transform(data, element, element.state)
593
+ request.data = data
594
+ }
595
+
596
+ if (method === 'rpc') {
597
+ let rpcParams = params
598
+ if (config.on === 'submit') {
599
+ const formData = collectFormData(element)
600
+ rpcParams = config.transformParams
601
+ ? config.transformParams(formData, element, element.state)
602
+ : formData
603
+ }
604
+ request.params = rpcParams
605
+ }
606
+
607
+ if (method === 'subscribe') {
608
+ const unsubscribe = adapter.subscribe(
609
+ { from, params, on: config.subscribeOn },
610
+ (newData, oldData, payload) => {
611
+ updateElementState(element, newData, stateKey)
612
+ triggerCallback(element, 'onFetchComplete', newData)
613
+ triggerConfigCallback(config, element, 'onFetchComplete', newData)
614
+ }
615
+ )
616
+ ref.__unsubscribe = unsubscribe
617
+ return { data: null, error: null, subscribed: true }
618
+ }
619
+
620
+ return fn(request)
621
+ }
622
+
623
+ // Execute with retry
624
+ const fetchPromise = withRetry(doFetch, retryConfig)
625
+
626
+ // Store for deduplication
627
+ if (cacheKey) activeQueries.set(cacheKey, fetchPromise)
628
+
629
+ try {
630
+ const result = await fetchPromise
631
+ if (result?.subscribed) return
632
+
633
+ const { data, error } = result || {}
634
+
635
+ if (error) {
636
+ if (cacheKey) setCacheEntry(cacheKey, null, error)
637
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error, status: 'error', fetchStatus: 'idle' })
638
+ triggerCallback(element, 'onFetchError', error)
639
+ triggerConfigCallback(config, element, 'onFetchError', error)
640
+ if (_emitNet) _emitNet({
641
+ phase: 'error', mode: 'query', from, method, cacheKey,
642
+ error: error.message || error,
643
+ durationMs: (typeof performance !== 'undefined' ? performance.now() : Date.now()) - __analyzeStart
644
+ })
645
+ return
646
+ }
647
+
648
+ if (data !== undefined) {
649
+ // Update cache
650
+ if ((method === 'select' || method === 'rpc') && cacheKey) {
651
+ const entry = setCacheEntry(cacheKey, data, null)
652
+ entry.gcTime = cacheConfig?.gcTime || 300000
653
+ }
654
+
655
+ let finalData = data
656
+
657
+ // Infinite query — append pages
658
+ if (infinite && isArray(data)) {
659
+ if (!ref.__pages) ref.__pages = []
660
+ if (opts.direction === 'previous') {
661
+ ref.__pages.unshift(data)
662
+ } else {
663
+ ref.__pages.push(data)
664
+ }
665
+ finalData = ref.__pages.flat()
666
+
667
+ // Resolve pagination cursors
668
+ if (getNextPageParam) {
669
+ ref.__nextPageParam = getNextPageParam(data, ref.__pages)
670
+ ref.__hasNextPage = ref.__nextPageParam != null
671
+ }
672
+ if (getPreviousPageParam) {
673
+ ref.__prevPageParam = getPreviousPageParam(data, ref.__pages)
674
+ ref.__hasPreviousPage = ref.__prevPageParam != null
675
+ }
676
+ }
677
+
678
+ if (isFunction(selectTransform)) finalData = selectTransform(finalData, element, element.state)
679
+ if (transform) finalData = transform(finalData, element, element.state)
680
+
681
+ // keepPreviousData — don't clear state while fetching new page
682
+ if (!keepPreviousData || finalData !== undefined) {
683
+ updateElementState(element, finalData, stateKey)
684
+ }
685
+
686
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
687
+ triggerCallback(element, 'onFetchComplete', finalData)
688
+ triggerConfigCallback(config, element, 'onFetchComplete', finalData)
689
+ if (_emitNet) _emitNet({
690
+ phase: 'success', mode: 'query', ok: true, from, method, cacheKey,
691
+ durationMs: (typeof performance !== 'undefined' ? performance.now() : Date.now()) - __analyzeStart
692
+ })
693
+ }
694
+ } catch (e) {
695
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error: e, status: 'error', fetchStatus: 'idle' })
696
+ triggerCallback(element, 'onFetchError', e)
697
+ triggerConfigCallback(config, element, 'onFetchError', e)
698
+ if (_emitNet) _emitNet({
699
+ phase: 'error', mode: 'query', from, method, cacheKey,
700
+ error: e.message || String(e),
701
+ durationMs: (typeof performance !== 'undefined' ? performance.now() : Date.now()) - __analyzeStart
702
+ })
703
+ } finally {
704
+ if (cacheKey) activeQueries.delete(cacheKey)
705
+ }
706
+ }
707
+
708
+ // --- Event binding ---
709
+
710
+ const bindEvent = (config, element, context) => {
711
+ const on = config.on
712
+ const ref = element.__ref
713
+ if (!ref.__fetchListeners) ref.__fetchListeners = []
714
+ const runner = config.__runner || runFetch
715
+
716
+ if (on === 'submit') {
717
+ const handler = (e) => {
718
+ e.preventDefault()
719
+ runner(config, element, context)
720
+ }
721
+ const node = element.node
722
+ if (node) {
723
+ node.addEventListener('submit', handler)
724
+ ref.__fetchListeners.push(() => node.removeEventListener('submit', handler))
725
+ }
726
+ } else if (on === 'click') {
727
+ const handler = () => runner(config, element, context)
728
+ const node = element.node
729
+ if (node) {
730
+ node.addEventListener('click', handler)
731
+ ref.__fetchListeners.push(() => node.removeEventListener('click', handler))
732
+ }
733
+ } else if (on === 'stateChange') {
734
+ ref.__fetchOnStateChange = () => runner(config, element, context)
735
+ }
736
+ }
737
+
738
+ // --- Refetch interval / focus / reconnect binding ---
739
+
740
+ const bindAutoRefetch = (config, element, context) => {
741
+ const ref = element.__ref
742
+ if (!ref.__fetchCleanups) ref.__fetchCleanups = []
743
+
744
+ attachGlobalListeners()
745
+
746
+ // refetchInterval
747
+ const interval = config.refetchInterval
748
+ if (interval) {
749
+ const ms = typeof interval === 'number' ? interval : parseDuration(interval)
750
+ if (ms > 0) {
751
+ const timer = setInterval(() => {
752
+ // Only refetch if visible (unless refetchIntervalInBackground is set)
753
+ if (config.refetchIntervalInBackground || typeof document === 'undefined' || document.visibilityState === 'visible') {
754
+ runFetch(config, element, context, { force: true })
755
+ }
756
+ }, ms)
757
+ ref.__fetchCleanups.push(() => clearInterval(timer))
758
+ }
759
+ }
760
+
761
+ // refetchOnWindowFocus
762
+ if (config.refetchOnWindowFocus !== false) {
763
+ const onFocus = () => {
764
+ const lang = resolveLanguage(element, context)
765
+ const cacheKey = buildCacheKey(config, lang)
766
+ const entry = getCacheEntry(cacheKey)
767
+ const cacheConfig = parseCacheConfig(config.cache)
768
+ if (!entry || entry.stale || (Date.now() - entry.time) > (cacheConfig?.staleTime || 0)) {
769
+ runFetch(config, element, context, { force: true })
770
+ }
771
+ }
772
+ globalListeners.focus.add(onFocus)
773
+ ref.__fetchCleanups.push(() => globalListeners.focus.delete(onFocus))
774
+ }
775
+
776
+ // refetchOnReconnect
777
+ if (config.refetchOnReconnect !== false) {
778
+ const onOnline = () => runFetch(config, element, context, { force: true })
779
+ globalListeners.online.add(onOnline)
780
+ ref.__fetchCleanups.push(() => globalListeners.online.delete(onOnline))
781
+ }
782
+ }
783
+
784
+ // --- Optimistic updates ---
785
+
786
+ const applyOptimisticUpdate = (element, config, mutationData) => {
787
+ if (!config.optimistic) return null
788
+ const ref = element.__ref
789
+ const stateKey = config.as
790
+
791
+ // Snapshot current state for rollback
792
+ const snapshot = element.state?.parse ? element.state.parse() : (isObject(element.state) ? { ...element.state } : element.state)
793
+ ref.__optimisticSnapshot = snapshot
794
+
795
+ const optimisticData = isFunction(config.optimistic)
796
+ ? config.optimistic(mutationData, snapshot, element)
797
+ : config.optimistic
798
+
799
+ if (optimisticData !== undefined) {
800
+ updateElementState(element, optimisticData, stateKey)
801
+ }
802
+
803
+ return snapshot
804
+ }
805
+
806
+ const rollbackOptimistic = (element, config) => {
807
+ const ref = element.__ref
808
+ const snapshot = ref.__optimisticSnapshot
809
+ if (snapshot !== undefined) {
810
+ updateElementState(element, snapshot, config.as)
811
+ delete ref.__optimisticSnapshot
812
+ }
813
+ }
814
+
815
+ // --- Mutation runner (insert/update/delete with optimistic + invalidation) ---
816
+
817
+ const runMutation = async (config, element, context) => {
818
+ const db = context?.fetch
819
+ if (!db) return
820
+
821
+ const adapter = await resolveAdapter(db, context)
822
+ if (!adapter) return
823
+
824
+ const ref = element.__ref
825
+ const { method, from, fields, transform, as: stateKey, on: trigger, auth, headers, baseUrl, invalidates, optimistic, onMutate, onSuccess, onError, onSettled } = config
826
+
827
+ // Collect mutation data
828
+ let mutationData
829
+ if (fields === true || trigger === 'submit') {
830
+ mutationData = collectFormData(element)
831
+ } else if (isArray(fields)) {
832
+ const formData = collectFormData(element)
833
+ mutationData = {}
834
+ for (const f of fields) mutationData[f] = formData[f]
835
+ } else if (element.state?.parse) {
836
+ mutationData = element.state.parse()
837
+ } else if (isObject(element.state)) {
838
+ mutationData = { ...element.state }
839
+ }
840
+
841
+ if (transform) mutationData = transform(mutationData, element, element.state)
842
+
843
+ // onMutate callback (runs before fetch)
844
+ if (isFunction(onMutate)) onMutate(mutationData, element, element.state)
845
+
846
+ // Apply optimistic update
847
+ const snapshot = optimistic ? applyOptimisticUpdate(element, config, mutationData) : null
848
+
849
+ ref.__fetching = true
850
+ setFetchStatus(element, { isFetching: true, isLoading: false, isStale: false, isSuccess: false, error: null, status: 'pending', fetchStatus: 'fetching' })
851
+ triggerCallback(element, 'onFetchStart')
852
+ triggerConfigCallback(config, element, 'onFetchStart')
853
+
854
+ const _emitNet = context?.analyze?.emitNetwork
855
+ const __analyzeStart = _emitNet ? (typeof performance !== 'undefined' ? performance.now() : Date.now()) : 0
856
+ if (_emitNet) _emitNet({ phase: 'start', mode: 'mutation', from, method })
857
+
858
+ try {
859
+ if (auth !== false && adapter.auth !== false && adapter.getSession) {
860
+ const session = await adapter.getSession()
861
+ if (auth === true && !session) {
862
+ if (snapshot !== undefined) rollbackOptimistic(element, config)
863
+ const err = { message: 'Not authenticated' }
864
+ triggerCallback(element, 'onFetchError', err)
865
+ triggerConfigCallback(config, element, 'onFetchError', err)
866
+ return
867
+ }
868
+ }
869
+
870
+ const fn = adapter[method]
871
+ if (!isFunction(fn)) return
872
+
873
+ const request = { from, data: mutationData, headers, baseUrl }
874
+ if (config.params) request.params = resolveParams(config.params, element, context)
875
+
876
+ // Inject language into mutation request headers
877
+ const lang = resolveLanguage(element, context)
878
+ if (lang) {
879
+ request.headers = { ...request.headers, 'Accept-Language': lang }
880
+ }
881
+
882
+ const retryConfig = resolveRetryConfig(config)
883
+ const result = await withRetry(() => fn(request), retryConfig)
884
+ const { data, error } = result || {}
885
+
886
+ if (error) {
887
+ if (snapshot !== undefined) rollbackOptimistic(element, config)
888
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error, status: 'error', fetchStatus: 'idle' })
889
+ triggerCallback(element, 'onFetchError', error)
890
+ if (isFunction(onError)) onError(error, mutationData, element)
891
+ if (isFunction(onSettled)) onSettled(null, error, mutationData, element)
892
+ if (_emitNet) _emitNet({
893
+ phase: 'error', mode: 'mutation', from, method,
894
+ error: error.message || String(error),
895
+ durationMs: (typeof performance !== 'undefined' ? performance.now() : Date.now()) - __analyzeStart
896
+ })
897
+ return
898
+ }
899
+
900
+ delete ref.__optimisticSnapshot
901
+
902
+ if (data !== undefined) {
903
+ const finalData = stateKey ? { [stateKey]: data } : data
904
+ if (element.state?.update) element.state.update(finalData, { preventFetch: true })
905
+ }
906
+
907
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
908
+ triggerCallback(element, 'onFetchComplete', data)
909
+ triggerConfigCallback(config, element, 'onFetchComplete', data)
910
+ if (isFunction(onSuccess)) onSuccess(data, mutationData, element)
911
+ if (isFunction(onSettled)) onSettled(data, null, mutationData, element)
912
+ if (_emitNet) _emitNet({
913
+ phase: 'success', mode: 'mutation', ok: true, from, method,
914
+ durationMs: (typeof performance !== 'undefined' ? performance.now() : Date.now()) - __analyzeStart
915
+ })
916
+
917
+ // Invalidate related queries
918
+ if (invalidates) {
919
+ const keys = isArray(invalidates) ? invalidates : [invalidates]
920
+ for (const k of keys) {
921
+ if (k === true || k === '*') {
922
+ // Invalidate all queries matching this `from`
923
+ for (const [ck] of cacheStore) {
924
+ if (ck.startsWith(from + ':')) invalidateCache(ck)
925
+ }
926
+ } else {
927
+ invalidateCache(k)
928
+ }
929
+ }
930
+ }
931
+ } catch (e) {
932
+ if (snapshot !== undefined) rollbackOptimistic(element, config)
933
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error: e, status: 'error', fetchStatus: 'idle' })
934
+ triggerCallback(element, 'onFetchError', e)
935
+ triggerConfigCallback(config, element, 'onFetchError', e)
936
+ if (isFunction(onError)) onError(e, mutationData, element)
937
+ if (isFunction(onSettled)) onSettled(null, e, mutationData, element)
938
+ if (_emitNet) _emitNet({
939
+ phase: 'error', mode: 'mutation', from, method,
940
+ error: e.message || String(e),
941
+ durationMs: (typeof performance !== 'undefined' ? performance.now() : Date.now()) - __analyzeStart
942
+ })
943
+ } finally {
944
+ ref.__fetching = false
945
+ }
946
+ }
947
+
948
+ // --- Public API ---
949
+
950
+ export const executeFetch = (param, element, state, context) => {
951
+ if (!param) return
952
+ const db = context?.fetch
953
+ if (!db) return
954
+
955
+ const fetchProp = exec(param, element)
956
+ if (!fetchProp) return
957
+
958
+ const configs = isArray(fetchProp)
959
+ ? fetchProp.map(c => resolveFetchConfig(exec(c, element), element)).filter(Boolean)
960
+ : [resolveFetchConfig(fetchProp, element)].filter(Boolean)
961
+
962
+ for (const config of configs) {
963
+ const isMutation = config.method === 'insert' || config.method === 'update' || config.method === 'upsert' || config.method === 'delete'
964
+ const runner = isMutation ? runMutation : runFetch
965
+
966
+ if (config.on === 'create') {
967
+ runner(config, element, context)
968
+ } else {
969
+ Promise.resolve().then(() => {
970
+ bindEvent({ ...config, __runner: runner }, element, context)
971
+ })
972
+ }
973
+
974
+ // Bind auto-refetch for queries
975
+ if (!isMutation && config.on === 'create') {
976
+ if (config.refetchInterval || config.refetchOnWindowFocus !== false || config.refetchOnReconnect !== false) {
977
+ bindAutoRefetch(config, element, context)
978
+ }
979
+ }
980
+ }
981
+
982
+ // Expose imperative methods on element
983
+ const ref = element.__ref
984
+ ref.refetch = (opts) => {
985
+ for (const config of configs) {
986
+ runFetch(config, element, context, { force: true, ...opts })
987
+ }
988
+ }
989
+
990
+ ref.fetchNextPage = () => {
991
+ const config = configs[0]
992
+ if (!config || !config.infinite) return
993
+ const nextParam = ref.__nextPageParam
994
+ if (nextParam == null) return
995
+ const nextConfig = { ...config, cursor: nextParam }
996
+ runFetch(nextConfig, element, context, { direction: 'next', force: true })
997
+ }
998
+
999
+ ref.fetchPreviousPage = () => {
1000
+ const config = configs[0]
1001
+ if (!config || !config.infinite) return
1002
+ const prevParam = ref.__prevPageParam
1003
+ if (prevParam == null) return
1004
+ const prevConfig = { ...config, cursor: prevParam }
1005
+ runFetch(prevConfig, element, context, { direction: 'previous', force: true })
1006
+ }
1007
+ }
1008
+
1009
+ // --- Query client (global cache management) ---
1010
+
1011
+ export const queryClient = {
1012
+ invalidateQueries: (keyOrPattern) => {
1013
+ if (!keyOrPattern) {
1014
+ invalidateCache()
1015
+ return
1016
+ }
1017
+ if (isString(keyOrPattern)) {
1018
+ for (const [key] of cacheStore) {
1019
+ if (key.startsWith(keyOrPattern) || key.includes(keyOrPattern)) {
1020
+ invalidateCache(key)
1021
+ }
1022
+ }
1023
+ } else if (isArray(keyOrPattern)) {
1024
+ const pattern = keyOrPattern.join(':')
1025
+ for (const [key] of cacheStore) {
1026
+ if (key.includes(pattern)) invalidateCache(key)
1027
+ }
1028
+ }
1029
+ },
1030
+
1031
+ removeQueries: (keyOrPattern) => {
1032
+ if (!keyOrPattern) {
1033
+ removeCache()
1034
+ return
1035
+ }
1036
+ if (isString(keyOrPattern)) {
1037
+ for (const [key] of cacheStore) {
1038
+ if (key.startsWith(keyOrPattern) || key.includes(keyOrPattern)) {
1039
+ removeCache(key)
1040
+ }
1041
+ }
1042
+ }
1043
+ },
1044
+
1045
+ getQueryData: (key) => {
1046
+ const entry = getCacheEntry(key)
1047
+ return entry?.data ?? undefined
1048
+ },
1049
+
1050
+ setQueryData: (key, updater) => {
1051
+ const existing = getCacheEntry(key)
1052
+ const data = isFunction(updater) ? updater(existing?.data) : updater
1053
+ setCacheEntry(key, data, null)
1054
+ },
1055
+
1056
+ prefetchQuery: async (config, context) => {
1057
+ const db = context?.fetch
1058
+ if (!db) return
1059
+
1060
+ const adapter = await resolveAdapter(db, context)
1061
+ if (!adapter) return
1062
+
1063
+ const cacheKey = config.cache?.key || `${config.from}:${config.method || 'select'}:${JSON.stringify(config.params || '')}`
1064
+ const fn = adapter[config.method || 'select']
1065
+ if (!isFunction(fn)) return
1066
+
1067
+ const result = await fn({
1068
+ from: config.from,
1069
+ params: config.params,
1070
+ limit: config.limit,
1071
+ offset: config.offset,
1072
+ order: config.order,
1073
+ single: config.single
1074
+ })
1075
+
1076
+ if (result?.data !== undefined && !result?.error) {
1077
+ const entry = setCacheEntry(cacheKey, result.data, null)
1078
+ entry.gcTime = parseCacheConfig(config.cache)?.gcTime || 300000
1079
+ }
1080
+
1081
+ return result
1082
+ },
1083
+
1084
+ getCache: () => cacheStore,
1085
+ clear: () => removeCache(),
1086
+
1087
+ // Drop every cache entry whose lang suffix isn't `keepLang`.
1088
+ // Cache keys are of the form `${from}:${method}:${params}[:lang]` — entries
1089
+ // without a lang suffix are kept (they were inserted before lang gating
1090
+ // was active). Used by setLang() to evict stale-language entries while
1091
+ // keeping the new active lang's entries warm.
1092
+ //
1093
+ // The English-fallback policy: callers typically pass `'en'` as keepLang
1094
+ // so English bundle stays cached even after switching to e.g. French,
1095
+ // since the page's English fallback may still render where French data
1096
+ // hasn't loaded yet.
1097
+ dropNonLang: (keepLang) => {
1098
+ if (!keepLang) return
1099
+ const keepSuffix = `:${keepLang}`
1100
+ for (const [key] of cacheStore) {
1101
+ // Entry without any lang suffix: presence of `:` in middle but not
1102
+ // matching any 2-3 letter ISO suffix at the end. Conservative: keep
1103
+ // entries whose tail doesn't look like `:xx` or `:xxx`.
1104
+ const tailMatch = key.match(/:([a-z]{2,3})$/i)
1105
+ if (!tailMatch) continue // no lang suffix → keep
1106
+ if (key.endsWith(keepSuffix)) continue // matches keepLang → keep
1107
+ removeCache(key)
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ // --- Plugin interface ---
1113
+
1114
+ /**
1115
+ * domql plugin for declarative data fetching.
1116
+ *
1117
+ * Usage:
1118
+ * context.plugins = [fetchPlugin]
1119
+ *
1120
+ * Or with language:
1121
+ * context.plugins = [fetchPlugin]
1122
+ * context.language = 'en'
1123
+ *
1124
+ * When used as a plugin, add `fetch` to `context.define` or
1125
+ * it is automatically registered via `defaultDefine.fetch`.
1126
+ */
1127
+ export const fetchPlugin = {
1128
+ name: 'fetch',
1129
+
1130
+ // Hook into element creation to auto-execute fetch configs
1131
+ create (element) {
1132
+ const fetchProp = element.fetch
1133
+ if (!fetchProp) return
1134
+ // Clear stale in-flight queries on root element creation
1135
+ if (!element.parent) activeQueries.clear()
1136
+ executeFetch(fetchProp, element, element.state, element.context)
1137
+ }
1138
+ }
1139
+
1140
+ export { parseDuration }
1141
+ export default executeFetch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbo.ls/fetch",
3
- "version": "3.14.7",
3
+ "version": "3.14.8",
4
4
  "license": "CC-BY-NC-4.0",
5
5
  "type": "module",
6
6
  "module": "./dist/esm/index.js",
@@ -35,12 +35,13 @@
35
35
  "source": "index.js",
36
36
  "files": [
37
37
  "dist",
38
+ "*.js",
38
39
  "*.md",
39
40
  "LICENSE",
40
41
  "adapters"
41
42
  ],
42
43
  "dependencies": {
43
- "@symbo.ls/utils": "^3.14.10"
44
+ "@symbo.ls/utils": "^3.14.11"
44
45
  },
45
46
  "peerDependencies": {
46
47
  "@supabase/supabase-js": "^2.0.0"