@sanity/sdk 2.18.0 → 3.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +233 -12
  2. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
  3. package/dist/_chunks-es/_internal.js +1 -0
  4. package/dist/_chunks-es/_internal.js.map +1 -1
  5. package/dist/_chunks-es/createGroqSearchFilter.js +284 -6
  6. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
  7. package/dist/_chunks-es/telemetryManager.js +2 -2
  8. package/dist/_exports/_internal.d.ts +2 -2
  9. package/dist/_exports/_internal.d.ts.map +1 -1
  10. package/dist/_exports/_internal.js +3 -1
  11. package/dist/index.d.ts +599 -185
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +492 -337
  14. package/dist/index.js.map +1 -1
  15. package/package.json +7 -7
  16. package/src/_exports/_internal.ts +11 -0
  17. package/src/_exports/index.ts +57 -12
  18. package/src/access/checkPermissions.test.ts +55 -0
  19. package/src/access/checkPermissions.ts +54 -0
  20. package/src/applications/applications.test-d.ts +83 -0
  21. package/src/applications/applications.test.ts +130 -0
  22. package/src/applications/applications.ts +364 -0
  23. package/src/applications/userApplications.test.ts +126 -0
  24. package/src/applications/userApplications.ts +261 -0
  25. package/src/auth/getOrganizationVerificationState.test.ts +7 -7
  26. package/src/auth/getOrganizationVerificationState.ts +4 -4
  27. package/src/document/documentStore.concurrency.test.ts +313 -22
  28. package/src/installations/installations.test-d.ts +68 -0
  29. package/src/installations/installations.test.ts +62 -0
  30. package/src/installations/installations.ts +202 -0
  31. package/src/organization/organization.test-d.ts +18 -84
  32. package/src/organization/organization.test.ts +11 -11
  33. package/src/organization/organization.ts +26 -54
  34. package/src/organizations/organizations.test-d.ts +11 -32
  35. package/src/organizations/organizations.test.ts +8 -8
  36. package/src/organizations/organizations.ts +29 -54
  37. package/src/project/project.test-d.ts +19 -75
  38. package/src/project/project.test.ts +57 -10
  39. package/src/project/project.ts +33 -52
  40. package/src/projects/projects.test-d.ts +15 -21
  41. package/src/projects/projects.test.ts +8 -8
  42. package/src/projects/projects.ts +25 -49
  43. package/src/store/fetcherStore.test-d.ts +121 -0
  44. package/src/store/fetcherStore.test.ts +299 -0
  45. package/src/store/fetcherStore.ts +801 -0
  46. package/src/utils/buildQuery.test.ts +14 -0
  47. package/src/utils/buildQuery.ts +17 -0
  48. package/src/utils/includeShape.ts +36 -0
  49. package/src/utils/setCleanupTimeout.ts +21 -8
  50. package/dist/_chunks-es/version.js +0 -7
  51. package/dist/_chunks-es/version.js.map +0 -1
  52. package/src/utils/defineIntent.test.ts +0 -477
  53. package/src/utils/defineIntent.ts +0 -244
