@sanity/sdk
Version:
396 lines (363 loc) • 12.5 kB
text/typescript
import {CorsOriginError, type ResponseQueryOptions} from '@sanity/client'
import {type SanityQueryResult} from 'groq'
import {
catchError,
combineLatest,
defer,
distinctUntilChanged,
EMPTY,
filter,
first,
firstValueFrom,
groupBy,
map,
mergeMap,
NEVER,
Observable,
pairwise,
race,
share,
startWith,
switchMap,
tap,
} from 'rxjs'
import {getClientState} from '../client/clientStore'
import {type DatasetHandle, type DocumentSource} from '../config/sanityConfig'
import {getPerspectiveState} from '../releases/getPerspectiveState'
import {bindActionBySource} from '../store/createActionBinder'
import {type SanityInstance} from '../store/createSanityInstance'
import {
createStateSourceAction,
type SelectorContext,
type StateSource,
} from '../store/createStateSourceAction'
import {type StoreState} from '../store/createStoreState'
import {defineStore, type StoreContext} from '../store/defineStore'
import {insecureRandomId} from '../utils/ids'
import {
QUERY_STATE_CLEAR_DELAY,
QUERY_STORE_API_VERSION,
QUERY_STORE_DEFAULT_PERSPECTIVE,
} from './queryStoreConstants'
import {
addSubscriber,
cancelQuery,
initializeQuery,
type QueryStoreState,
removeSubscriber,
setLastLiveEventId,
setQueryData,
setQueryError,
} from './reducers'
/**
* @beta
*/
export interface QueryOptions<
TQuery extends string = string,
TDataset extends string = string,
TProjectId extends string = string,
> extends Pick<ResponseQueryOptions, 'useCdn' | 'cache' | 'next' | 'cacheMode' | 'tag'>,
DatasetHandle<TDataset, TProjectId> {
query: TQuery
params?: Record<string, unknown>
source?: DocumentSource
}
/**
* @beta
*/
export interface ResolveQueryOptions<
TQuery extends string = string,
TDataset extends string = string,
TProjectId extends string = string,
> extends QueryOptions<TQuery, TDataset, TProjectId> {
signal?: AbortSignal
}
const EMPTY_ARRAY: never[] = []
/** @beta */
export const getQueryKey = (options: QueryOptions): string => JSON.stringify(options)
/** @beta */
export const parseQueryKey = (key: string): QueryOptions => JSON.parse(key)
/**
* Ensures the query key includes an effective perspective so that
* implicit differences (e.g. different instance.config.perspective)
* don't collide in the dataset-scoped store.
*
* Since perspectives are unique, we can depend on the release stacks
* to be correct when we retrieve the results.
*
*/
function normalizeOptionsWithPerspective(
instance: SanityInstance,
options: QueryOptions,
): QueryOptions {
if (options.perspective !== undefined) return options
const instancePerspective = instance.config.perspective
return {
...options,
perspective:
instancePerspective !== undefined ? instancePerspective : QUERY_STORE_DEFAULT_PERSPECTIVE,
}
}
const queryStore = defineStore<QueryStoreState>({
name: 'QueryStore',
getInitialState: () => ({queries: {}}),
initialize(context) {
const subscriptions = [
listenForNewSubscribersAndFetch(context),
listenToLiveClientAndSetLastLiveEventIds(context),
]
return () => {
for (const subscription of subscriptions) {
subscription.unsubscribe()
}
}
},
})
const errorHandler = (state: StoreState<{error?: unknown}>) => {
return (error: unknown): void => state.set('setError', {error})
}
const listenForNewSubscribersAndFetch = ({state, instance}: StoreContext<QueryStoreState>) => {
return state.observable
.pipe(
map((s) => new Set(Object.keys(s.queries))),
distinctUntilChanged((curr, next) => {
if (curr.size !== next.size) return false
return Array.from(next).every((i) => curr.has(i))
}),
startWith(new Set<string>()),
pairwise(),
mergeMap(([curr, next]) => {
const added = Array.from(next).filter((i) => !curr.has(i))
const removed = Array.from(curr).filter((i) => !next.has(i))
return [
...added.map((key) => ({key, added: true})),
...removed.map((key) => ({key, added: false})),
]
}),
groupBy((i) => i.key),
mergeMap((group$) =>
group$.pipe(
switchMap((e) => {
if (!e.added) return EMPTY
const lastLiveEventId$ = state.observable.pipe(
map((s) => s.queries[group$.key]?.lastLiveEventId),
distinctUntilChanged(),
)
const {
query,
params,
projectId,
dataset,
tag,
source,
perspective: perspectiveFromOptions,
...restOptions
} = parseQueryKey(group$.key)
const perspective$ = getPerspectiveState(instance, {
perspective: perspectiveFromOptions,
}).observable.pipe(filter(Boolean))
const client$ = getClientState(instance, {
apiVersion: QUERY_STORE_API_VERSION,
projectId,
dataset,
source,
}).observable
return combineLatest([lastLiveEventId$, client$, perspective$]).pipe(
switchMap(([lastLiveEventId, client, perspective]) =>
client.observable.fetch(query, params, {
...restOptions,
perspective,
filterResponse: false,
returnQuery: false,
lastLiveEventId,
tag,
}),
),
)
}),
catchError((error) => {
state.set('setQueryError', setQueryError(group$.key, error))
return EMPTY
}),
tap(({result, syncTags}) => {
state.set('setQueryData', setQueryData(group$.key, result, syncTags))
}),
),
),
)
.subscribe({error: errorHandler(state)})
}
const listenToLiveClientAndSetLastLiveEventIds = ({
state,
instance,
}: StoreContext<QueryStoreState>) => {
const liveMessages$ = getClientState(instance, {
apiVersion: QUERY_STORE_API_VERSION,
}).observable.pipe(
switchMap((client) =>
defer(() =>
client.live.events({includeDrafts: !!client.config().token, tag: 'query-store'}),
).pipe(
catchError((error) => {
if (error instanceof CorsOriginError) {
// Swallow only CORS errors in store without bubbling up so that they are handled by the Cors Error component
state.set('setError', {error})
return EMPTY
}
throw error
}),
),
),
share(),
filter((e) => e.type === 'message'),
)
return state.observable
.pipe(
mergeMap((s) => Object.entries(s.queries)),
groupBy(([key]) => key),
mergeMap((group$) => {
const syncTags$ = group$.pipe(
map(([, queryState]) => queryState),
map((i) => i?.syncTags ?? EMPTY_ARRAY),
distinctUntilChanged(),
)
return combineLatest([liveMessages$, syncTags$]).pipe(
filter(([message, syncTags]) => message.tags.some((tag) => syncTags.includes(tag))),
tap(([message]) => {
state.set('setLastLiveEventId', setLastLiveEventId(group$.key, message.id))
}),
)
}),
)
.subscribe({error: errorHandler(state)})
}
/**
* Returns the state source for a query.
*
* This function returns a state source that represents the current result of a GROQ query.
* Subscribing to the state source will instruct the SDK to fetch the query (if not already fetched)
* and will keep the query live using the Live content API (considering sync tags) to provide up-to-date results.
* When the last subscriber is removed, the query state is automatically cleaned up from the store.
*
* Note: This functionality is for advanced users who want to build their own framework integrations.
* Our SDK also provides a React integration (useQuery hook) for convenient usage.
*
* Note: Automatic cleanup can interfere with React Suspense because if a component suspends while being the only subscriber,
* cleanup might occur unexpectedly. In such cases, consider using `resolveQuery` instead.
*
* @beta
*/
export function getQueryState<
TQuery extends string = string,
TDataset extends string = string,
TProjectId extends string = string,
>(
instance: SanityInstance,
queryOptions: QueryOptions<TQuery, TDataset, TProjectId>,
): StateSource<SanityQueryResult<TQuery, `${TProjectId}.${TDataset}`> | undefined>
/** @beta */
export function getQueryState<TData>(
instance: SanityInstance,
queryOptions: QueryOptions,
): StateSource<TData | undefined>
/** @beta */
export function getQueryState(
instance: SanityInstance,
queryOptions: QueryOptions,
): StateSource<unknown>
/** @beta */
export function getQueryState(
...args: Parameters<typeof _getQueryState>
): ReturnType<typeof _getQueryState> {
return _getQueryState(...args)
}
const _getQueryState = bindActionBySource(
queryStore,
createStateSourceAction({
selector: ({state, instance}: SelectorContext<QueryStoreState>, options: QueryOptions) => {
if (state.error) throw state.error
const key = getQueryKey(normalizeOptionsWithPerspective(instance, options))
const queryState = state.queries[key]
if (queryState?.error) throw queryState.error
return queryState?.result
},
onSubscribe: ({state, instance}, options: QueryOptions) => {
const subscriptionId = insecureRandomId()
const key = getQueryKey(normalizeOptionsWithPerspective(instance, options))
state.set('addSubscriber', addSubscriber(key, subscriptionId))
return () => {
// this runs on unsubscribe
setTimeout(
() => state.set('removeSubscriber', removeSubscriber(key, subscriptionId)),
QUERY_STATE_CLEAR_DELAY,
)
}
},
}),
)
/**
* Resolves the result of a query without registering a lasting subscriber.
*
* This function fetches the result of a GROQ query and returns a promise that resolves with the query result.
* Unlike `getQueryState`, which registers subscribers to keep the query live and performs automatic cleanup,
* `resolveQuery` does not track subscribers. This makes it ideal for use with React Suspense, where the returned
* promise is thrown to delay rendering until the query result becomes available.
* Once the promise resolves, it is expected that a real subscriber will be added via `getQueryState` to manage ongoing updates.
*
* Additionally, an optional AbortSignal can be provided to cancel the query and immediately clear the associated state
* if there are no active subscribers.
*
* @beta
*/
export function resolveQuery<
TQuery extends string = string,
TDataset extends string = string,
TProjectId extends string = string,
>(
instance: SanityInstance,
queryOptions: ResolveQueryOptions<TQuery, TDataset, TProjectId>,
): Promise<SanityQueryResult<TQuery, `${TProjectId}.${TDataset}`>>
/** @beta */
export function resolveQuery<TData>(
instance: SanityInstance,
queryOptions: ResolveQueryOptions,
): Promise<TData>
/** @beta */
export function resolveQuery(...args: Parameters<typeof _resolveQuery>): Promise<unknown> {
return _resolveQuery(...args)
}
const _resolveQuery = bindActionBySource(
queryStore,
({state, instance}, {signal, ...options}: ResolveQueryOptions) => {
const normalized = normalizeOptionsWithPerspective(instance, options)
const {getCurrent} = getQueryState(instance, normalized)
const key = getQueryKey(normalized)
const aborted$ = signal
? new Observable<void>((observer) => {
const cleanup = () => {
signal.removeEventListener('abort', listener)
}
const listener = () => {
observer.error(new DOMException('The operation was aborted.', 'AbortError'))
observer.complete()
cleanup()
}
signal.addEventListener('abort', listener)
return cleanup
}).pipe(
catchError((error) => {
if (error instanceof Error && error.name === 'AbortError') {
state.set('cancelQuery', cancelQuery(key))
}
throw error
}),
)
: NEVER
state.set('initializeQuery', initializeQuery(key))
const resolved$ = state.observable.pipe(
map(getCurrent),
first((i) => i !== undefined),
)
return firstValueFrom(race([resolved$, aborted$]))
},
)