@symbo.ls/fetch 3.6.3 → 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/index.js CHANGED
@@ -1,96 +1,968 @@
1
1
  'use strict'
2
2
 
3
- import * as utils from '@domql/utils'
4
- const { window, overwriteDeep, deepDestringifyFunctions } = utils
3
+ import { isArray, isFunction, isObject, isString, exec } from '@domql/utils'
5
4
 
6
- const IS_DEVELOPMENT =
7
- window && window.location
8
- ? window.location.host.includes('dev.')
9
- : utils.isDevelopment()
5
+ // --- Adapter resolution ---
10
6
 
11
- const SERVER_URL = IS_DEVELOPMENT
12
- ? 'http://localhost:8080/get'
13
- : 'https://api.symbols.app/get'
7
+ const ADAPTER_METHODS = ['select', 'rpc', 'insert', 'update', 'delete', 'subscribe']
14
8
 
15
- const defaultOptions = {
16
- endpoint: SERVER_URL
9
+ const BUILTIN_ADAPTERS = {
10
+ supabase: () => import('./adapters/supabase.js'),
11
+ rest: () => import('./adapters/rest.js'),
12
+ local: () => import('./adapters/local.js')
17
13
  }
18
14
 
19
- export const fetch = globalThis.fetch
15
+ export const registerAdapter = (name, loader) => {
16
+ BUILTIN_ADAPTERS[name] = loader
17
+ }
20
18
 
21
- export const fetchRemote = async (key, options = defaultOptions) => {
22
- const baseUrl = options.endpoint || SERVER_URL
23
- const route = options.serviceRoute
24
- ? utils.isArray(options.serviceRoute)
25
- ? options.serviceRoute.map((v) => v.toLowerCase() + '=true').join('&')
26
- : options.serviceRoute
27
- : ''
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
+ }
28
27
 
29
- let response
30
- try {
31
- response = await fetch(baseUrl + '/' + '?' + route, {
32
- method: 'GET',
33
- headers: {
34
- 'Content-Type': 'application/json',
35
- 'X-AppKey': key,
36
- 'X-Metadata': options.metadata
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
+ const buildCacheKey = (config) => {
66
+ if (config.cache?.key) return config.cache.key
67
+ const params = resolveParamsSync(config.params)
68
+ return `${config.from}:${config.method}:${JSON.stringify(params || '')}`
69
+ }
70
+
71
+ const getCacheEntry = (key) => cacheStore.get(key) || null
72
+
73
+ const setCacheEntry = (key, data, error) => {
74
+ const existing = cacheStore.get(key)
75
+ const entry = {
76
+ data,
77
+ error,
78
+ time: Date.now(),
79
+ stale: false
80
+ }
81
+ cacheStore.set(key, entry)
82
+
83
+ // Notify all subscribers of this query
84
+ const subs = querySubscribers.get(key)
85
+ if (subs) {
86
+ for (const sub of subs) {
87
+ if (isFunction(sub)) sub(entry)
88
+ }
89
+ }
90
+
91
+ return entry
92
+ }
93
+
94
+ const invalidateCache = (key) => {
95
+ if (key) {
96
+ const entry = cacheStore.get(key)
97
+ if (entry) entry.stale = true
98
+ return
99
+ }
100
+ // Invalidate all
101
+ for (const [, entry] of cacheStore) {
102
+ entry.stale = true
103
+ }
104
+ }
105
+
106
+ const removeCache = (key) => {
107
+ if (key) {
108
+ cacheStore.delete(key)
109
+ return
110
+ }
111
+ cacheStore.clear()
112
+ }
113
+
114
+ const subscribeToCacheKey = (key, callback) => {
115
+ if (!querySubscribers.has(key)) querySubscribers.set(key, new Set())
116
+ querySubscribers.get(key).add(callback)
117
+ return () => {
118
+ const subs = querySubscribers.get(key)
119
+ if (subs) {
120
+ subs.delete(callback)
121
+ if (subs.size === 0) querySubscribers.delete(key)
122
+ }
123
+ }
124
+ }
125
+
126
+ // --- Cache config resolution ---
127
+
128
+ const parseCacheConfig = (cache) => {
129
+ if (!cache && cache !== false) return { staleTime: 60000, gcTime: 300000 }
130
+ if (cache === false) return null
131
+ if (cache === true) return { staleTime: 60000, gcTime: 300000 }
132
+ if (typeof cache === 'number') return { staleTime: cache, gcTime: Math.max(cache * 5, 300000) }
133
+ if (isString(cache)) return { staleTime: parseDuration(cache), gcTime: 300000 }
134
+ return {
135
+ staleTime: parseDuration(cache.stale || cache.staleTime) || 60000,
136
+ gcTime: parseDuration(cache.gc || cache.gcTime || cache.expire) || 300000,
137
+ key: cache.key
138
+ }
139
+ }
140
+
141
+ // --- GC ---
142
+
143
+ let gcTimer = null
144
+
145
+ const startGC = () => {
146
+ if (gcTimer) return
147
+ gcTimer = setInterval(() => {
148
+ const now = Date.now()
149
+ for (const [key, entry] of cacheStore) {
150
+ const subs = querySubscribers.get(key)
151
+ const hasSubscribers = subs && subs.size > 0
152
+ if (!hasSubscribers && (now - entry.time) > (entry.gcTime || 300000)) {
153
+ cacheStore.delete(key)
37
154
  }
155
+ }
156
+ }, 30000)
157
+ if (gcTimer.unref) gcTimer.unref()
158
+ }
159
+
160
+ startGC()
161
+
162
+ // --- Retry logic ---
163
+
164
+ const DEFAULT_RETRY = 3
165
+ const DEFAULT_RETRY_DELAY = (attempt) => Math.min(1000 * 2 ** attempt, 30000)
166
+
167
+ const resolveRetryConfig = (config) => {
168
+ const retry = config.retry
169
+ if (retry === false) return { count: 0 }
170
+ if (retry === true || retry === undefined) return { count: DEFAULT_RETRY, delay: DEFAULT_RETRY_DELAY }
171
+ if (typeof retry === 'number') return { count: retry, delay: DEFAULT_RETRY_DELAY }
172
+ return {
173
+ count: retry.count ?? DEFAULT_RETRY,
174
+ delay: isFunction(retry.delay) ? retry.delay : (typeof retry.delay === 'number' ? () => retry.delay : DEFAULT_RETRY_DELAY)
175
+ }
176
+ }
177
+
178
+ const withRetry = async (fn, retryConfig) => {
179
+ const { count, delay } = retryConfig
180
+ let lastError
181
+ for (let attempt = 0; attempt <= count; attempt++) {
182
+ try {
183
+ const result = await fn()
184
+ if (result?.error) {
185
+ lastError = result.error
186
+ if (attempt < count) {
187
+ const ms = isFunction(delay) ? delay(attempt, lastError) : delay
188
+ await new Promise(r => setTimeout(r, ms))
189
+ continue
190
+ }
191
+ return result
192
+ }
193
+ return result
194
+ } catch (e) {
195
+ lastError = e
196
+ if (attempt < count) {
197
+ const ms = isFunction(delay) ? delay(attempt, e) : delay
198
+ await new Promise(r => setTimeout(r, ms))
199
+ continue
200
+ }
201
+ throw e
202
+ }
203
+ }
204
+ return { data: null, error: lastError }
205
+ }
206
+
207
+ // --- Refetch on window focus / reconnect ---
208
+
209
+ const globalListeners = { focus: new Set(), online: new Set() }
210
+ let globalListenersAttached = false
211
+
212
+ const attachGlobalListeners = () => {
213
+ if (globalListenersAttached || typeof window === 'undefined') return
214
+ globalListenersAttached = true
215
+
216
+ const onFocus = () => {
217
+ for (const fn of globalListeners.focus) fn()
218
+ }
219
+ const onOnline = () => {
220
+ for (const fn of globalListeners.online) fn()
221
+ }
222
+
223
+ window.addEventListener('visibilitychange', () => {
224
+ if (document.visibilityState === 'visible') onFocus()
225
+ })
226
+ window.addEventListener('focus', onFocus)
227
+ window.addEventListener('online', onOnline)
228
+ }
229
+
230
+ // --- Fetch config resolution ---
231
+
232
+ const resolveFetchConfig = (fetchProp, element) => {
233
+ const ref = element.__ref
234
+
235
+ if (fetchProp === true) {
236
+ return { from: ref.__state || element.key, method: 'select', query: true, on: 'create' }
237
+ }
238
+
239
+ if (isString(fetchProp)) {
240
+ return { from: fetchProp, method: 'select', query: true, on: 'create' }
241
+ }
242
+
243
+ if (isObject(fetchProp)) {
244
+ const config = { ...fetchProp }
245
+ config.from = config.from || ref.__state || element.key
246
+ config.method = config.method || 'select'
247
+ config.on = config.on || 'create'
248
+ if (config.query === undefined) config.query = true
249
+ return config
250
+ }
251
+
252
+ return null
253
+ }
254
+
255
+ const resolveParamsSync = (params) => {
256
+ if (!params || isFunction(params)) return params
257
+ return params
258
+ }
259
+
260
+ const resolveParams = (params, element) => {
261
+ if (!params) return undefined
262
+ if (isFunction(params)) return params(element, element.state)
263
+ const resolved = {}
264
+ for (const key in params) {
265
+ const val = params[key]
266
+ resolved[key] = isFunction(val) ? val(element, element.state) : val
267
+ }
268
+ return resolved
269
+ }
270
+
271
+ export const initAdapterAuth = async (adapter, context) => {
272
+ if (adapter.__authInitialized) return
273
+ adapter.__authInitialized = true
274
+ if (!adapter.getSession) return
275
+ const updateAuth = (user, session) => {
276
+ const root = context.state?.root
277
+ if (root?.update) {
278
+ root.update({ auth: { user, session, loading: false } })
279
+ }
280
+ }
281
+ try {
282
+ const session = await adapter.getSession()
283
+ updateAuth(session?.user || null, session)
284
+ } catch (e) {}
285
+ if (adapter.onAuthStateChange) {
286
+ adapter.onAuthStateChange((event, session) => {
287
+ updateAuth(session?.user || null, session)
38
288
  })
289
+ }
290
+ }
291
+
292
+ const resolveAdapter = async (db, context) => {
293
+ // When adapter is already resolved (e.g. by getDB()), still init auth
294
+ if (isFunction(db.select)) {
295
+ if (db.auth !== false) await initAdapterAuth(db, context)
296
+ return db
297
+ }
298
+ if (db.__resolved) {
299
+ if (db.auth !== false) await initAdapterAuth(db.__resolved, context)
300
+ return db.__resolved
301
+ }
302
+ if (db.__resolving) return db.__resolving
303
+
304
+ db.__resolving = resolveDb(db)
305
+ const resolved = await db.__resolving
306
+ db.__resolved = resolved
307
+ context.db = resolved
308
+ delete db.__resolving
309
+
310
+ // Auto-init auth when adapter supports it and db.auth is enabled
311
+ if (db.auth !== false) await initAdapterAuth(resolved, context)
312
+
313
+ return resolved
314
+ }
39
315
 
40
- return await response.json()
316
+ const triggerCallback = (element, name, ...args) => {
317
+ const fn = isFunction(element[name]) ? element[name]
318
+ : (element.props && isFunction(element.props[name])) ? element.props[name]
319
+ : null
320
+ if (fn) fn.call(element, ...args, element, element.state, element.context)
321
+ }
322
+
323
+ const collectFormData = (element) => {
324
+ const data = {}
325
+ const node = element.node
326
+ if (!node) return data
327
+
328
+ if (node.tagName === 'FORM') {
329
+ const formData = new FormData(node)
330
+ for (const [key, value] of formData.entries()) {
331
+ data[key] = value
332
+ }
333
+ return data
334
+ }
335
+
336
+ const inputs = node.querySelectorAll('input, textarea, select')
337
+ for (let i = 0; i < inputs.length; i++) {
338
+ const input = inputs[i]
339
+ const name = input.name || input.getAttribute('name')
340
+ if (!name) continue
341
+ if (input.type === 'checkbox') {
342
+ data[name] = input.checked
343
+ } else if (input.type === 'file') {
344
+ data[name] = input.files
345
+ } else {
346
+ data[name] = input.value
347
+ }
348
+ }
349
+ return data
350
+ }
351
+
352
+ // --- State update helpers ---
353
+
354
+ const updateElementState = (element, data, stateKey, options = {}) => {
355
+ const stateData = stateKey ? { [stateKey]: data } : data
356
+ if (element.state?.update) {
357
+ element.state.update(stateData, { preventFetch: true, ...options })
358
+ }
359
+ }
360
+
361
+ const setFetchStatus = (element, status) => {
362
+ const ref = element.__ref
363
+ ref.__fetchStatus = status
364
+ ref.__fetching = status.isFetching
365
+ ref.__fetchError = status.error
366
+ }
367
+
368
+ // --- Core fetch runner ---
369
+
370
+ const runFetch = async (config, element, context, opts = {}) => {
371
+ const db = context?.db
372
+ if (!db) return
373
+
374
+ // enabled check
375
+ if (config.enabled === false) return
376
+ if (isFunction(config.enabled) && !config.enabled(element, element.state)) return
377
+
378
+ const adapter = await resolveAdapter(db, context)
379
+ if (!adapter) return
380
+
381
+ const ref = element.__ref
382
+ const {
383
+ from, method, query, params: rawParams, cache: cacheRaw, transform,
384
+ single, auth, fields, as: stateKey, limit, offset, order,
385
+ headers, baseUrl,
386
+ // Pagination
387
+ page, cursor, getNextPageParam, getPreviousPageParam, infinite,
388
+ // TanStack-style options
389
+ placeholderData, initialData, select: selectTransform,
390
+ keepPreviousData
391
+ } = config
392
+
393
+ let select
394
+ if (query && isFunction(element.getQuery)) {
395
+ const q = element.getQuery(adapter.name || 'paths')
396
+ if (q) select = q.select || (q.length && q.join(',')) || undefined
397
+ }
398
+
399
+ const params = resolveParams(rawParams, element)
400
+ const cacheConfig = parseCacheConfig(cacheRaw)
401
+ const retryConfig = resolveRetryConfig(config)
402
+
403
+ // Build cache key
404
+ const cacheKey = cacheConfig
405
+ ? (cacheRaw?.key || `${from}:${method}:${JSON.stringify(params || '')}${infinite ? ':infinite' : ''}${page ? ':p' + JSON.stringify(page) : ''}`)
406
+ : null
407
+
408
+ // Apply initial/placeholder data on first mount
409
+ if (!ref.__fetchInitialized && cacheKey) {
410
+ ref.__fetchInitialized = true
411
+ if (initialData !== undefined) {
412
+ const initData = isFunction(initialData) ? initialData() : initialData
413
+ setCacheEntry(cacheKey, initData, null)
414
+ }
415
+ }
416
+
417
+ // Check cache (stale-while-revalidate)
418
+ if ((method === 'select' || method === 'rpc') && cacheKey && cacheConfig) {
419
+ const cached = getCacheEntry(cacheKey)
420
+ if (cached && !cached.error) {
421
+ const age = Date.now() - cached.time
422
+ const isStale = cached.stale || age > (cacheConfig.staleTime || 0)
423
+ let data = cached.data
424
+ if (selectTransform) data = selectTransform(data, element, element.state)
425
+ if (transform) data = transform(data, element, element.state)
426
+ updateElementState(element, data, stateKey)
427
+ if (!isStale) {
428
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
429
+ return
430
+ }
431
+ // Stale — continue to background refetch
432
+ } else if (placeholderData !== undefined) {
433
+ const phData = isFunction(placeholderData) ? placeholderData(element, element.state) : placeholderData
434
+ if (phData !== undefined) {
435
+ updateElementState(element, phData, stateKey)
436
+ }
437
+ }
438
+ }
439
+
440
+ // Deduplication — if same query is already in-flight, share the promise
441
+ if (cacheKey && activeQueries.has(cacheKey) && !opts.force) {
442
+ const existing = activeQueries.get(cacheKey)
443
+ try {
444
+ const result = await existing
445
+ if (result?.data !== undefined) {
446
+ let finalData = result.data
447
+ if (selectTransform) finalData = selectTransform(finalData, element, element.state)
448
+ if (transform) finalData = transform(finalData, element, element.state)
449
+ updateElementState(element, finalData, stateKey)
450
+ triggerCallback(element, 'onFetchComplete', finalData)
451
+ }
452
+ } catch (e) {
453
+ // Error handled by the original caller
454
+ }
455
+ return
456
+ }
457
+
458
+ // Set loading status
459
+ const isFirstLoad = !getCacheEntry(cacheKey)?.data
460
+ setFetchStatus(element, {
461
+ isFetching: true,
462
+ isLoading: isFirstLoad,
463
+ isStale: false,
464
+ isSuccess: false,
465
+ error: null,
466
+ status: 'pending',
467
+ fetchStatus: 'fetching'
468
+ })
469
+ triggerCallback(element, 'onFetchStart')
470
+
471
+ const doFetch = async () => {
472
+ if (auth !== false && adapter.getSession) {
473
+ const session = await adapter.getSession()
474
+ if (auth === true && !session) {
475
+ const err = { message: 'Not authenticated' }
476
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error: err, status: 'error', fetchStatus: 'idle' })
477
+ triggerCallback(element, 'onFetchError', err)
478
+ return { data: null, error: err }
479
+ }
480
+ }
481
+
482
+ const fn = adapter[method]
483
+ if (!isFunction(fn)) return { data: null, error: { message: `Method "${method}" not found on adapter` } }
484
+
485
+ const request = { from, select, params, single, limit, offset, order, headers, baseUrl }
486
+
487
+ // Pagination support
488
+ if (page !== undefined) {
489
+ if (isObject(page)) {
490
+ if (page.offset !== undefined) request.offset = page.offset
491
+ if (page.limit !== undefined) request.limit = page.limit
492
+ if (page.cursor !== undefined) request.params = { ...request.params, cursor: page.cursor }
493
+ } else if (typeof page === 'number') {
494
+ const pageSize = config.pageSize || limit || 20
495
+ request.offset = (page - 1) * pageSize
496
+ request.limit = pageSize
497
+ }
498
+ }
499
+
500
+ if (cursor !== undefined) {
501
+ request.params = { ...request.params, cursor }
502
+ }
503
+
504
+ if (method === 'insert' || method === 'update' || method === 'upsert') {
505
+ let data
506
+ if (fields === true || config.on === 'submit') {
507
+ data = collectFormData(element)
508
+ } else if (isArray(fields)) {
509
+ const formData = collectFormData(element)
510
+ data = {}
511
+ for (const f of fields) data[f] = formData[f]
512
+ } else if (element.state?.parse) {
513
+ data = element.state.parse()
514
+ } else if (isObject(element.state)) {
515
+ data = { ...element.state }
516
+ }
517
+
518
+ if (transform) data = transform(data, element, element.state)
519
+ request.data = data
520
+ }
521
+
522
+ if (method === 'rpc') {
523
+ let rpcParams = params
524
+ if (config.on === 'submit') {
525
+ const formData = collectFormData(element)
526
+ rpcParams = config.transformParams
527
+ ? config.transformParams(formData, element, element.state)
528
+ : formData
529
+ }
530
+ request.params = rpcParams
531
+ }
532
+
533
+ if (method === 'subscribe') {
534
+ const unsubscribe = adapter.subscribe(
535
+ { from, params, on: config.subscribeOn },
536
+ (newData, oldData, payload) => {
537
+ updateElementState(element, newData, stateKey)
538
+ triggerCallback(element, 'onFetchComplete', newData)
539
+ }
540
+ )
541
+ ref.__unsubscribe = unsubscribe
542
+ return { data: null, error: null, subscribed: true }
543
+ }
544
+
545
+ return fn(request)
546
+ }
547
+
548
+ // Execute with retry
549
+ const fetchPromise = withRetry(doFetch, retryConfig)
550
+
551
+ // Store for deduplication
552
+ if (cacheKey) activeQueries.set(cacheKey, fetchPromise)
553
+
554
+ try {
555
+ const result = await fetchPromise
556
+ if (result?.subscribed) return
557
+
558
+ const { data, error } = result || {}
559
+
560
+ if (error) {
561
+ if (cacheKey) setCacheEntry(cacheKey, null, error)
562
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error, status: 'error', fetchStatus: 'idle' })
563
+ triggerCallback(element, 'onFetchError', error)
564
+ return
565
+ }
566
+
567
+ if (data !== undefined) {
568
+ // Update cache
569
+ if ((method === 'select' || method === 'rpc') && cacheKey) {
570
+ const entry = setCacheEntry(cacheKey, data, null)
571
+ entry.gcTime = cacheConfig?.gcTime || 300000
572
+ }
573
+
574
+ let finalData = data
575
+
576
+ // Infinite query — append pages
577
+ if (infinite && isArray(data)) {
578
+ if (!ref.__pages) ref.__pages = []
579
+ if (opts.direction === 'previous') {
580
+ ref.__pages.unshift(data)
581
+ } else {
582
+ ref.__pages.push(data)
583
+ }
584
+ finalData = ref.__pages.flat()
585
+
586
+ // Resolve pagination cursors
587
+ if (getNextPageParam) {
588
+ ref.__nextPageParam = getNextPageParam(data, ref.__pages)
589
+ ref.__hasNextPage = ref.__nextPageParam != null
590
+ }
591
+ if (getPreviousPageParam) {
592
+ ref.__prevPageParam = getPreviousPageParam(data, ref.__pages)
593
+ ref.__hasPreviousPage = ref.__prevPageParam != null
594
+ }
595
+ }
596
+
597
+ if (selectTransform) finalData = selectTransform(finalData, element, element.state)
598
+ if (transform) finalData = transform(finalData, element, element.state)
599
+
600
+ // keepPreviousData — don't clear state while fetching new page
601
+ if (!keepPreviousData || finalData !== undefined) {
602
+ updateElementState(element, finalData, stateKey)
603
+ }
604
+
605
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
606
+ triggerCallback(element, 'onFetchComplete', finalData)
607
+ }
41
608
  } catch (e) {
42
- if (utils.isFunction(options.onError)) return options.onError(e)
43
- else console.error(e)
609
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error: e, status: 'error', fetchStatus: 'idle' })
610
+ triggerCallback(element, 'onFetchError', e)
611
+ } finally {
612
+ if (cacheKey) activeQueries.delete(cacheKey)
613
+ }
614
+ }
615
+
616
+ // --- Event binding ---
617
+
618
+ const bindEvent = (config, element, context) => {
619
+ const on = config.on
620
+ const ref = element.__ref
621
+ if (!ref.__fetchListeners) ref.__fetchListeners = []
622
+ const runner = config.__runner || runFetch
623
+
624
+ if (on === 'submit') {
625
+ const handler = (e) => {
626
+ e.preventDefault()
627
+ runner(config, element, context)
628
+ }
629
+ const node = element.node
630
+ if (node) {
631
+ node.addEventListener('submit', handler)
632
+ ref.__fetchListeners.push(() => node.removeEventListener('submit', handler))
633
+ }
634
+ } else if (on === 'click') {
635
+ const handler = () => runner(config, element, context)
636
+ const node = element.node
637
+ if (node) {
638
+ node.addEventListener('click', handler)
639
+ ref.__fetchListeners.push(() => node.removeEventListener('click', handler))
640
+ }
641
+ } else if (on === 'stateChange') {
642
+ ref.__fetchOnStateChange = () => runner(config, element, context)
643
+ }
644
+ }
645
+
646
+ // --- Refetch interval / focus / reconnect binding ---
647
+
648
+ const bindAutoRefetch = (config, element, context) => {
649
+ const ref = element.__ref
650
+ if (!ref.__fetchCleanups) ref.__fetchCleanups = []
651
+
652
+ attachGlobalListeners()
653
+
654
+ // refetchInterval
655
+ const interval = config.refetchInterval
656
+ if (interval) {
657
+ const ms = typeof interval === 'number' ? interval : parseDuration(interval)
658
+ if (ms > 0) {
659
+ const timer = setInterval(() => {
660
+ // Only refetch if visible (unless refetchIntervalInBackground is set)
661
+ if (config.refetchIntervalInBackground || typeof document === 'undefined' || document.visibilityState === 'visible') {
662
+ runFetch(config, element, context, { force: true })
663
+ }
664
+ }, ms)
665
+ ref.__fetchCleanups.push(() => clearInterval(timer))
666
+ }
667
+ }
668
+
669
+ // refetchOnWindowFocus
670
+ if (config.refetchOnWindowFocus !== false) {
671
+ const onFocus = () => {
672
+ const cacheKey = buildCacheKey(config)
673
+ const entry = getCacheEntry(cacheKey)
674
+ const cacheConfig = parseCacheConfig(config.cache)
675
+ if (!entry || entry.stale || (Date.now() - entry.time) > (cacheConfig?.staleTime || 0)) {
676
+ runFetch(config, element, context, { force: true })
677
+ }
678
+ }
679
+ globalListeners.focus.add(onFocus)
680
+ ref.__fetchCleanups.push(() => globalListeners.focus.delete(onFocus))
681
+ }
682
+
683
+ // refetchOnReconnect
684
+ if (config.refetchOnReconnect !== false) {
685
+ const onOnline = () => runFetch(config, element, context, { force: true })
686
+ globalListeners.online.add(onOnline)
687
+ ref.__fetchCleanups.push(() => globalListeners.online.delete(onOnline))
688
+ }
689
+ }
690
+
691
+ // --- Optimistic updates ---
692
+
693
+ const applyOptimisticUpdate = (element, config, mutationData) => {
694
+ if (!config.optimistic) return null
695
+ const ref = element.__ref
696
+ const stateKey = config.as
697
+
698
+ // Snapshot current state for rollback
699
+ const snapshot = element.state?.parse ? element.state.parse() : (isObject(element.state) ? { ...element.state } : element.state)
700
+ ref.__optimisticSnapshot = snapshot
701
+
702
+ const optimisticData = isFunction(config.optimistic)
703
+ ? config.optimistic(mutationData, snapshot, element)
704
+ : config.optimistic
705
+
706
+ if (optimisticData !== undefined) {
707
+ updateElementState(element, optimisticData, stateKey)
44
708
  }
709
+
710
+ return snapshot
45
711
  }
