@symbo.ls/fetch 3.14.660 → 3.14.662

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,6 +1,8 @@
1
1
  'use strict'
2
2
 
3
3
  import { isArray, isFunction, isObject, isString, exec } from '@symbo.ls/utils'
4
+ import { createEffect } from '@symbo.ls/signal'
5
+ import { dropEarly } from './early.js'
4
6
 
5
7
  // --- Adapter resolution ---
6
8
 
@@ -122,6 +124,16 @@ export const registerAdapter = (name, loader) => {
122
124
  }
123
125
  }
124
126
 
127
+ // Whether `name` resolves without waiting: a built-in, or a custom adapter
128
+ // that has already been registered (in any module copy — CUSTOM_ADAPTERS is
129
+ // the shared globalThis slot). resolveDb() waits up to
130
+ // _ADAPTER_REGISTRATION_TIMEOUT for an unknown name, which is right for a
131
+ // browser boot race and wrong for a server render, where no page runtime will
132
+ // ever register the platform's 'sdk' adapter — @symbo.ls/brender asks first
133
+ // and skips server prefetch instead of stalling every route for 5 s.
134
+ export const hasAdapter = (name) =>
135
+ typeof name === 'string' && !!(CUSTOM_ADAPTERS[name] || BUILTIN_ADAPTERS[name])
136
+
125
137
  export const createAdapter = (config) => {
126
138
  const adapter = {}
127
139
  for (const method of ADAPTER_METHODS) {
@@ -189,31 +201,58 @@ const cacheStore = new Map()
189
201
  const querySubscribers = new Map() // key -> Set of elements
190
202
  const activeQueries = new Map() // key -> Promise (deduplication)
191
203
 
204
+ // Request order. Every request a query sends, every cache write and every
205
+ // invalidation takes the next number. A reply then knows whether it is older
206
+ // than its key's last invalidation (its rows may predate the write: kept, but
207
+ // never served as fresh) and whether a request that started after it has
208
+ // already written its answer (that newer answer wins — a slow reply never
209
+ // puts old rows back over it).
210
+ let fetchSeq = 0
211
+ const invalidatedAt = new Map() // key -> seq of the key's last invalidation or removal
212
+ const requestSeqs = new WeakMap() // in-flight promise -> seq of its request
213
+
214
+ // The cache (and dedup) key of a query. runFetch writes its entry under it and
215
+ // the window-focus check reads it back, so both build it here, from the params
216
+ // RESOLVED with the element (resolveParams: params written as a function, or
217
+ // with function values) — a check that read them unresolved (a function
218
+ // serialises to nothing) never found a fresh entry and ran the query again on
219
+ // every focus, writing the cached rows back into state each time.
192
220
  // Cache key includes lang when provided so different-locale responses don't
193
221
  // collide. The fetch plugin injects Accept-Language on requests, so the same
194
222
  // (from, method, params) tuple yields different bodies per lang. queryClient.
195
223
  // dropNonLang(keepLang) leans on this suffix to evict stale-language entries
196
224
  // on a setLang() event without touching the kept lang's cache.
197
- const buildCacheKey = (config, lang, scope) => {
225
+ //
226
+ // The page a request asks for is part of its answer, so `page` (offset pages)
227
+ // and `cursor` (an infinite query's next / previous page) are in the key too,
228
+ // RESOLVED like params (`page: (el, s) => s.page`). The cursor was missing:
229
+ // fetchNextPage asked under page 1's key, so with a cache the next page came
230
+ // back as page 1, and page 2's rows were written over page 1's entry.
231
+ const queryCacheKey = (config, params, scope, lang, page = config.page, cursor = config.cursor) => {
198
232
  if (config.cache?.key) return config.cache.key
199
- const params = resolveParamsSync(config.params)
233
+ const { from, method, infinite } = config
200
234
  // Scope segment sits AFTER `${from}:${method}:` so `from`/`from:method`
201
235
  // prefix matching (mutation `invalidates`, queryClient string patterns)
202
236
  // and the trailing lang suffix (dropNonLang) are both preserved.
203
237
  const scopeSeg = scope ? `${scope}:` : ''
204
- const base = `${config.from}:${config.method}:${scopeSeg}${JSON.stringify(params || '')}`
205
- return lang ? `${base}:${lang}` : base
238
+ const langSuffix = lang ? `:${lang}` : ''
239
+ const pageSeg = page ? ':p' + JSON.stringify(page) : ''
240
+ const cursorSeg = cursor !== undefined && cursor !== null ? ':c' + JSON.stringify(cursor) : ''
241
+ return `${from}:${method}:${scopeSeg}${JSON.stringify(params || '')}${infinite ? ':infinite' : ''}${pageSeg}${cursorSeg}${langSuffix}`
206
242
  }
207
243
 
208
244
  const getCacheEntry = (key) => cacheStore.get(key) || null
209
245
 
210
- const setCacheEntry = (key, data, error) => {
211
- const existing = cacheStore.get(key)
246
+ // `seq` is the request the rows came from (a direct write — setQueryData,
247
+ // initialData, a prefetch — is its own, current one); `stale` is set for rows
248
+ // whose request started before the key's last invalidation.
249
+ const setCacheEntry = (key, data, error, seq = ++fetchSeq, stale = false) => {
212
250
  const entry = {
213
251
  data,
214
252
  error,
215
253
  time: Date.now(),
216
- stale: false
254
+ stale,
255
+ seq
217
256
  }
218
257
  cacheStore.set(key, entry)
219
258
 
@@ -228,23 +267,26 @@ const setCacheEntry = (key, data, error) => {
228
267
  return entry
229
268
  }
230
269
 
231
- const invalidateCache = (key) => {
232
- if (key) {
233
- const entry = cacheStore.get(key)
234
- if (entry) entry.stale = true
235
- return
236
- }
237
- // Invalidate all
238
- for (const [, entry] of cacheStore) {
239
- entry.stale = true
240
- }
270
+ // The entry a request that started after `seq` wrote (an error aside): the
271
+ // newer answer, which an older reply must not replace.
272
+ const newerEntry = (key, seq) => {
273
+ const entry = key ? cacheStore.get(key) : null
274
+ return entry && !entry.error && entry.seq > seq ? entry : null
241
275
  }
242
276
 
277
+ // A request that started before its key's last invalidation (or removal).
278
+ const isOutdated = (key, seq) => (invalidatedAt.get(key) || 0) > seq
279
+
280
+ // Removing an entry also marks its key, so a reply still in flight writes
281
+ // its rows back as stale, never as fresh.
243
282
  const removeCache = (key) => {
244
283
  if (key) {
245
284
  cacheStore.delete(key)
285
+ invalidatedAt.set(key, ++fetchSeq)
246
286
  return
247
287
  }
288
+ for (const [cacheKey] of cacheStore) invalidatedAt.set(cacheKey, ++fetchSeq)
289
+ for (const [inFlightKey] of activeQueries) invalidatedAt.set(inFlightKey, ++fetchSeq)
248
290
  cacheStore.clear()
249
291
  }
250
292
 
@@ -275,6 +317,28 @@ const parseCacheConfig = (cache) => {
275
317
  }
276
318
  }
277
319
 
320
+ // --- Mounted queries ---
321
+ //
322
+ // The queries on screen: one record per element and `on: 'create'` query
323
+ // config, registered by executeFetch. An invalidation refetches the ones whose
324
+ // CURRENT key it selects (TanStack's "active" queries) instead of leaving them
325
+ // on the old rows until their next mount or a window focus. The key is
326
+ // computed when needed — the same resolution as the focus check — because
327
+ // params, language and tenant scope can move after mount. A record whose
328
+ // element was removed (`__ref` gone or replaced) is dropped whenever the
329
+ // registry is walked: an invalidation, or the GC sweep.
330
+ const mountedQueries = new Set()
331
+
332
+ const isMounted = (record) => !!record.ref && record.element.__ref === record.ref
333
+
334
+ const mountedKey = ({ config, element, context }) => {
335
+ try {
336
+ return elementQueryKey(config, element, context)
337
+ } catch (e) {
338
+ return null // a params function that throws names no key
339
+ }
340
+ }
341
+
278
342
  // --- GC ---
279
343
 
280
344
  let gcTimer = null
@@ -290,6 +354,14 @@ const startGC = () => {
290
354
  cacheStore.delete(key)
291
355
  }
292
356
  }
357
+ // What nothing can need any more: an invalidation mark matters while its
358
+ // key has rows or a request out, and a removed element is not mounted.
359
+ for (const [key] of invalidatedAt) {
360
+ if (!cacheStore.has(key) && !activeQueries.has(key)) invalidatedAt.delete(key)
361
+ }
362
+ for (const record of mountedQueries) {
363
+ if (!isMounted(record)) mountedQueries.delete(record)
364
+ }
293
365
  }, 30000)
294
366
  if (gcTimer.unref) gcTimer.unref()
295
367
  }
@@ -301,8 +373,13 @@ startGC()
301
373
  const DEFAULT_RETRY = 3
302
374
  const DEFAULT_RETRY_DELAY = (attempt) => Math.min(1000 * 2 ** attempt, 30000)
303
375
 
304
- const resolveRetryConfig = (config) => {
376
+ // `write`: the config changes data (insert / update / upsert / delete, an rpc
377
+ // action). A write is not sent again unless `retry` asks for it: one that
378
+ // failed by timing out may have been applied, and a retry applies it twice.
379
+ // Reads keep the default retries.
380
+ const resolveRetryConfig = (config, write) => {
305
381
  const retry = config.retry
382
+ if (retry === undefined && write) return { count: 0 }
306
383
  if (retry === false) return { count: 0 }
307
384
  if (retry === true || retry === undefined) return { count: DEFAULT_RETRY, delay: DEFAULT_RETRY_DELAY }
308
385
  if (typeof retry === 'number') return { count: retry, delay: DEFAULT_RETRY_DELAY }
@@ -389,9 +466,23 @@ const resolveFetchConfig = (fetchProp, element) => {
389
466
  return null
390
467
  }
391
468
 
392
- const resolveParamsSync = (params) => {
393
- if (!params || isFunction(params)) return params
394
- return params
469
+ // --- Writes and actions ---
470
+ //
471
+ // insert / update / upsert / delete are writes (runMutation). An `rpc` is a
472
+ // call that may write, so it is an ACTION — sent on every trigger, never
473
+ // answered from or written to the query cache, never re-sent by a refetch or
474
+ // an invalidation, its `invalidates` run on success — when a user event fires
475
+ // it (`on: 'submit'`, `on: 'click'`), when it declares `invalidates`, or with
476
+ // `mutation: true` (which makes any method an action). `mutation: false`
477
+ // keeps an rpc a cached query. An rpc read on create stays what it was: a
478
+ // cached, deduplicated view of data.
479
+ const MUTATION_METHODS = ['insert', 'update', 'upsert', 'delete']
480
+ const isMutationMethod = (method) => MUTATION_METHODS.includes(method)
481
+
482
+ const isAction = (config) => {
483
+ if (config.mutation === true) return true
484
+ if (config.mutation === false || config.method !== 'rpc') return false
485
+ return config.on === 'submit' || config.on === 'click' || config.invalidates != null
395
486
  }
396
487
 
397
488
  const resolveLanguage = (element, context) => {
@@ -431,6 +522,22 @@ const resolveParams = (params, element, context) => {
431
522
  return resolved
432
523
  }
433
524
 
525
+ // A paging value (`page`, `cursor`) written as a function resolves with the
526
+ // element, as params do. The README's `page: (el, s) => s.page` was never
527
+ // called: it sent no offset, and its key carried no page.
528
+ const resolveValue = (value, element) => (isFunction(value) ? value(element, element.state) : value)
529
+
530
+ // The key a query of `config` has for `element` right now: the one runFetch
531
+ // writes, the focus check reads, an invalidation matches and keyFor returns.
532
+ const elementQueryKey = (config, element, context) => queryCacheKey(
533
+ config,
534
+ resolveParams(config.params, element, context),
535
+ resolveScope(element, context),
536
+ resolveLanguage(element, context),
537
+ resolveValue(config.page, element),
538
+ resolveValue(config.cursor, element)
539
+ )
540
+
434
541
  export const initAdapterAuth = async (adapter, context) => {
435
542
  if (!adapter || adapter.__authInitialized) return
436
543
  adapter.__authInitialized = true
@@ -572,11 +679,31 @@ const setFetchStatus = (element, status) => {
572
679
  // `TypeError: Cannot set properties of null` from every status write.
573
680
  const ref = element?.__ref
574
681
  if (!ref) return
575
- ref.__fetchStatus = status
682
+ // `isError` is part of the documented status shape; no call site set it.
683
+ ref.__fetchStatus = { ...status, isError: !!status.error }
576
684
  ref.__fetching = status.isFetching
577
685
  ref.__fetchError = status.error
578
686
  }
579
687
 
688
+ // --- Infinite queries ---
689
+
690
+ // The next / previous cursor of an infinite query, as documented:
691
+ // getNextPageParam(lastPage, allPages), getPreviousPageParam(firstPage,
692
+ // allPages). Both used to receive the page just fetched, so a previous page
693
+ // moved the next cursor. Called on every reply and when the first page is
694
+ // served from the cache.
695
+ const setPageCursors = (ref, config, fetched) => {
696
+ const pages = ref.__pages && ref.__pages.length ? ref.__pages : [fetched]
697
+ if (isFunction(config.getNextPageParam)) {
698
+ ref.__nextPageParam = config.getNextPageParam(pages[pages.length - 1], pages)
699
+ ref.__hasNextPage = ref.__nextPageParam != null
700
+ }
701
+ if (isFunction(config.getPreviousPageParam)) {
702
+ ref.__prevPageParam = config.getPreviousPageParam(pages[0], pages)
703
+ ref.__hasPreviousPage = ref.__prevPageParam != null
704
+ }
705
+ }
706
+
580
707
  // --- Core fetch runner ---
581
708
 
582
709
  const runFetch = async (config, element, context, opts = {}) => {
@@ -621,7 +748,7 @@ const runFetch = async (config, element, context, opts = {}) => {
621
748
  single, auth, fields, as: stateKey, limit, offset, order,
622
749
  headers, baseUrl, responseType,
623
750
  // Pagination
624
- page, cursor, getNextPageParam, getPreviousPageParam, infinite,
751
+ page, cursor, infinite,
625
752
  // TanStack-style options
626
753
  placeholderData, initialData, select: selectTransform,
627
754
  keepPreviousData
@@ -634,9 +761,15 @@ const runFetch = async (config, element, context, opts = {}) => {
634
761
  }
635
762
 
636
763
  const params = resolveParams(rawParams, element, context)
637
- // No cache key for a single-use body: no cache entry, no dedup sharing.
638
- const cacheConfig = isSingleUse(config) ? null : parseCacheConfig(cacheRaw)
639
- const retryConfig = resolveRetryConfig(config)
764
+ const pageValue = resolveValue(page, element)
765
+ const cursorValue = resolveValue(cursor, element)
766
+ // An action (isAction) is a call, not a view of data: each trigger sends
767
+ // exactly one request — a second submit inside the stale time used to be
768
+ // answered from the cache, unsent.
769
+ const action = isAction(config)
770
+ // No cache key for a single-use body or an action: no cache entry, no dedup sharing.
771
+ const cacheConfig = isSingleUse(config) || action ? null : parseCacheConfig(cacheRaw)
772
+ const retryConfig = resolveRetryConfig(config, action || isMutationMethod(method))
640
773
  const lang = resolveLanguage(element, context)
641
774
 
642
775
  // Build cache key — lang suffix lets queryClient.dropNonLang(keepLang)
@@ -644,11 +777,7 @@ const runFetch = async (config, element, context, opts = {}) => {
644
777
  // scopeSeg (after `${from}:${method}:`) isolates the cache + dedup keys per
645
778
  // tenant so an org/workspace switch never serves — or dedup-collapses into —
646
779
  // the previous tenant's entry, while keeping `from:method` as the key prefix.
647
- const langSuffix = lang ? `:${lang}` : ''
648
- const scopeSeg = scope ? `${scope}:` : ''
649
- const cacheKey = cacheConfig
650
- ? (cacheRaw?.key || `${from}:${method}:${scopeSeg}${JSON.stringify(params || '')}${infinite ? ':infinite' : ''}${page ? ':p' + JSON.stringify(page) : ''}${langSuffix}`)
651
- : null
780
+ const cacheKey = cacheConfig ? queryCacheKey(config, params, scope, lang, pageValue, cursorValue) : null
652
781
 
653
782
  // Apply initial/placeholder data on first mount
654
783
  if (!ref.__fetchInitialized && cacheKey) {
@@ -659,21 +788,35 @@ const runFetch = async (config, element, context, opts = {}) => {
659
788
  }
660
789
  }
661
790
 
662
- // Check cache (stale-while-revalidate)
663
- if ((method === 'select' || method === 'rpc') && cacheKey && cacheConfig) {
791
+ // Check cache (stale-while-revalidate). A run that must reach the network
792
+ // skips it: a refresh (an invalidation refetching a mounted query — the
793
+ // element shows these rows already) and a forced run (`el.__ref.refetch()`,
794
+ // refetchInterval, reconnect, fetchNextPage). `force` used to skip only the
795
+ // dedup, so while the entry was fresh every one of those answered from the
796
+ // cache and sent nothing — a poll shorter than the stale time never polled.
797
+ if ((method === 'select' || method === 'rpc') && cacheKey && cacheConfig && !opts.refresh && !opts.force) {
664
798
  const cached = getCacheEntry(cacheKey)
665
799
  if (cached && !cached.error) {
666
800
  const age = Date.now() - cached.time
667
801
  const isStale = cached.stale || age > (cacheConfig.staleTime || 0)
802
+ // An infinite query served from the cache starts its page list there,
803
+ // with its next / previous cursor, so fetchNextPage can go on from it.
804
+ if (infinite && isArray(cached.data) && !ref.__pages) {
805
+ ref.__pages = [cached.data]
806
+ setPageCursors(ref, config, cached.data)
807
+ }
668
808
  let data = cached.data
669
809
  if (isFunction(selectTransform)) data = selectTransform(data, element, element.state)
670
810
  if (transform) data = transform(data, element, element.state)
671
811
  if (!isCurrent()) return
672
- updateElementState(element, data, stateKey)
812
+ // The status first: the state update re-renders, and a render that
813
+ // reads `__fetchStatus` must see the answer it renders (report #25).
673
814
  if (!isStale) {
674
815
  setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
816
+ updateElementState(element, data, stateKey)
675
817
  return
676
818
  }
819
+ updateElementState(element, data, stateKey)
677
820
  // Stale — continue to background refetch
678
821
  } else if (placeholderData !== undefined) {
679
822
  const phData = isFunction(placeholderData) ? placeholderData(element, element.state) : placeholderData
@@ -683,14 +826,29 @@ const runFetch = async (config, element, context, opts = {}) => {
683
826
  }
684
827
  }
685
828
 
686
- // Deduplication — if same query is already in-flight, share the promise (with timeout)
687
- if (cacheKey && activeQueries.has(cacheKey) && !opts.force) {
688
- const existing = activeQueries.get(cacheKey)
829
+ // Deduplication — if same query is already in-flight, share the promise (with
830
+ // timeout). Never one that started before the key's last invalidation: its
831
+ // reply may predate the write.
832
+ const existing = cacheKey && !opts.force ? activeQueries.get(cacheKey) : null
833
+ if (existing && !isOutdated(cacheKey, requestSeqs.get(existing) || 0)) {
834
+ // The shared request failed: so did this query, with the same error. It
835
+ // used to read an error answer as null rows (onFetchComplete(null)) and a
836
+ // thrown one as nothing at all.
837
+ const fail = (error) => {
838
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error, status: 'error', fetchStatus: 'idle' })
839
+ triggerCallback(element, 'onFetchError', error)
840
+ triggerConfigCallback(config, element, 'onFetchError', error)
841
+ }
689
842
  try {
690
843
  const result = await Promise.race([existing, new Promise((_, r) => setTimeout(() => r('DEDUP_TIMEOUT'), 15000))])
691
844
  if (!isCurrent()) return
692
- if (result?.data !== undefined) {
693
- let finalData = result.data
845
+ if (result?.error) {
846
+ fail(result.error)
847
+ } else if (result?.data !== undefined) {
848
+ // A request that started later (an invalidation's refetch) may have
849
+ // landed first: its rows are the newer answer.
850
+ const newer = !infinite && newerEntry(cacheKey, requestSeqs.get(existing) || 0)
851
+ let finalData = newer ? newer.data : result.data
694
852
  if (isFunction(selectTransform)) finalData = selectTransform(finalData, element, element.state)
695
853
  if (transform) finalData = transform(finalData, element, element.state)
696
854
  if (!isCurrent()) return
@@ -700,7 +858,9 @@ const runFetch = async (config, element, context, opts = {}) => {
700
858
  }
701
859
  } catch (e) {
702
860
  if (!isCurrent()) return
703
- // Error handled by the original caller
861
+ // A dedup timeout is no error of the request: this query only stopped
862
+ // waiting for it.
863
+ if (e !== 'DEDUP_TIMEOUT') fail(e)
704
864
  }
705
865
  return
706
866
  }
@@ -750,21 +910,21 @@ const runFetch = async (config, element, context, opts = {}) => {
750
910
  request.headers = { ...request.headers, 'Accept-Language': lang }
751
911
  }
752
912
 
753
- // Pagination support
754
- if (page !== undefined) {
755
- if (isObject(page)) {
756
- if (page.offset !== undefined) request.offset = page.offset
757
- if (page.limit !== undefined) request.limit = page.limit
758
- if (page.cursor !== undefined) request.params = { ...request.params, cursor: page.cursor }
759
- } else if (typeof page === 'number') {
913
+ // Pagination support (the values resolved with the element, as the key is)
914
+ if (pageValue !== undefined) {
915
+ if (isObject(pageValue)) {
916
+ if (pageValue.offset !== undefined) request.offset = pageValue.offset
917
+ if (pageValue.limit !== undefined) request.limit = pageValue.limit
918
+ if (pageValue.cursor !== undefined) request.params = { ...request.params, cursor: pageValue.cursor }
919
+ } else if (typeof pageValue === 'number') {
760
920
  const pageSize = config.pageSize || limit || 20
761
- request.offset = (page - 1) * pageSize
921
+ request.offset = (pageValue - 1) * pageSize
762
922
  request.limit = pageSize
763
923
  }
764
924
  }
765
925
 
766
- if (cursor !== undefined) {
767
- request.params = { ...request.params, cursor }
926
+ if (cursorValue !== undefined) {
927
+ request.params = { ...request.params, cursor: cursorValue }
768
928
  }
769
929
 
770
930
  if (method === 'insert' || method === 'update' || method === 'upsert') {
@@ -794,9 +954,20 @@ const runFetch = async (config, element, context, opts = {}) => {
794
954
  : formData
795
955
  }
796
956
  request.params = rpcParams
957
+ // A cached rpc query is a read; an action may write. The REST adapter
958
+ // sends a `read` rpc as a GET when its `reads.rpc` says the backend
959
+ // serves those (no CORS preflight); `read: false` keeps one a POST.
960
+ request.read = !action && config.read !== false
797
961
  }
798
962
 
799
963
  if (method === 'subscribe') {
964
+ // One subscription per element: a re-run (`el.__ref.refetch()`) closes
965
+ // the one it replaces instead of opening a second beside it.
966
+ if (isFunction(ref.__unsubscribe)) {
967
+ const close = ref.__unsubscribe
968
+ ref.__unsubscribe = null
969
+ try { close() } catch (e) { /* the old stream is gone either way */ }
970
+ }
800
971
  const unsubscribe = adapter.subscribe(
801
972
  { from, params, on: config.subscribeOn },
802
973
  (newData, oldData, payload) => {
@@ -818,20 +989,28 @@ const runFetch = async (config, element, context, opts = {}) => {
818
989
  }
819
990
 
820
991
  // Execute with retry
992
+ const requestSeq = ++fetchSeq
821
993
  const fetchPromise = withRetry(doFetch, retryConfig)
994
+ requestSeqs.set(fetchPromise, requestSeq)
822
995
 
823
996
  // Store for deduplication
824
997
  if (cacheKey) activeQueries.set(cacheKey, fetchPromise)
825
998
 
826
999
  try {
827
1000
  const result = await fetchPromise
1001
+ // An action's write happened whatever became of the element that fired
1002
+ // it (a dialog that closed on submit): its `invalidates` runs on success.
1003
+ if (action && config.invalidates && result && !result.error && !result.subscribed) {
1004
+ runInvalidates(config.invalidates, from)
1005
+ }
828
1006
  if (!isCurrent()) return
829
1007
  if (result?.subscribed) return
830
1008
 
831
1009
  const { data, error } = result || {}
832
1010
 
833
1011
  if (error) {
834
- if (cacheKey) setCacheEntry(cacheKey, null, error)
1012
+ // An older request's failure never replaces a newer answer.
1013
+ if (cacheKey && !newerEntry(cacheKey, requestSeq)) setCacheEntry(cacheKey, null, error, requestSeq)
835
1014
  setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error, status: 'error', fetchStatus: 'idle' })
836
1015
  triggerCallback(element, 'onFetchError', error)
837
1016
  triggerConfigCallback(config, element, 'onFetchError', error)
@@ -844,45 +1023,54 @@ const runFetch = async (config, element, context, opts = {}) => {
844
1023
  }
845
1024
 
846
1025
  if (data !== undefined) {
1026
+ let rows = data
847
1027
  // Update cache
848
1028
  if ((method === 'select' || method === 'rpc') && cacheKey) {
849
- const entry = setCacheEntry(cacheKey, data, null)
850
- entry.gcTime = cacheConfig?.gcTime || 300000
1029
+ // A request that started later has written its newer rows already:
1030
+ // show those, leave the cache alone. (Not for an infinite query —
1031
+ // each of its replies is one page, not the whole answer.)
1032
+ const newer = !infinite && newerEntry(cacheKey, requestSeq)
1033
+ if (newer) {
1034
+ rows = newer.data
1035
+ } else {
1036
+ // Invalidated while this request was out: kept, never served as fresh.
1037
+ const entry = setCacheEntry(cacheKey, data, null, requestSeq, isOutdated(cacheKey, requestSeq))
1038
+ entry.gcTime = cacheConfig?.gcTime || 300000
1039
+ }
851
1040
  }
852
1041
 
853
- let finalData = data
1042
+ let finalData = rows
854
1043
 
855
1044
  // Infinite query — append pages
856
1045
  if (infinite && isArray(data)) {
857
- if (!ref.__pages) ref.__pages = []
1046
+ // Any run that is not a next / previous step — a refetch, an
1047
+ // invalidation's refresh, a stale cache's revalidation — asks for the
1048
+ // first page and starts the list again. Appending it to the loaded
1049
+ // pages listed it twice.
1050
+ if (!ref.__pages || !opts.direction) ref.__pages = []
858
1051
  if (opts.direction === 'previous') {
859
1052
  ref.__pages.unshift(data)
860
1053
  } else {
861
1054
  ref.__pages.push(data)
862
1055
  }
863
1056
  finalData = ref.__pages.flat()
864
-
865
- // Resolve pagination cursors
866
- if (getNextPageParam) {
867
- ref.__nextPageParam = getNextPageParam(data, ref.__pages)
868
- ref.__hasNextPage = ref.__nextPageParam != null
869
- }
870
- if (getPreviousPageParam) {
871
- ref.__prevPageParam = getPreviousPageParam(data, ref.__pages)
872
- ref.__hasPreviousPage = ref.__prevPageParam != null
873
- }
1057
+ setPageCursors(ref, config, data)
874
1058
  }
875
1059
 
876
1060
  if (isFunction(selectTransform)) finalData = selectTransform(finalData, element, element.state)
877
1061
  if (transform) finalData = transform(finalData, element, element.state)
878
1062
  if (!isCurrent()) return
879
1063
 
1064
+ // The status first: the state update re-renders, and a render that
1065
+ // reads `__fetchStatus` saw the request still pending beside its own
1066
+ // rows (report #25).
1067
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
1068
+
880
1069
  // keepPreviousData — don't clear state while fetching new page
881
1070
  if (!keepPreviousData || finalData !== undefined) {
882
1071
  updateElementState(element, finalData, stateKey)
883
1072
  }
884
1073
 
885
- setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
886
1074
  triggerCallback(element, 'onFetchComplete', finalData)
887
1075
  triggerConfigCallback(config, element, 'onFetchComplete', finalData)
888
1076
  if (_emitNet) _emitNet({
@@ -911,6 +1099,8 @@ const runFetch = async (config, element, context, opts = {}) => {
911
1099
  const bindEvent = (config, element, context) => {
912
1100
  const on = config.on
913
1101
  const ref = element.__ref
1102
+ // Bound a microtask after create: the element may have been removed since.
1103
+ if (!ref) return
914
1104
  if (!ref.__fetchListeners) ref.__fetchListeners = []
915
1105
  const runner = config.__runner || runFetch
916
1106
 
@@ -932,10 +1122,49 @@ const bindEvent = (config, element, context) => {
932
1122
  ref.__fetchListeners.push(() => node.removeEventListener('click', handler))
933
1123
  }
934
1124
  } else if (on === 'stateChange') {
935
- ref.__fetchOnStateChange = () => runner(config, element, context)
1125
+ bindStateChange(config, element, context, runner)
936
1126
  }
937
1127
  }
938
1128
 
1129
+ // `on: 'stateChange'` — the query runs when the state it reads changes
1130
+ // (search as you type). It never fired: the pre-v4 runtime called
1131
+ // `ref.__fetchOnStateChange()` from the element's update(), the signal rewrite
1132
+ // dropped that call, and a signal-only state change fires no update hook.
1133
+ // Its inputs — params, `enabled`, `skip`, `page`, resolved as a request would
1134
+ // — are read inside a signal effect, which subscribes to exactly the state
1135
+ // they read (helmet's metadata effect works the same way); a change of their
1136
+ // values runs the query. Never on create (that is `on: 'create'`) and never
1137
+ // for its own rows: they land in state while its inputs stay as they were.
1138
+ // The effect is one of the element's `__ref.__effects`, so dispose() stops it.
1139
+ const bindStateChange = (config, element, context, runner) => {
1140
+ const ref = element.__ref
1141
+ let last
1142
+ const effect = createEffect(() => {
1143
+ let inputs
1144
+ try {
1145
+ inputs = JSON.stringify([
1146
+ resolveParams(config.params, element, context) ?? null,
1147
+ isFunction(config.enabled) ? !!config.enabled(element, element.state) : config.enabled !== false,
1148
+ isFunction(config.skip) ? !!config.skip(element, element.state) : config.skip === true,
1149
+ resolveValue(config.page, element) ?? null
1150
+ ])
1151
+ } catch (e) {
1152
+ return // an input that throws names no request
1153
+ }
1154
+ if (last === undefined) {
1155
+ last = inputs
1156
+ return
1157
+ }
1158
+ if (inputs === last) return
1159
+ last = inputs
1160
+ // Outside the effect: the run reads state as well, and none of that may
1161
+ // become the effect's dependency.
1162
+ Promise.resolve().then(() => runner(config, element, context))
1163
+ })
1164
+ if (Array.isArray(ref.__effects)) ref.__effects.push(effect)
1165
+ else ref.__fetchListeners.push(() => effect.dispose())
1166
+ }
1167
+
939
1168
  // --- Refetch interval / focus / reconnect binding ---
940
1169
 
941
1170
  const bindAutoRefetch = (config, element, context) => {
@@ -969,9 +1198,7 @@ const bindAutoRefetch = (config, element, context) => {
969
1198
  // refetchOnWindowFocus
970
1199
  if (onFocusEnabled) {
971
1200
  const onFocus = () => {
972
- const lang = resolveLanguage(element, context)
973
- const scope = resolveScope(element, context)
974
- const cacheKey = buildCacheKey(config, lang, scope)
1201
+ const cacheKey = elementQueryKey(config, element, context)
975
1202
  const entry = getCacheEntry(cacheKey)
976
1203
  const cacheConfig = parseCacheConfig(config.cache)
977
1204
  if (!entry || entry.stale || (Date.now() - entry.time) > (cacheConfig?.staleTime || 0)) {
@@ -1012,8 +1239,13 @@ const applyOptimisticUpdate = (element, config, mutationData) => {
1012
1239
  return snapshot
1013
1240
  }
1014
1241
 
1242
+ // Rolls back only what `optimistic` applied, and never on a removed element
1243
+ // (a dialog that closed while its write was out): the write failing used to
1244
+ // call this for every mutation, and read `__ref` of a removed one — a
1245
+ // TypeError in the failure path.
1015
1246
  const rollbackOptimistic = (element, config) => {
1016
1247
  const ref = element.__ref
1248
+ if (!ref || !config.optimistic) return
1017
1249
  const snapshot = ref.__optimisticSnapshot
1018
1250
  if (snapshot !== undefined) {
1019
1251
  updateElementState(element, snapshot, config.as)
@@ -1021,6 +1253,91 @@ const rollbackOptimistic = (element, config) => {
1021
1253
  }
1022
1254
  }
1023
1255
 
1256
+ // --- Invalidation ---
1257
+
1258
+ // Which keys a match selects — one rule for queryClient, the project
1259
+ // functions and a write's `invalidates`: nothing (every key), a key prefix or
1260
+ // substring, a list of key parts (joined with ':'), a RegExp, or a predicate
1261
+ // `(key) => boolean`.
1262
+ const keyMatcher = (match) => {
1263
+ if (!match) return () => true
1264
+ if (isString(match)) return (key) => key.includes(match)
1265
+ if (isArray(match)) {
1266
+ const parts = match.join(':')
1267
+ return (key) => key.includes(parts)
1268
+ }
1269
+ if (match instanceof RegExp) {
1270
+ return (key) => {
1271
+ match.lastIndex = 0 // a /g pattern keeps its position between tests
1272
+ return match.test(key)
1273
+ }
1274
+ }
1275
+ if (isFunction(match)) {
1276
+ // A predicate that throws selects nothing: it must not turn the write
1277
+ // whose `invalidates` it is into a failed one.
1278
+ return (key) => {
1279
+ try { return !!match(key) } catch (e) { return false }
1280
+ }
1281
+ }
1282
+ return () => false
1283
+ }
1284
+
1285
+ // Refetch the mounted queries whose current key `matches` selects. The first
1286
+ // on a key sends the request; the others on that key share it through the
1287
+ // dedup, which never shares a request that started before the key's
1288
+ // invalidation — so no one is handed the pre-write rows.
1289
+ const refreshMounted = (matches) => {
1290
+ const runs = []
1291
+ for (const record of mountedQueries) {
1292
+ if (!isMounted(record)) {
1293
+ mountedQueries.delete(record)
1294
+ continue
1295
+ }
1296
+ const key = mountedKey(record)
1297
+ if (key && matches(key)) runs.push(runFetch(record.config, record.element, record.context, { refresh: true }))
1298
+ }
1299
+ return Promise.allSettled(runs).then(() => {})
1300
+ }
1301
+
1302
+ // Mark every key `matches` selects as invalidated — its cache entry stale, and
1303
+ // a first load still in flight outdated — then refetch the mounted queries on
1304
+ // those keys; resolves once those refetches settle. The answers the pre-boot
1305
+ // script asked for (early.js) are dropped as well: an invalidation means a
1306
+ // write happened, and they may predate it.
1307
+ const invalidateMatching = (matches) => {
1308
+ dropEarly()
1309
+ for (const [key, entry] of cacheStore) {
1310
+ if (!matches(key)) continue
1311
+ entry.stale = true
1312
+ invalidatedAt.set(key, ++fetchSeq)
1313
+ }
1314
+ for (const [key] of activeQueries) {
1315
+ if (!cacheStore.has(key) && matches(key)) invalidatedAt.set(key, ++fetchSeq)
1316
+ }
1317
+ return refreshMounted(matches)
1318
+ }
1319
+
1320
+ // A write's `invalidates`: `true` or '*' selects every query of the write's
1321
+ // own `from`; anything else — or each item of a list — is a match as
1322
+ // invalidateQueries takes it. (Whole keys only, before: the documented
1323
+ // `invalidates: ['posts']` matched no real key.)
1324
+ const runInvalidates = (invalidates, from) => {
1325
+ const matchers = (isArray(invalidates) ? invalidates : [invalidates]).map((match) => {
1326
+ if (match === true || match === '*') return (key) => key.startsWith(from + ':')
1327
+ return match ? keyMatcher(match) : () => false
1328
+ })
1329
+ return invalidateMatching((key) => matchers.some((matches) => matches(key)))
1330
+ }
1331
+
1332
+ const removeMatching = (matches) => {
1333
+ for (const [key] of cacheStore) {
1334
+ if (matches(key)) removeCache(key)
1335
+ }
1336
+ for (const [key] of activeQueries) {
1337
+ if (matches(key)) invalidatedAt.set(key, ++fetchSeq)
1338
+ }
1339
+ }
1340
+
1024
1341
  // --- Mutation runner (insert/update/delete with optimistic + invalidation) ---
1025
1342
 
1026
1343
  const runMutation = async (config, element, context) => {
@@ -1089,7 +1406,7 @@ const runMutation = async (config, element, context) => {
1089
1406
  request.headers = { ...request.headers, 'Accept-Language': lang }
1090
1407
  }
1091
1408
 
1092
- const retryConfig = resolveRetryConfig(config)
1409
+ const retryConfig = resolveRetryConfig(config, true)
1093
1410
  const result = await withRetry(() => fn(request), retryConfig)
1094
1411
  const { data, error } = result || {}
1095
1412
 
@@ -1109,13 +1426,15 @@ const runMutation = async (config, element, context) => {
1109
1426
 
1110
1427
  delete ref.__optimisticSnapshot
1111
1428
 
1429
+ // The status first, as for a query: the state update re-renders.
1430
+ setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
1431
+
1112
1432
  // A body value (Blob, stream, …) lands in state only under `as`.
1113
1433
  if (data !== undefined && (stateKey || !isBodyValue(data))) {
1114
1434
  const finalData = stateKey ? { [stateKey]: data } : data
1115
1435
  if (element.state?.update) element.state.update(finalData, { preventFetch: true })
1116
1436
  }
1117
1437
 
1118
- setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
1119
1438
  triggerCallback(element, 'onFetchComplete', data)
1120
1439
  triggerConfigCallback(config, element, 'onFetchComplete', data)
1121
1440
  if (isFunction(onSuccess)) onSuccess(data, mutationData, element)
@@ -1125,20 +1444,8 @@ const runMutation = async (config, element, context) => {
1125
1444
  durationMs: (typeof performance !== 'undefined' ? performance.now() : Date.now()) - __analyzeStart
1126
1445
  })
1127
1446
 
1128
- // Invalidate related queries
1129
- if (invalidates) {
1130
- const keys = isArray(invalidates) ? invalidates : [invalidates]
1131
- for (const k of keys) {
1132
- if (k === true || k === '*') {
1133
- // Invalidate all queries matching this `from`
1134
- for (const [ck] of cacheStore) {
1135
- if (ck.startsWith(from + ':')) invalidateCache(ck)
1136
- }
1137
- } else {
1138
- invalidateCache(k)
1139
- }
1140
- }
1141
- }
1447
+ // Invalidate related queries — the mounted ones refetch now
1448
+ if (invalidates) runInvalidates(invalidates, from)
1142
1449
  } catch (e) {
1143
1450
  if (snapshot !== undefined) rollbackOptimistic(element, config)
1144
1451
  setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error: e, status: 'error', fetchStatus: 'idle' })
@@ -1158,6 +1465,24 @@ const runMutation = async (config, element, context) => {
1158
1465
 
1159
1466
  // --- Public API ---
1160
1467
 
1468
+ // What the plugin bound for an element: the auto-refetch interval and the
1469
+ // window focus / online listeners (bindAutoRefetch), the submit / click
1470
+ // listeners (bindEvent), the realtime subscription and the mounted-query
1471
+ // records. dispose() calls this unguarded, so nothing here may throw.
1472
+ const releaseFetch = (ref) => {
1473
+ const release = (cleanup) => {
1474
+ try { cleanup() } catch (e) { /* one cleanup never stops the rest */ }
1475
+ }
1476
+ for (const cleanup of ref.__fetchCleanups || []) release(cleanup)
1477
+ for (const cleanup of ref.__fetchListeners || []) release(cleanup)
1478
+ if (isFunction(ref.__unsubscribe)) release(ref.__unsubscribe)
1479
+ for (const record of ref.__fetchMounted || []) mountedQueries.delete(record)
1480
+ ref.__fetchCleanups = []
1481
+ ref.__fetchListeners = []
1482
+ ref.__unsubscribe = null
1483
+ ref.__fetchMounted = []
1484
+ }
1485
+
1161
1486
  export const executeFetch = (param, element, state, context) => {
1162
1487
  if (!param) return
1163
1488
  const db = context?.fetch
@@ -1170,9 +1495,32 @@ export const executeFetch = (param, element, state, context) => {
1170
1495
  ? fetchProp.map(c => resolveFetchConfig(exec(c, element), element)).filter(Boolean)
1171
1496
  : [resolveFetchConfig(fetchProp, element)].filter(Boolean)
1172
1497
 
1498
+ // This element's mounted queries (refetched by an invalidation). A re-run
1499
+ // replaces its configs, so it replaces their records too.
1500
+ const ref = element.__ref
1501
+ if (ref.__fetchMounted) {
1502
+ for (const record of ref.__fetchMounted) mountedQueries.delete(record)
1503
+ }
1504
+ ref.__fetchMounted = []
1505
+
1506
+ // Released with the element. dispose() — the one way an element leaves —
1507
+ // calls every `__ref.__effects` entry (helmet's head layers use the same
1508
+ // path; the element package sends plugins no remove hook). The interval,
1509
+ // the window focus / online listeners and a realtime subscription used to
1510
+ // outlive their element. An entry without `_execute` is skipped by the
1511
+ // update levers that re-run effects.
1512
+ if (!ref.__fetchDisposer && isArray(ref.__effects)) {
1513
+ ref.__fetchDisposer = { dispose: () => releaseFetch(ref) }
1514
+ ref.__effects.push(ref.__fetchDisposer)
1515
+ }
1516
+
1173
1517
  for (const config of configs) {
1174
- const isMutation = config.method === 'insert' || config.method === 'update' || config.method === 'upsert' || config.method === 'delete'
1518
+ const isMutation = isMutationMethod(config.method)
1175
1519
  const runner = isMutation ? runMutation : runFetch
1520
+ // An action never re-sends itself: no refetch on focus, reconnect or an
1521
+ // interval, and an invalidation does not fire it again. Nor does a live
1522
+ // subscription — each re-run opened a second one beside the first.
1523
+ const isQuery = !isMutation && !isAction(config) && config.method !== 'subscribe'
1176
1524
 
1177
1525
  if (config.on === 'create') {
1178
1526
  runner(config, element, context)
@@ -1183,15 +1531,20 @@ export const executeFetch = (param, element, state, context) => {
1183
1531
  }
1184
1532
 
1185
1533
  // Bind auto-refetch for queries
1186
- if (!isMutation && config.on === 'create') {
1534
+ if (isQuery && config.on === 'create') {
1187
1535
  if (config.refetchInterval || config.refetchOnWindowFocus !== false || config.refetchOnReconnect !== false) {
1188
1536
  bindAutoRefetch(config, element, context)
1189
1537
  }
1538
+ // A single-use body has no cache key to invalidate.
1539
+ if (!isSingleUse(config)) {
1540
+ const record = { element, ref, config, context }
1541
+ mountedQueries.add(record)
1542
+ ref.__fetchMounted.push(record)
1543
+ }
1190
1544
  }
1191
1545
  }
1192
1546
 
1193
1547
  // Expose imperative methods on element
1194
- const ref = element.__ref
1195
1548
  ref.refetch = (opts) => {
1196
1549
  for (const config of configs) {
1197
1550
  runFetch(config, element, context, { force: true, ...opts })
@@ -1220,37 +1573,20 @@ export const executeFetch = (param, element, state, context) => {
1220
1573
  // --- Query client (global cache management) ---
1221
1574
 
1222
1575
  export const queryClient = {
1223
- invalidateQueries: (keyOrPattern) => {
1224
- if (!keyOrPattern) {
1225
- invalidateCache()
1226
- return
1227
- }
1228
- if (isString(keyOrPattern)) {
1229
- for (const [key] of cacheStore) {
1230
- if (key.startsWith(keyOrPattern) || key.includes(keyOrPattern)) {
1231
- invalidateCache(key)
1232
- }
1233
- }
1234
- } else if (isArray(keyOrPattern)) {
1235
- const pattern = keyOrPattern.join(':')
1236
- for (const [key] of cacheStore) {
1237
- if (key.includes(pattern)) invalidateCache(key)
1238
- }
1239
- }
1240
- },
1241
-
1576
+ // Marks every matching entry stale and refetches the mounted queries on
1577
+ // those keys; resolves once those refetches settle. `keyOrPattern`: nothing
1578
+ // (every key), a key prefix or substring, a list of key parts (joined with
1579
+ // ':'), a RegExp, or a predicate `(key) => boolean`.
1580
+ invalidateQueries: (keyOrPattern) => invalidateMatching(keyMatcher(keyOrPattern)),
1581
+
1582
+ // Drops every matching entry (same matches). A mounted query keeps the rows
1583
+ // it shows; the next mount loads them afresh.
1242
1584
  removeQueries: (keyOrPattern) => {
1243
1585
  if (!keyOrPattern) {
1244
1586
  removeCache()
1245
1587
  return
1246
1588
  }
1247
- if (isString(keyOrPattern)) {
1248
- for (const [key] of cacheStore) {
1249
- if (key.startsWith(keyOrPattern) || key.includes(keyOrPattern)) {
1250
- removeCache(key)
1251
- }
1252
- }
1253
- }
1589
+ removeMatching(keyMatcher(keyOrPattern))
1254
1590
  },
1255
1591
 
1256
1592
  getQueryData: (key) => {
@@ -1271,8 +1607,15 @@ export const queryClient = {
1271
1607
  const adapter = await resolveAdapter(db, context)
1272
1608
  if (!adapter) return
1273
1609
 
1274
- const cacheKey = config.cache?.key || `${config.from}:${config.method || 'select'}:${JSON.stringify(config.params || '')}`
1275
- const fn = adapter[config.method || 'select']
1610
+ // The key — and the request — a declarative query of this config builds
1611
+ // in this context: its tenant scope and language (queryCacheKey, as
1612
+ // runFetch does). The key used to carry neither, so an element with a
1613
+ // language never read what was prefetched for it.
1614
+ const method = config.method || 'select'
1615
+ const scope = resolveScope(null, context)
1616
+ const lang = resolveLanguage(null, context)
1617
+ const cacheKey = queryCacheKey({ ...config, method }, config.params, scope, lang)
1618
+ const fn = adapter[method]
1276
1619
  if (!isFunction(fn)) return
1277
1620
 
1278
1621
  const request = {
@@ -1285,11 +1628,17 @@ export const queryClient = {
1285
1628
  headers: config.headers,
1286
1629
  baseUrl: config.baseUrl
1287
1630
  }
1631
+ if (lang) request.headers = { ...request.headers, 'Accept-Language': lang }
1288
1632
  if (config.responseType !== undefined) request.responseType = config.responseType
1633
+ // A prefetched rpc is the query a mount reads: a read, as in runFetch.
1634
+ if (method === 'rpc') request.read = !isAction(config) && config.read !== false
1635
+ const requestSeq = ++fetchSeq
1289
1636
  const result = await fn(request)
1290
1637
 
1291
- if (result?.data !== undefined && !result?.error && !isSingleUse(config)) {
1292
- const entry = setCacheEntry(cacheKey, result.data, null)
1638
+ // Ordered like a query's reply: never over newer rows, stale when the key
1639
+ // was invalidated while the prefetch was out.
1640
+ if (result?.data !== undefined && !result?.error && !isSingleUse(config) && !newerEntry(cacheKey, requestSeq)) {
1641
+ const entry = setCacheEntry(cacheKey, result.data, null, requestSeq, isOutdated(cacheKey, requestSeq))
1293
1642
  entry.gcTime = parseCacheConfig(config.cache)?.gcTime || 300000
1294
1643
  }
1295
1644
 
@@ -1324,6 +1673,28 @@ export const queryClient = {
1324
1673
  }
1325
1674
  }
1326
1675
 
1676
+ // --- Project functions ---
1677
+ //
1678
+ // The query cache's controls for project code. smbls registers these into
1679
+ // `context.functions` whenever `context.fetch` is set (createDomql), so a
1680
+ // component reaches them as `el.call('invalidateQueries', 'articles')`:
1681
+ // project code cannot import `queryClient` — frank serializes an import of
1682
+ // @symbo.ls/fetch to a stub. Same names and matches as queryClient. `this` is
1683
+ // the calling element: given a fetch config instead of a key, getQueryData /
1684
+ // setQueryData use the key that element would build for it (its params
1685
+ // resolved, its language, its tenant scope).
1686
+ const keyFor = (keyOrConfig, element) => {
1687
+ if (!isObject(keyOrConfig)) return keyOrConfig
1688
+ return elementQueryKey({ method: 'select', ...keyOrConfig }, element, element?.context)
1689
+ }
1690
+
1691
+ export const fetchFunctions = {
1692
+ invalidateQueries (keyOrPattern) { return queryClient.invalidateQueries(keyOrPattern) },
1693
+ removeQueries (keyOrPattern) { return queryClient.removeQueries(keyOrPattern) },
1694
+ getQueryData (keyOrConfig) { return queryClient.getQueryData(keyFor(keyOrConfig, this)) },
1695
+ setQueryData (keyOrConfig, updater) { return queryClient.setQueryData(keyFor(keyOrConfig, this), updater) }
1696
+ }
1697
+
1327
1698
  // --- Plugin interface ---
1328
1699
 
1329
1700
  /**