@@ -0,0 +1,801 @@
1
+ import {filter, firstValueFrom, map, type Observable, Subscription} from 'rxjs'
2
+
3
+ import {setCleanupInterval, setCleanupTimeout} from '../utils/setCleanupTimeout'
4
+ import {bindActionGlobally} from './createActionBinder'
5
+ import {type SanityInstance} from './createSanityInstance'
6
+ import {
7
+ createStateSourceAction,
8
+ type SelectorContext,
9
+ type StateSource,
10
+ } from './createStateSourceAction'
11
+ import {type StoreState} from './createStoreState'
12
+ import {defineStore, type StoreContext} from './defineStore'
13
+
14
+ const DEFAULT_STALE_TIME = 30_000
15
+ const DEFAULT_GC_TIME = 300_000
16
+
17
+ /**
18
+ * Identifies cached data for invalidation, matched across the whole store.
19
+ * A fetcher declares the tags each of its entries carries (via
20
+ * {@link FetcherDefinition.tags}); a mutation declares the tags it invalidates
21
+ * (via {@link MutationDefinition.invalidates}).
22
+ *
23
+ * When invalidating,`{type: 'organization'}` matches every entry carrying any `organization` tag;
24
+ * `{type: 'organization', id: 'abc'}` matches only entries carrying that exact pair.
25
+ * List entries carry a `{type, id: 'LIST'}` sentinel plus one tag per item, so a
26
+ * "create" mutation can refetch lists without touching unrelated detail entries.
27
+ *
28
+ * @internal
29
+ */
30
+ export interface CacheTag {
31
+ type: string
32
+ /** Specific id, or the `'LIST'` sentinel for list entries */
33
+ id?: string | number
34
+ }
35
+
36
+ /**
37
+ * The reactive value of one cache entry, as exposed by
38
+ * {@link Fetcher.getState}. React hooks consume this envelope directly.
39
+ * Discriminated on `status`, so narrowing gives correctly-typed `data`:
40
+ * `'success'` is the only arm carrying data, and a background failure while
41
+ * data still renders surfaces on the `'success'` arm's `error`.
42
+ *
43
+ * `isFetching` is common to all arms: a fetch for this entry is in flight
44
+ * (initial load, revalidation, or poll).
45
+ *
46
+ * @internal
47
+ */
48
+ export type FetcherSnapshot<TData> =
49
+ | {
50
+ /** No data yet — initial fetch in flight or not started */
51
+ status: 'pending'
52
+ data: undefined
53
+ error: undefined
54
+ isFetching: boolean
55
+ dataUpdatedAt: undefined
56
+ }
57
+ | {
58
+ /** Data available — possibly stale, possibly revalidating */
59
+ status: 'success'
60
+ data: TData
61
+ /** Most recent background-fetch failure, cleared by the next successful fetch */
62
+ error: unknown
63
+ isFetching: boolean
64
+ /** Epoch ms of the fetch that produced `data` */
65
+ dataUpdatedAt: number
66
+ }
67
+ | {
68
+ /** The last fetch failed and no data exists to serve */
69
+ status: 'error'
70
+ data: undefined
71
+ error: unknown
72
+ isFetching: boolean
73
+ dataUpdatedAt: undefined
74
+ }
75
+
76
+ /**
77
+ * A typed description of one cached, parameterised read of an API response —
78
+ * here is a single cache per {@link SanityInstance}; every definition registers
79
+ * into it and its `name` namespaces its keys. A list and a detail read are two
80
+ * definitions over the one cache, not two caches.
81
+ *
82
+ * Entry lifecycle: fresh → stale → collected. Fresh entries are served from
83
+ * cache with no fetch; stale entries are served immediately and revalidated in
84
+ * the background (stale-while-revalidate); collected entries are removed once
85
+ * their `gcTime` elapses after the last unsubscribe, so the next subscriber
86
+ * suspends.
87
+ *
88
+ * @internal
89
+ */
90
+ export interface FetcherDefinition<TParams extends unknown[], TData> {
91
+ /** Namespaces this fetcher's keys in the shared cache; used for devtools and request-tag derivation */
92
+ name: string
93
+ /**
94
+ * Fetches the data once. Return an observable that emits the result and
95
+ * completes.
96
+ */
97
+ fetch: (instance: SanityInstance) => (...params: TParams) => Observable<TData>
98
+ /** Converts params into the cache key that identifies an entry (unique within `name`) */
99
+ getKey: (instance: SanityInstance, ...params: TParams) => string
100
+ /**
101
+ * Cache tags each entry carries, for cache-wide invalidation. A static array,
102
+ * or a function of the fetched data when tags depend on the response (e.g. one
103
+ * tag per list item).
104
+ */
105
+ tags?: CacheTag[] | ((data: TData, ...params: TParams) => CacheTag[])
106
+ /**
107
+ * How long fetched data is fresh, in ms. Fresh entries never refetch;
108
+ * stale entries serve cached data and revalidate in the background.
109
+ * @defaultValue 30_000
110
+ */
111
+ staleTime?: number
112
+ /**
113
+ * How long an entry survives after its last unsubscribe before it is garbage
114
+ * collected, in ms. The clock starts at the last unsubscribe — entries with
115
+ * active subscribers are never collected.
116
+ * @defaultValue 300_000
117
+ */
118
+ gcTime?: number
119
+ /**
120
+ * Refetch on this interval (ms) while the entry has at least one subscriber,
121
+ * independent of staleness. Ticks are skipped while `document.hidden`. Off by default.
122
+ */
123
+ refetchInterval?: number
124
+ /**
125
+ * Consulted once, when an entry is being created and no cached data exists for
126
+ * it. Return data pulled from another fetcher in the same cache — read via its
127
+ * ordinary `getState(instance, ...).getCurrent()`, e.g. a detail entry pulling
128
+ * its item out of the cached list — to skip or soften the initial fetch.
129
+ * `dataUpdatedAt` must be when the source data was fetched, not "now": staleness
130
+ * is measured from it. Return `undefined` to fetch as normal.
131
+ */
132
+ initialData?: (
133
+ instance: SanityInstance,
134
+ ...params: TParams
135
+ ) => {data: TData; dataUpdatedAt: number} | undefined
136
+ }
137
+
138
+ /**
139
+ * A typed handle over the shared fetcher cache, returned by
140
+ * {@link defineFetcher}. It is a view onto the entries under its definition's
141
+ * `name`, not a cache of its own.
142
+ *
143
+ * @internal
144
+ */
145
+ export interface Fetcher<TParams extends unknown[], TData> {
146
+ /** Reactive envelope for one entry. Subscribing triggers fetching per staleness rules. */
147
+ getState: (instance: SanityInstance, ...params: TParams) => StateSource<FetcherSnapshot<TData>>
148
+ /**
149
+ * Resolves with the entry's first available data — the promise a suspending
150
+ * component throws. Rejects if the initial fetch fails.
151
+ */
152
+ resolveState: (instance: SanityInstance, ...params: TParams) => Promise<TData>
153
+ /**
154
+ * Marks the entry stale. If it has active subscribers it refetches immediately;
155
+ * otherwise it refetches on next subscribe.
156
+ */
157
+ invalidate: (instance: SanityInstance, ...params: TParams) => void
158
+ /** {@link Fetcher.invalidate} for every entry under this fetcher's `name`. */
159
+ invalidateAll: (instance: SanityInstance) => void
160
+ /**
161
+ * Imperative fetch, bypassing staleness. Dedupes against an in-flight fetch.
162
+ * Resolves with the entry's data as held by the cache once the fetch settles.
163
+ */
164
+ refetch: (instance: SanityInstance, ...params: TParams) => Promise<TData>
165
+ /**
166
+ * Writes an entry's data directly. Returns an undo handle — inside a
167
+ * mutation's `onMutate` these writes are recorded and rolled back automatically
168
+ * on failure.
169
+ */
170
+ setData: (
171
+ instance: SanityInstance,
172
+ params: TParams,
173
+ updater: TData | ((current: TData | undefined) => TData),
174
+ ) => {undo: () => void}
175
+ }
176
+
177
+ /**
178
+ * Writes an entry's data in any fetcher. Passed to {@link MutationDefinition}
179
+ * callbacks — mutations are the only holders of arbitrary write access; read
180
+ * fetchers pull their data (see {@link FetcherDefinition.initialData}). Inside
181
+ * `onMutate`, every write is recorded and undone automatically if the mutation
182
+ * fails.
183
+ *
184
+ * @internal
185
+ */
186
+ export type CacheWriter = <TParams extends unknown[], TData>(
187
+ fetcher: Fetcher<TParams, TData>,
188
+ params: TParams,
189
+ updater: TData | ((current: TData | undefined) => TData),
190
+ ) => void
191
+
192
+ /**
193
+ * Resolves when the mutation settles on the server. `invalidated` tracks the
194
+ * background reconciliation — the refetches triggered by
195
+ * {@link MutationDefinition.invalidates} — for the rare caller that must wait for
196
+ * full reconvergence rather than server settlement.
197
+ *
198
+ * @internal
199
+ */
200
+ export interface MutationResult<TResult> {
201
+ /** The server response */
202
+ data: TResult
203
+ /** Settles when the refetches of matched active entries complete */
204
+ invalidated: Promise<void>
205
+ }
206
+
207
+ /**
208
+ * Defines a mutation with optimistic cache updates. Lifecycle:
209
+ *
210
+ * 1. `onMutate` applies optimistic writes synchronously — the UI updates before
211
+ * the request leaves. Each entry carries a version counter that increments on
212
+ * every write. An optimistic write bumps the counter, so a revalidation already
213
+ * in flight lands "out of date" and is discarded rather than clobbering the
214
+ * optimistic write.
215
+ * 2. The request runs. On failure, recorded writes are rolled back automatically
216
+ * — the cache is restored exactly, so failure ends the lifecycle here.
217
+ * 3. On success, `onSuccess` may write the server response into the cache directly.
218
+ * 4. On success, `invalidates` tags mark matching entries across the cache
219
+ * stale (active entries refetch), so the cache reconverges on server truth
220
+ * even if the optimistic shape was wrong.
221
+ *
222
+ * @internal
223
+ */
224
+ export interface MutationDefinition<TInput, TResult> {
225
+ /** Unique mutation name, used for devtools and request-tag derivation */
226
+ name: string
227
+ /** Creates the observable that performs the server mutation */
228
+ mutationFn: (instance: SanityInstance) => (input: TInput) => Observable<TResult>
229
+ /** Optimistic cache writes, applied synchronously before the request */
230
+ onMutate?: (write: CacheWriter, input: TInput) => void
231
+ /** Reconcile the cache with the server response, avoiding a refetch round-trip */
232
+ onSuccess?: (write: CacheWriter, result: TResult, input: TInput) => void
233
+ /**
234
+ * Cache tags to invalidate on success. Failure invalidates nothing: recorded
235
+ * optimistic writes roll back to the exact pre-mutation cache, and any
236
+ * server-side ambiguity (e.g. timeout after commit) heals via `staleTime`.
237
+ * A static array, or a function of the result/input.
238
+ */
239
+ invalidates?: CacheTag[] | ((result: TResult, input: TInput) => CacheTag[])
240
+ }
241
+
242
+ // --- Internal cache machinery -------------------------------------------------
243
+
244
+ /** Internal bookkeeping carried by every entry, on top of the public {@link FetcherSnapshot} fields. */
245
+ interface CacheEntryMeta {
246
+ fetcherName: string
247
+ /** The entry's cache key — unique across the whole cache */
248
+ key: string
249
+ params: unknown[]
250
+ instance: SanityInstance
251
+ /** Bumps on every write; a fetch whose result lands on a changed version is discarded */
252
+ version: number
253
+ /** Forced stale by invalidation, independent of `staleTime` */
254
+ forcedStale: boolean
255
+ tags: CacheTag[]
256
+ subscriptions: number
257
+ }
258
+
259
+ /**
260
+ * One entry in the shared cache: a {@link FetcherSnapshot} plus internal
261
+ * bookkeeping. Discriminated on `status` like the snapshot, so `data` and
262
+ * `dataUpdatedAt` are correctly typed per status. Generic over the fetcher's
263
+ * `TData`; the store holds `CacheEntry` (i.e. `CacheEntry<unknown>`) because it
264
+ * is shared across fetchers, and {@link asTyped} recovers a specific `TData` at
265
+ * the read boundary. Immutable: replaced, never mutated in place.
266
+ */
267
+ type CacheEntry<TData = unknown> = FetcherSnapshot<TData> & CacheEntryMeta
268
+
269
+ interface CacheState {
270
+ entries: {[compoundKey: string]: CacheEntry}
271
+ }
272
+
273
+ interface InflightFetch {
274
+ sub: Subscription
275
+ promise: Promise<unknown>
276
+ resolve: (data: unknown) => void
277
+ reject: (error: unknown) => void
278
+ startVersion: number
279
+ }
280
+
281
+ // Erased definition type used by the machinery, which is generic-agnostic.
282
+ type AnyDefinition = FetcherDefinition<unknown[], unknown>
283
+
284
+ // Module-level: the cache store is global (one instance across all Sanity
285
+ // instances, keyed only by fetcher key), so this bookkeeping lives alongside it.
286
+ const definitions = new Map<string, AnyDefinition>()
287
+ const inflight = new Map<string, InflightFetch>()
288
+ const gcTimers = new Map<string, ReturnType<typeof setTimeout>>()
289
+ const intervalTimers = new Map<string, ReturnType<typeof setInterval>>()
290
+ const snapshotCache = new WeakMap<CacheEntry, FetcherSnapshot<unknown>>()
291
+
292
+ const PENDING_SNAPSHOT: FetcherSnapshot<never> = {
293
+ status: 'pending',
294
+ data: undefined,
295
+ error: undefined,
296
+ isFetching: false,
297
+ dataUpdatedAt: undefined,
298
+ }
299
+
300
+ /** Builds the cache key that identifies one entry: the fetcher's name namespacing its raw key. */
301
+ const entryKey = (fetcherName: string, rawKey: string): string => `${fetcherName}:${rawKey}`
302
+
303
+ const computeTags = (def: AnyDefinition, data: unknown, params: unknown[]): CacheTag[] =>
304
+ typeof def.tags === 'function' ? def.tags(data, ...params) : (def.tags ?? [])
305
+
306
+ /**
307
+ * Distinguishes an updater function from a replacement value. Sound because
308
+ * cached data is API response data — never itself callable — so a callable
309
+ * `updater` can only be the function arm. This lets us narrow without an `as`.
310
+ */
311
+ const isUpdaterFn = <T>(
312
+ updater: T | ((current: T | undefined) => T),
313
+ ): updater is (current: T | undefined) => T => typeof updater === 'function'
314
+
315
+ /** Applies an updater — a replacement value, or a function of the current value. */
316
+ const applyUpdater = <T>(updater: T | ((current: T | undefined) => T), current: T | undefined): T =>
317
+ isUpdaterFn(updater) ? updater(current) : updater
318
+
319
+ /** Whether an entry should fetch on subscribe: not already fetching, and either empty or stale. */
320
+ const needsFetch = (entry: CacheEntry, def: AnyDefinition): boolean => {
321
+ if (entry.isFetching) return false
322
+ if (entry.status !== 'success') return true
323
+ return (
324
+ entry.forcedStale ||
325
+ Date.now() - (entry.dataUpdatedAt ?? 0) >= (def.staleTime ?? DEFAULT_STALE_TIME)
326
+ )
327
+ }
328
+
329
+ /**
330
+ * Views a stored entry as the calling fetcher's typed entry. The cache holds
331
+ * every fetcher's entries as `CacheEntry<unknown>`, but keys are namespaced by
332
+ * fetcher name, so an entry read under a fetcher's key holds that fetcher's
333
+ * `TData` — the one sound `unknown → TData` seam (cf. TanStack's `getQueryData`).
334
+ */
335
+ const asTyped = <TData>(entry: CacheEntry): CacheEntry<TData> => entry as CacheEntry<TData>
336
+
337
+ /** Snapshot for a given entry, memoised by entry identity so unrelated cache changes don't re-emit. */
338
+ const toSnapshot = <TData>(entry: CacheEntry<TData> | undefined): FetcherSnapshot<TData> => {
339
+ if (!entry) return PENDING_SNAPSHOT
340
+ const cached = snapshotCache.get(entry)
341
+ if (cached) return cached as FetcherSnapshot<TData>
342
+
343
+ let snapshot: FetcherSnapshot<TData>
344
+ if (entry.status === 'success') {
345
+ snapshot = {
346
+ status: 'success',
347
+ data: entry.data,
348
+ error: entry.error,
349
+ isFetching: entry.isFetching,
350
+ dataUpdatedAt: entry.dataUpdatedAt,
351
+ }
352
+ } else if (entry.status === 'error') {
353
+ snapshot = {
354
+ status: 'error',
355
+ data: undefined,
356
+ error: entry.error,
357
+ isFetching: entry.isFetching,
358
+ dataUpdatedAt: undefined,
359
+ }
360
+ } else {
361
+ snapshot = {
362
+ status: 'pending',
363
+ data: undefined,
364
+ error: undefined,
365
+ isFetching: entry.isFetching,
366
+ dataUpdatedAt: undefined,
367
+ }
368
+ }
369
+
370
+ snapshotCache.set(entry, snapshot)
371
+ return snapshot
372
+ }
373
+
374
+ const cacheStore = defineStore<CacheState>({
375
+ name: 'FetcherCache',
376
+ getInitialState: () => ({entries: {}}),
377
+ initialize: () => () => {
378
+ // The global store is disposed only when the last Sanity instance is: tear
379
+ // down every outstanding timer and fetch so nothing leaks between lifetimes.
380
+ for (const timer of gcTimers.values()) clearTimeout(timer)
381
+ for (const timer of intervalTimers.values()) clearInterval(timer)
382
+ for (const fetch of inflight.values()) fetch.sub.unsubscribe()
383
+ gcTimers.clear()
384
+ intervalTimers.clear()
385
+ inflight.clear()
386
+ },
387
+ })
388
+
389
+ /** Immutably replace (or delete, when the updater returns `undefined`) one entry. */
390
+ const updateEntry = (
391
+ state: StoreState<CacheState>,
392
+ key: string,
393
+ actionName: string,
394
+ updater: (prev: CacheEntry | undefined) => CacheEntry | undefined,
395
+ ): void => {
396
+ state.set(actionName, (prev: CacheState) => {
397
+ const next = updater(prev.entries[key])
398
+ if (next === prev.entries[key]) return prev
399
+ const entries = {...prev.entries}
400
+ if (next === undefined) delete entries[key]
401
+ else entries[key] = next
402
+ return {entries}
403
+ })
404
+ }
405
+
406
+ const baseEntry = (
407
+ def: AnyDefinition,
408
+ key: string,
409
+ instance: SanityInstance,
410
+ params: unknown[],
411
+ ): CacheEntry => {
412
+ const seeded = def.initialData?.(instance, ...params)
413
+ if (seeded) {
414
+ return {
415
+ fetcherName: def.name,
416
+ key,
417
+ params,
418
+ instance,
419
+ status: 'success',
420
+ data: seeded.data,
421
+ error: undefined,
422
+ dataUpdatedAt: seeded.dataUpdatedAt,
423
+ isFetching: false,
424
+ version: 0,
425
+ forcedStale: false,
426
+ tags: computeTags(def, seeded.data, params),
427
+ subscriptions: 0,
428
+ }
429
+ }
430
+ return {
431
+ fetcherName: def.name,
432
+ key,
433
+ params,
434
+ instance,
435
+ status: 'pending',
436
+ data: undefined,
437
+ error: undefined,
438
+ dataUpdatedAt: undefined,
439
+ isFetching: false,
440
+ version: 0,
441
+ forcedStale: false,
442
+ tags: [],
443
+ subscriptions: 0,
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Runs (or dedupes/restarts) the fetch for one entry, wiring the result back
449
+ * into the cache. Returns a promise resolving with the entry's data once the
450
+ * fetch settles — rejecting only when the entry has no data to serve.
451
+ */
452
+ const startFetch = (
453
+ state: StoreState<CacheState>,
454
+ def: AnyDefinition,
455
+ instance: SanityInstance,
456
+ params: unknown[],
457
+ force: boolean,
458
+ ): Promise<unknown> => {
459
+ const key = entryKey(def.name, def.getKey(instance, ...params))
460
+
461
+ const existing = inflight.get(key)
462
+ if (existing && !force) return existing.promise
463
+ if (existing) {
464
+ existing.sub.unsubscribe()
465
+ inflight.delete(key)
466
+ }
467
+
468
+ updateEntry(state, key, 'fetchStart', (prev) => ({
469
+ ...(prev ?? baseEntry(def, key, instance, params)),
470
+ isFetching: true,
471
+ }))
472
+ const startVersion = state.get().entries[key]?.version ?? 0
473
+
474
+ let resolve!: (data: unknown) => void
475
+ let reject!: (error: unknown) => void
476
+ const promise = new Promise<unknown>((res, rej) => {
477
+ resolve = res
478
+ reject = rej
479
+ })
480
+
481
+ // A forced fetch pre-empted one already in flight: forward this fetch's
482
+ // settlement to the pre-empted promise so its awaiters don't hang forever.
483
+ if (existing) void promise.then(existing.resolve, existing.reject)
484
+
485
+ // Register before subscribing so a synchronous observable (whose `next`/
486
+ // `complete` fire during `.subscribe()`) sees — and can clean up — its own record.
487
+ const sub = new Subscription()
488
+ inflight.set(key, {sub, promise, resolve, reject, startVersion})
489
+
490
+ sub.add(
491
+ def
492
+ .fetch(instance)(...params)
493
+ .subscribe({
494
+ next: (data) => {
495
+ const current = state.get().entries[key]
496
+ if (current && current.version !== startVersion) {
497
+ // A write (optimistic mutation) landed mid-fetch: discard the now-stale
498
+ // server result and keep the written data. Tear the fetch down here —
499
+ // a non-completing source won't fire `complete` to clear `isFetching`.
500
+ inflight.delete(key)
501
+ if (current.isFetching) {
502
+ updateEntry(
503
+ state,
504
+ key,
505
+ 'fetchDiscard',
506
+ (prev) => prev && {...prev, isFetching: false},
507
+ )
508
+ }
509
+ resolve(current.data)
510
+ sub.unsubscribe()
511
+ return
512
+ }
513
+ updateEntry(state, key, 'fetchSuccess', (prev) => ({
514
+ ...(prev ?? baseEntry(def, key, instance, params)),
515
+ status: 'success',
516
+ data,
517
+ error: undefined,
518
+ dataUpdatedAt: Date.now(),
519
+ isFetching: false,
520
+ forcedStale: false,
521
+ tags: computeTags(def, data, params),
522
+ }))
523
+ resolve(data)
524
+ },
525
+ error: (error) => {
526
+ updateEntry(state, key, 'fetchError', (prev) => {
527
+ if (!prev) return prev
528
+ // Keep rendering data if we have it (background failure); otherwise surface as error.
529
+ if (prev.status === 'success') return {...prev, error, isFetching: false}
530
+ return {...prev, status: 'error', error, isFetching: false}
531
+ })
532
+ inflight.delete(key)
533
+ reject(error)
534
+ },
535
+ complete: () => {
536
+ inflight.delete(key)
537
+ const current = state.get().entries[key]
538
+ if (current?.isFetching) {
539
+ updateEntry(state, key, 'fetchSettle', (prev) => prev && {...prev, isFetching: false})
540
+ }
541
+ },
542
+ }),
543
+ )
544
+
545
+ return promise
546
+ }
547
+
548
+ /**
549
+ * Trigger a fetch without awaiting it. The failure is already reflected in the
550
+ * entry's snapshot, so swallow the promise rejection to avoid an unhandled one.
551
+ */
552
+ const detach = (promise: Promise<unknown>): void => {
553
+ void promise.catch(() => {})
554
+ }
555
+
556
+ const markStale = (state: StoreState<CacheState>, key: string): void =>
557
+ updateEntry(state, key, 'markStale', (prev) => prev && {...prev, forcedStale: true})
558
+
559
+ /** Polls an entry on `refetchInterval` while it has subscribers, skipping ticks while the tab is hidden. */
560
+ const startInterval = (
561
+ state: StoreState<CacheState>,
562
+ def: AnyDefinition,
563
+ instance: SanityInstance,
564
+ params: unknown[],
565
+ key: string,
566
+ ): void => {
567
+ if (!def.refetchInterval || intervalTimers.has(key)) return
568
+ const timer = setCleanupInterval(() => {
569
+ if (typeof document !== 'undefined' && document.hidden) return
570
+ const entry = state.get().entries[key]
571
+ if (entry && entry.subscriptions > 0) detach(startFetch(state, def, instance, params, true))
572
+ }, def.refetchInterval)
573
+ intervalTimers.set(key, timer)
574
+ }
575
+
576
+ /**
577
+ * Schedules an unsubscribed entry for garbage collection: after `gcTime` (from
578
+ * now) its in-flight fetch is torn down and the entry removed. No-op if the
579
+ * entry still has subscribers or a GC timer is already pending. Called from
580
+ * every path that can leave an entry at zero subscribers — the unsubscribe
581
+ * teardown, and the imperative `refetch`/`setData` writes that create entries
582
+ * without ever subscribing. A later subscribe cancels the pending timer.
583
+ */
584
+ const scheduleGc = (state: StoreState<CacheState>, def: AnyDefinition, key: string): void => {
585
+ if (gcTimers.has(key)) return
586
+ const entry = state.get().entries[key]
587
+ if (!entry || entry.subscriptions > 0) return
588
+ const timer = setCleanupTimeout(() => {
589
+ gcTimers.delete(key)
590
+ const fetch = inflight.get(key)
591
+ if (fetch) {
592
+ fetch.sub.unsubscribe()
593
+ inflight.delete(key)
594
+ }
595
+ updateEntry(state, key, 'gc', () => undefined)
596
+ }, def.gcTime ?? DEFAULT_GC_TIME)
597
+ gcTimers.set(key, timer)
598
+ }
599
+
600
+ /** Shared across all fetchers: scans the whole cache and revalidates entries carrying any matched tag. */
601
+ const invalidateByTags = bindActionGlobally(
602
+ cacheStore,
603
+ ({state}: StoreContext<CacheState>, targets: CacheTag[]): Promise<void> => {
604
+ const refetches: Promise<unknown>[] = []
605
+ for (const [key, entry] of Object.entries(state.get().entries)) {
606
+ const matches = entry.tags.some((tag) =>
607
+ targets.some(
608
+ (target) => tag.type === target.type && (target.id === undefined || tag.id === target.id),
609
+ ),
610
+ )
611
+ if (!matches) continue
612
+ markStale(state, key)
613
+ if (entry.subscriptions <= 0) continue
614
+ const def = definitions.get(entry.fetcherName)
615
+ // Reconciliation is best-effort: a failed refetch surfaces on its snapshot,
616
+ // and must not reject the caller's `invalidated` promise.
617
+ if (def)
618
+ refetches.push(
619
+ startFetch(state, def, entry.instance, entry.params, true).catch(() => undefined),
620
+ )
621
+ }
622
+ return Promise.all(refetches).then(() => undefined)
623
+ },
624
+ )
625
+
626
+ /**
627
+ * Registers a fetcher definition against the shared cache and returns its typed
628
+ * handle.
629
+ *
630
+ * @internal
631
+ */
632
+ export function defineFetcher<TParams extends unknown[], TData>(
633
+ definition: FetcherDefinition<TParams, TData>,
634
+ ): Fetcher<TParams, TData> {
635
+ const def = definition as unknown as AnyDefinition
636
+ definitions.set(definition.name, def)
637
+
638
+ const getState = bindActionGlobally(
639
+ cacheStore,
640
+ createStateSourceAction<CacheState, TParams, FetcherSnapshot<TData>>({
641
+ selector: ({state, instance}: SelectorContext<CacheState>, ...params: TParams) => {
642
+ const key = entryKey(def.name, def.getKey(instance, ...params))
643
+ const entry = state.entries[key]
644
+ return toSnapshot(entry && asTyped<TData>(entry))
645
+ },
646
+ onSubscribe: ({state, instance}: StoreContext<CacheState>, ...params: TParams) => {
647
+ const key = entryKey(def.name, def.getKey(instance, ...params))
648
+
649
+ const pendingGc = gcTimers.get(key)
650
+ if (pendingGc) {
651
+ clearTimeout(pendingGc)
652
+ gcTimers.delete(key)
653
+ }
654
+
655
+ updateEntry(state, key, 'subscribe', (prev) => ({
656
+ ...(prev ?? baseEntry(def, key, instance, params)),
657
+ instance,
658
+ subscriptions: (prev?.subscriptions ?? 0) + 1,
659
+ }))
660
+ startInterval(state, def, instance, params, key)
661
+
662
+ // Defer the fetch decision off the subscribe call stack (see the
663
+ // `delay(0)` precedent in the legacy fetcher store) to avoid a
664
+ // re-entrant cache write during subscription setup.
665
+ queueMicrotask(() => {
666
+ const entry = state.get().entries[key]
667
+ if (entry && entry.subscriptions > 0 && needsFetch(entry, def)) {
668
+ detach(startFetch(state, def, instance, params, false))
669
+ }
670
+ })
671
+
672
+ return () => {
673
+ updateEntry(
674
+ state,
675
+ key,
676
+ 'unsubscribe',
677
+ (prev) => prev && {...prev, subscriptions: Math.max(0, prev.subscriptions - 1)},
678
+ )
679
+ const entry = state.get().entries[key]
680
+ if (!entry || entry.subscriptions > 0) return
681
+
682
+ const interval = intervalTimers.get(key)
683
+ if (interval) {
684
+ clearInterval(interval)
685
+ intervalTimers.delete(key)
686
+ }
687
+ scheduleGc(state, def, key)
688
+ }
689
+ },
690
+ }),
691
+ )
692
+
693
+ const resolveState = (instance: SanityInstance, ...params: TParams): Promise<TData> =>
694
+ firstValueFrom(
695
+ getState(instance, ...params).observable.pipe(
696
+ filter(
697
+ (snapshot): snapshot is Exclude<FetcherSnapshot<TData>, {status: 'pending'}> =>
698
+ snapshot.status !== 'pending',
699
+ ),
700
+ map((snapshot) => {
701
+ if (snapshot.status === 'error') throw snapshot.error
702
+ return snapshot.data
703
+ }),
704
+ ),
705
+ )
706
+
707
+ const invalidate = bindActionGlobally(
708
+ cacheStore,
709
+ ({state, instance}: StoreContext<CacheState>, ...params: TParams) => {
710
+ const key = entryKey(def.name, def.getKey(instance, ...params))
711
+ markStale(state, key)
712
+ const entry = state.get().entries[key]
713
+ if (entry && entry.subscriptions > 0) detach(startFetch(state, def, instance, params, true))
714
+ },
715
+ )
716
+
717
+ const invalidateAll = bindActionGlobally(cacheStore, ({state}: StoreContext<CacheState>) => {
718
+ const prefix = `${def.name}:`
719
+ for (const [key, entry] of Object.entries(state.get().entries)) {
720
+ if (!key.startsWith(prefix)) continue
721
+ markStale(state, key)
722
+ if (entry.subscriptions > 0)
723
+ detach(startFetch(state, def, entry.instance, entry.params, true))
724
+ }
725
+ })
726
+
727
+ const refetch = bindActionGlobally(
728
+ cacheStore,
729
+ ({state, instance}: StoreContext<CacheState>, ...params: TParams): Promise<TData> => {
730
+ const key = entryKey(def.name, def.getKey(instance, ...params))
731
+ const promise = startFetch(state, def, instance, params, true) as Promise<TData>
732
+ scheduleGc(state, def, key)
733
+ return promise
734
+ },
735
+ )
736
+
737
+ const setData = bindActionGlobally(
738
+ cacheStore,
739
+ (
740
+ {state, instance}: StoreContext<CacheState>,
741
+ params: TParams,
742
+ updater: TData | ((current: TData | undefined) => TData),
743
+ ) => {
744
+ const key = entryKey(def.name, def.getKey(instance, ...params))
745
+ let previous: CacheEntry | undefined
746
+ updateEntry(state, key, 'setData', (prev) => {
747
+ previous = prev
748
+ const from = asTyped<TData>(prev ?? baseEntry(def, key, instance, params))
749
+ const nextData = applyUpdater(updater, from.data)
750
+ return {
751
+ ...from,
752
+ status: 'success',
753
+ data: nextData,
754
+ dataUpdatedAt: from.dataUpdatedAt ?? Date.now(),
755
+ version: from.version + 1,
756
+ tags: computeTags(def, nextData, params),
757
+ }
758
+ })
759
+ scheduleGc(state, def, key)
760
+ return {undo: () => updateEntry(state, key, 'undoSetData', () => previous)}
761
+ },
762
+ )
763
+
764
+ return {getState, resolveState, invalidate, invalidateAll, refetch, setData}
765
+ }
766
+
767
+ /**
768
+ * Creates a bound mutation from its definition. The returned action resolves at
769
+ * server settlement — not after invalidation refetches, which run as background
770
+ * reconciliation (see {@link MutationResult.invalidated}).
771
+ *
772
+ * @internal
773
+ */
774
+ export function defineMutation<TInput, TResult>(
775
+ definition: MutationDefinition<TInput, TResult>,
776
+ ): (instance: SanityInstance, input: TInput) => Promise<MutationResult<TResult>> {
777
+ return (instance, input) => {
778
+ const undos: Array<() => void> = []
779
+ const write: CacheWriter = (fetcher, params, updater) => {
780
+ undos.push(fetcher.setData(instance, params, updater).undo)
781
+ }
782
+
783
+ definition.onMutate?.(write, input)
784
+
785
+ return firstValueFrom(definition.mutationFn(instance)(input)).then(
786
+ (result) => {
787
+ definition.onSuccess?.(write, result, input)
788
+ const targets =
789
+ typeof definition.invalidates === 'function'
790
+ ? definition.invalidates(result, input)
791
+ : (definition.invalidates ?? [])
792
+ const invalidated = targets.length ? invalidateByTags(instance, targets) : Promise.resolve()
793
+ return {data: result, invalidated}
794
+ },
795
+ (error) => {
796
+ for (let i = undos.length - 1; i >= 0; i--) undos[i]()
797
+ throw error
798
+ },
799
+ )
800
+ }
801
+ }