@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.
- package/CHANGELOG.md +10 -0
- package/index.js +1141 -0
- 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.
|
|
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.
|
|
44
|
+
"@symbo.ls/utils": "^3.14.11"
|
|
44
45
|
},
|
|
45
46
|
"peerDependencies": {
|
|
46
47
|
"@supabase/supabase-js": "^2.0.0"
|