@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.
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts +233 -12
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
- package/dist/_chunks-es/_internal.js +1 -0
- package/dist/_chunks-es/_internal.js.map +1 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +284 -6
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- package/dist/_chunks-es/telemetryManager.js +2 -2
- package/dist/_exports/_internal.d.ts +2 -2
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +3 -1
- package/dist/index.d.ts +599 -185
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +492 -337
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/_exports/_internal.ts +11 -0
- package/src/_exports/index.ts +57 -12
- package/src/access/checkPermissions.test.ts +55 -0
- package/src/access/checkPermissions.ts +54 -0
- package/src/applications/applications.test-d.ts +83 -0
- package/src/applications/applications.test.ts +130 -0
- package/src/applications/applications.ts +364 -0
- package/src/applications/userApplications.test.ts +126 -0
- package/src/applications/userApplications.ts +261 -0
- package/src/auth/getOrganizationVerificationState.test.ts +7 -7
- package/src/auth/getOrganizationVerificationState.ts +4 -4
- package/src/document/documentStore.concurrency.test.ts +313 -22
- package/src/installations/installations.test-d.ts +68 -0
- package/src/installations/installations.test.ts +62 -0
- package/src/installations/installations.ts +202 -0
- package/src/organization/organization.test-d.ts +18 -84
- package/src/organization/organization.test.ts +11 -11
- package/src/organization/organization.ts +26 -54
- package/src/organizations/organizations.test-d.ts +11 -32
- package/src/organizations/organizations.test.ts +8 -8
- package/src/organizations/organizations.ts +29 -54
- package/src/project/project.test-d.ts +19 -75
- package/src/project/project.test.ts +57 -10
- package/src/project/project.ts +33 -52
- package/src/projects/projects.test-d.ts +15 -21
- package/src/projects/projects.test.ts +8 -8
- package/src/projects/projects.ts +25 -49
- package/src/store/fetcherStore.test-d.ts +121 -0
- package/src/store/fetcherStore.test.ts +299 -0
- package/src/store/fetcherStore.ts +801 -0
- package/src/utils/buildQuery.test.ts +14 -0
- package/src/utils/buildQuery.ts +17 -0
- package/src/utils/includeShape.ts +36 -0
- package/src/utils/setCleanupTimeout.ts +21 -8
- package/dist/_chunks-es/version.js +0 -7
- package/dist/_chunks-es/version.js.map +0 -1
- package/src/utils/defineIntent.test.ts +0 -477
- 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
|
+
}
|