@symbo.ls/fetch 3.14.7 → 3.14.9

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