46
712
 
47
- export const fetchProject = async (key, options) => {
48
- const { editor } = options
713
+ const rollbackOptimistic = (element, config) => {
714
+ const ref = element.__ref
715
+ const snapshot = ref.__optimisticSnapshot
716
+ if (snapshot !== undefined) {
717
+ updateElementState(element, snapshot, config.as)
718
+ delete ref.__optimisticSnapshot
719
+ }
720
+ }
721
+
722
+ // --- Mutation runner (insert/update/delete with optimistic + invalidation) ---
723
+
724
+ const runMutation = async (config, element, context) => {
725
+ const db = context?.db
726
+ if (!db) return
727
+
728
+ const adapter = await resolveAdapter(db, context)
729
+ if (!adapter) return
730
+
731
+ const ref = element.__ref
732
+ const { method, from, fields, transform, as: stateKey, on: trigger, auth, headers, baseUrl, invalidates, optimistic, onMutate, onSuccess, onError, onSettled } = config
733
+
734
+ // Collect mutation data
735
+ let mutationData
736
+ if (fields === true || trigger === 'submit') {
737
+ mutationData = collectFormData(element)
738
+ } else if (isArray(fields)) {
739
+ const formData = collectFormData(element)
740
+ mutationData = {}
741
+ for (const f of fields) mutationData[f] = formData[f]
742
+ } else if (element.state?.parse) {
743
+ mutationData = element.state.parse()
744
+ } else if (isObject(element.state)) {
745
+ mutationData = { ...element.state }
746
+ }
49
747
 
50
- if (editor && editor.remote) {
51
- const data = await fetchRemote(key, editor)
52
- const evalData =
53
- IS_DEVELOPMENT || options.isDevelopment
54
- ? deepDestringifyFunctions(data)
55
- : deepDestringifyFunctions(data.releases[0])
748
+ if (transform) mutationData = transform(mutationData, element, element.state)
56
749
 
57
- if (editor.serviceRoute) {
58
- if (utils.isArray(editor.serviceRoute)) {
59
- editor.serviceRoute.forEach((route) => {
60
- overwriteDeep(options[route], evalData[route.toLowerCase()])
61
- })
62
- } else {
63
- overwriteDeep(options[editor.serviceRoute], evalData)
750
+ // onMutate callback (runs before fetch)
751
+ if (isFunction(onMutate)) onMutate(mutationData, element, element.state)
752
+
753
+ // Apply optimistic update
754
+ const snapshot = optimistic ? applyOptimisticUpdate(element, config, mutationData) : null
755
+
756
+ ref.__fetching = true
757
+ setFetchStatus(element, { isFetching: true, isLoading: false, isStale: false, isSuccess: false, error: null, status: 'pending', fetchStatus: 'fetching' })
758
+ triggerCallback(element, 'onFetchStart')
759
+
760
+ try {
761
+ if (auth !== false && adapter.getSession) {
762
+ const session = await adapter.getSession()
763
+ if (auth === true && !session) {
764
+ if (snapshot !== undefined) rollbackOptimistic(element, config)
765
+ const err = { message: 'Not authenticated' }
766
+ triggerCallback(element, 'onFetchError', err)
767
+ return
64
768
  }
769
+ }
770
+
771
+ const fn = adapter[method]
772
+ if (!isFunction(fn)) return
773
+
774
+ const request = { from, data: mutationData, headers, baseUrl }
775
+ if (config.params) request.params = resolveParams(config.params, element)
776
+
777
+ const retryConfig = resolveRetryConfig(config)
778
+ const result = await withRetry(() => fn(request), retryConfig)
779
+ const { data, error } = result || {}
780
+
781
+ if (error) {
782
+ if (snapshot !== undefined) rollbackOptimistic(element, config)
783
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error, status: 'error', fetchStatus: 'idle' })
784
+ triggerCallback(element, 'onFetchError', error)
785
+ if (isFunction(onError)) onError(error, mutationData, element)
786
+ if (isFunction(onSettled)) onSettled(null, error, mutationData, element)
787
+ return
788
+ }
789
+
790
+ delete ref.__optimisticSnapshot
791
+
792
+ if (data !== undefined) {
793
+ const finalData = stateKey ? { [stateKey]: data } : data
794
+ if (element.state?.update) element.state.update(finalData, { preventFetch: true })
795
+ }
796
+
797
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
798
+ triggerCallback(element, 'onFetchComplete', data)
799
+ if (isFunction(onSuccess)) onSuccess(data, mutationData, element)
800
+ if (isFunction(onSettled)) onSettled(data, null, mutationData, element)
801
+
802
+ // Invalidate related queries
803
+ if (invalidates) {
804
+ const keys = isArray(invalidates) ? invalidates : [invalidates]
805
+ for (const k of keys) {
806
+ if (k === true || k === '*') {
807
+ // Invalidate all queries matching this `from`
808
+ for (const [ck] of cacheStore) {
809
+ if (ck.startsWith(from + ':')) invalidateCache(ck)
810
+ }
811
+ } else {
812
+ invalidateCache(k)
813
+ }
814
+ }
815
+ }
816
+ } catch (e) {
817
+ if (snapshot !== undefined) rollbackOptimistic(element, config)
818
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error: e, status: 'error', fetchStatus: 'idle' })
819
+ triggerCallback(element, 'onFetchError', e)
820
+ if (isFunction(onError)) onError(e, mutationData, element)
821
+ if (isFunction(onSettled)) onSettled(null, e, mutationData, element)
822
+ } finally {
823
+ ref.__fetching = false
824
+ }
825
+ }
826
+
827
+ // --- Public API ---
828
+
829
+ export const executeFetch = (param, element, state, context) => {
830
+ if (!param) return
831
+ const db = context?.db
832
+ if (!db) return
833
+
834
+ const fetchProp = exec(param, element)
835
+ if (!fetchProp) return
836
+
837
+ const configs = isArray(fetchProp)
838
+ ? fetchProp.map(c => resolveFetchConfig(exec(c, element), element)).filter(Boolean)
839
+ : [resolveFetchConfig(fetchProp, element)].filter(Boolean)
840
+
841
+ for (const config of configs) {
842
+ const isMutation = config.method === 'insert' || config.method === 'update' || config.method === 'upsert' || config.method === 'delete'
843
+ const runner = isMutation ? runMutation : runFetch
844
+
845
+ if (config.on === 'create') {
846
+ runner(config, element, context)
65
847
  } else {
66
- ;[
67
- 'state',
68
- 'designSystem',
69
- 'components',
70
- 'snippets',
71
- 'pages',
72
- 'utils',
73
- 'files',
74
- 'packages',
75
- 'functions'
76
- ].forEach((key) => {
77
- overwriteDeep(options[key], evalData[key.toLowerCase()])
848
+ Promise.resolve().then(() => {
849
+ bindEvent({ ...config, __runner: runner }, element, context)
78
850
  })
79
851
  }
852
+
853
+ // Bind auto-refetch for queries
854
+ if (!isMutation && config.on === 'create') {
855
+ if (config.refetchInterval || config.refetchOnWindowFocus !== false || config.refetchOnReconnect !== false) {
856
+ bindAutoRefetch(config, element, context)
857
+ }
858
+ }
80
859
  }
81
860
 
82
- return options
83
- }
861
+ // Expose imperative methods on element
862
+ const ref = element.__ref
863
+ ref.refetch = (opts) => {
864
+ for (const config of configs) {
865
+ runFetch(config, element, context, { force: true, ...opts })
866
+ }
867
+ }
84
868
 
85
- export const fetchProjectAsync = async (key, options, callback) => {
86
- const { editor } = options
869
+ ref.fetchNextPage = () => {
870
+ const config = configs[0]
871
+ if (!config || !config.infinite) return
872
+ const nextParam = ref.__nextPageParam
873
+ if (nextParam == null) return
874
+ const nextConfig = { ...config, cursor: nextParam }
875
+ runFetch(nextConfig, element, context, { direction: 'next', force: true })
876
+ }
87
877
 
88
- if (editor && editor.remote) {
89
- const data = await fetchRemote(key, editor)
90
- const evalData =
91
- IS_DEVELOPMENT || options.isDevelopment
92
- ? deepDestringifyFunctions(data)
93
- : deepDestringifyFunctions(data.releases[0])
94
- callback(evalData)
878
+ ref.fetchPreviousPage = () => {
879
+ const config = configs[0]
880
+ if (!config || !config.infinite) return
881
+ const prevParam = ref.__prevPageParam
882
+ if (prevParam == null) return
883
+ const prevConfig = { ...config, cursor: prevParam }
884
+ runFetch(prevConfig, element, context, { direction: 'previous', force: true })
95
885
  }
96
886
  }
887
+
888
+ // --- Query client (global cache management) ---
889
+
890
+ export const queryClient = {
891
+ invalidateQueries: (keyOrPattern) => {
892
+ if (!keyOrPattern) {
893
+ invalidateCache()
894
+ return
895
+ }
896
+ if (isString(keyOrPattern)) {
897
+ for (const [key] of cacheStore) {
898
+ if (key.startsWith(keyOrPattern) || key.includes(keyOrPattern)) {
899
+ invalidateCache(key)
900
+ }
901
+ }
902
+ } else if (isArray(keyOrPattern)) {
903
+ const pattern = keyOrPattern.join(':')
904
+ for (const [key] of cacheStore) {
905
+ if (key.includes(pattern)) invalidateCache(key)
906
+ }
907
+ }
908
+ },
909
+
910
+ removeQueries: (keyOrPattern) => {
911
+ if (!keyOrPattern) {
912
+ removeCache()
913
+ return
914
+ }
915
+ if (isString(keyOrPattern)) {
916
+ for (const [key] of cacheStore) {
917
+ if (key.startsWith(keyOrPattern) || key.includes(keyOrPattern)) {
918
+ removeCache(key)
919
+ }
920
+ }
921
+ }
922
+ },
923
+
924
+ getQueryData: (key) => {
925
+ const entry = getCacheEntry(key)
926
+ return entry?.data ?? undefined
927
+ },
928
+
929
+ setQueryData: (key, updater) => {
930
+ const existing = getCacheEntry(key)
931
+ const data = isFunction(updater) ? updater(existing?.data) : updater
932
+ setCacheEntry(key, data, null)
933
+ },
934
+
935
+ prefetchQuery: async (config, context) => {
936
+ const db = context?.db
937
+ if (!db) return
938
+
939
+ const adapter = await resolveAdapter(db, context)
940
+ if (!adapter) return
941
+
942
+ const cacheKey = config.cache?.key || `${config.from}:${config.method || 'select'}:${JSON.stringify(config.params || '')}`
943
+ const fn = adapter[config.method || 'select']
944
+ if (!isFunction(fn)) return
945
+
946
+ const result = await fn({
947
+ from: config.from,
948
+ params: config.params,
949
+ limit: config.limit,
950
+ offset: config.offset,
951
+ order: config.order,
952
+ single: config.single
953
+ })
954
+
955
+ if (result?.data !== undefined && !result?.error) {
956
+ const entry = setCacheEntry(cacheKey, result.data, null)
957
+ entry.gcTime = parseCacheConfig(config.cache)?.gcTime || 300000
958
+ }
959
+
960
+ return result
961
+ },
962
+
963
+ getCache: () => cacheStore,
964
+ clear: () => removeCache()
965
+ }
966
+
967
+ export { parseDuration }
968
+ export default executeFetch