UNPKG

@sanity/client

Version:

Client for retrieving, creating and patching data from Sanity.io

1,316 lines (1,211 loc) 40.5 kB
import {getDraftId, getVersionFromId, getVersionId, isDraftId} from '@sanity/client/csm' import {type MonoTypeOperatorFunction, Observable} from 'rxjs' import {filter, map} from 'rxjs/operators' import {validateApiPerspective} from '../config' import {type FetchRequest, requestOptions} from '../http/requestOptions' import type {ObservableSanityClient, SanityClient} from '../SanityClient' import {stegaClean, type StegaCleaned} from '../stega/stegaClean' import type { Action, AllDocumentIdsMutationOptions, AllDocumentsMutationOptions, Any, BaseActionOptions, BaseMutationOptions, ClientVariantConditions, CreateVersionAction, DiscardVersionAction, FirstDocumentIdMutationOptions, FirstDocumentMutationOptions, HttpRequest, IdentifiedSanityDocumentStub, InitializedClientConfig, InitializedStegaConfig, MultipleActionResult, MultipleMutationResult, Mutation, MutationSelection, QueryOptions, RawQueryResponse, ReplaceVersionAction, RequestObservableOptions, SanityDocument, SingleActionResult, SingleMutationResult, UnpublishVersionAction, UploadEvent, } from '../types' import {getSelection} from '../util/getSelection' import * as validate from '../validators' import * as validators from '../validators' import { printCdnPreviewDraftsWarning, printCreateVersionWithBaseIdWarning, printDeprecatedUriOptionWarning, printPreviewDraftsDeprecationWarning, } from '../warnings' import {encodeQueryString} from './encodeQueryString' import {ObservablePatch, Patch} from './patch' import {ObservableTransaction, Transaction} from './transaction' type Client = SanityClient | ObservableSanityClient const excludeFalsey = (param: Any, defValue: Any) => { const value = typeof param === 'undefined' ? defValue : param return param === false ? undefined : value } const getMutationQuery = (options: BaseMutationOptions = {}) => { return { dryRun: options.dryRun, returnIds: true, returnDocuments: excludeFalsey(options.returnDocuments, true), visibility: options.visibility || 'sync', autoGenerateArrayKeys: options.autoGenerateArrayKeys, skipCrossDatasetReferenceValidation: options.skipCrossDatasetReferenceValidation, } } const indexBy = (docs: Any[], attr: Any) => docs.reduce((indexed, doc) => { indexed[attr(doc)] = doc return indexed }, Object.create(null)) /** * Encoded query strings longer than this are sent as a `POST` body rather than * in the request URL. * * @internal */ export const getQuerySizeLimit = 11264 /** * Resolve the effective stega config, cleaned params, response mapper and * transport request options for a `fetch()` call. Shared by the observable and * promise paths. * * @internal */ function _fetchRequest<Q>( _stega: InitializedStegaConfig, _params: Q, options: QueryOptions, ): { stega: InitializedStegaConfig // Stega-enabled requests send the cleaned (brand-stripped) params. params: Q | StegaCleaned<Q> mapResponse: (res: Any) => Any reqOpts: Any } { const stega = 'stega' in options ? { ..._stega, ...(typeof options.stega === 'boolean' ? {enabled: options.stega} : options.stega || {}), } : _stega const params = stega.enabled ? stegaClean(_params) : _params const mapResponse = options.filterResponse === false ? (res: Any) => res : (res: Any) => res.result const {cache, next, ...opts} = { // Opt out of setting a `signal` on an internal `fetch` if one isn't provided. // This is necessary in React Server Components to avoid opting out of Request Memoization. useAbortSignal: typeof options.signal !== 'undefined', // Set `resultSourceMap' when stega is enabled, as it's required for encoding. resultSourceMap: stega.enabled ? 'withKeyArraySelector' : options.resultSourceMap, ...options, // Default to not returning the query, unless `filterResponse` is `false`, // or `returnQuery` is explicitly set. `true` is the default in Content Lake, so skip if truthy returnQuery: options.filterResponse === false && options.returnQuery !== false, } const reqOpts = typeof cache !== 'undefined' || typeof next !== 'undefined' ? {...opts, fetch: {cache, next}} : opts return {stega, params, mapResponse, reqOpts} } /** @internal */ export function _fetchObservable<R, Q>( client: Client, httpRequest: HttpRequest, _stega: InitializedStegaConfig, query: string, _params: Q = {} as Q, options: QueryOptions = {}, ): Observable<RawQueryResponse<R> | R> { return _observe(options.signal, (signal) => _fetch<R, Q>(client, httpRequest, _stega, query, _params, {...options, signal}), ) } /** * Promise-based sibling of {@link _fetchObservable}. * * @internal */ export function _fetch<R, Q>( client: Client, httpRequest: HttpRequest, _stega: InitializedStegaConfig, query: string, _params: Q = {} as Q, options: QueryOptions = {}, ): Promise<RawQueryResponse<R> | R> { const {stega, params, mapResponse, reqOpts} = _fetchRequest(_stega, _params, options) // Kick off the request synchronously (not in an `async` body) so option // validation - e.g. an invalid request tag or a missing dataset - throws // synchronously at call time, matching the rest of the promise surface. const request = _dataRequest(client, httpRequest, 'query', {query, params}, reqOpts) if (!stega.enabled) { return request.then(mapResponse) } return Promise.all([request, import('../stega/stegaEncodeSourceMap')]).then( ([res, {stegaEncodeSourceMap}]) => { const result = stegaEncodeSourceMap(res.result, res.resultSourceMap, stega) return mapResponse({...res, result}) }, ) } /** @internal */ export function _getDocumentObservable<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, id: string, opts: {signal?: AbortSignal; tag?: string; releaseId?: string; includeAllVersions: true}, ): Observable<SanityDocument<R>[]> /** @internal */ export function _getDocumentObservable<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, id: string, opts?: {signal?: AbortSignal; tag?: string; releaseId?: string; includeAllVersions?: false}, ): Observable<SanityDocument<R> | undefined> /** @internal */ export function _getDocumentObservable<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, id: string, opts: {signal?: AbortSignal; tag?: string; releaseId?: string; includeAllVersions?: boolean} = {}, ): Observable<SanityDocument<R> | undefined | SanityDocument<R>[]> { return _observe(opts.signal, (signal) => _request<{documents?: SanityDocument<R>[]}>( client, httpRequest, _getDocumentOptions(client, id, {...opts, signal}), ).then((body) => _mapGetDocument<R>(body, opts.includeAllVersions)), ) } /** * Promise-based sibling of {@link _getDocumentObservable}. * * @internal */ export function _getDocument<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, id: string, opts: {signal?: AbortSignal; tag?: string; releaseId?: string; includeAllVersions: true}, ): Promise<SanityDocument<R>[]> /** @internal */ export function _getDocument<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, id: string, opts?: {signal?: AbortSignal; tag?: string; releaseId?: string; includeAllVersions?: false}, ): Promise<SanityDocument<R> | undefined> /** @internal */ export function _getDocument<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, id: string, opts: {signal?: AbortSignal; tag?: string; releaseId?: string; includeAllVersions?: boolean} = {}, ): Promise<SanityDocument<R> | undefined | SanityDocument<R>[]> { const options = _getDocumentOptions(client, id, opts) return _request<{documents?: SanityDocument<R>[]}>(client, httpRequest, options).then((body) => _mapGetDocument<R>(body, opts.includeAllVersions), ) } /** * Resolve the document id (honoring `releaseId`) and build the transport * request options for {@link _getDocumentObservable}. Shared by the observable and * promise paths. * * @internal */ function _getDocumentOptions( client: Client, id: string, opts: {signal?: AbortSignal; tag?: string; releaseId?: string; includeAllVersions?: boolean}, ): Any { let docId = id if (opts.releaseId) { const versionId = getVersionFromId(id) if (!versionId) { if (isDraftId(id)) { throw new Error( `The document ID (\`${id}\`) is a draft, but \`options.releaseId\` is set as \`${opts.releaseId}\``, ) } docId = getVersionId(id, opts.releaseId) } else if (versionId !== opts.releaseId) { throw new Error( `The document ID (\`${id}\`) is already a version of \`${versionId}\` release, but this does not match the provided \`options.releaseId\` (\`${opts.releaseId}\`)`, ) } } return { url: _getDataUrl(client, 'doc', docId), tag: opts.tag, signal: opts.signal, query: opts.includeAllVersions !== undefined ? {includeAllVersions: opts.includeAllVersions} : undefined, } } function _mapGetDocument<R extends Record<string, Any>>( body: {documents?: SanityDocument<R>[]}, includeAllVersions?: boolean, ): SanityDocument<R> | undefined | SanityDocument<R>[] { const documents = body.documents if (!documents) { return includeAllVersions ? [] : undefined } return includeAllVersions ? documents : documents[0] } /** @internal */ export function _getDocumentsObservable<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, ids: string[], opts: {signal?: AbortSignal; tag?: string} = {}, ): Observable<(SanityDocument<R> | null)[]> { return _observe(opts.signal, (signal) => _getDocuments<R>(client, httpRequest, ids, {...opts, signal}), ) } /** * Promise-based sibling of {@link _getDocumentsObservable}. * * @internal */ export function _getDocuments<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, ids: string[], opts: {signal?: AbortSignal; tag?: string} = {}, ): Promise<(SanityDocument<R> | null)[]> { const options = _getDocumentsOptions(client, ids, opts) return _request<{documents?: SanityDocument<R>[]}>(client, httpRequest, options).then((body) => _mapGetDocuments<R>(body, ids), ) } function _getDocumentsOptions( client: Client, ids: string[], opts: {signal?: AbortSignal; tag?: string}, ): Any { return { url: _getDataUrl(client, 'doc', ids.join(',')), tag: opts.tag, signal: opts.signal, } } function _mapGetDocuments<R extends Record<string, Any>>( body: {documents?: SanityDocument<R>[]}, ids: string[], ): (SanityDocument<R> | null)[] { const indexed = indexBy(body.documents || [], (doc: Any) => doc._id) return ids.map((id) => indexed[id] || null) } const DOCUMENTS_EXISTS_BATCH_SIZE = 100 /** @internal */ export function _documentsExistsObservable( client: Client, httpRequest: HttpRequest, ids: string[], opts: {signal?: AbortSignal; tag?: string} = {}, ): Observable<Set<string>> { return _observe(opts.signal, (signal) => _documentsExists(client, httpRequest, ids, {...opts, signal}), ) } /** * Promise-based sibling of {@link _documentsExistsObservable}. Checks document * existence in batches, resolving to the set of IDs that exist. * * @internal */ export async function _documentsExists( client: Client, httpRequest: HttpRequest, ids: string[], opts: {signal?: AbortSignal; tag?: string} = {}, ): Promise<Set<string>> { const existing = new Set<string>() if (ids.length === 0) { return existing } for (let i = 0; i < ids.length; i += DOCUMENTS_EXISTS_BATCH_SIZE) { const batchIds = ids.slice(i, i + DOCUMENTS_EXISTS_BATCH_SIZE) const body = await _request<{omitted?: {id: string; reason: string}[]}>(client, httpRequest, { url: _getDataUrl(client, 'doc', batchIds.map(encodeURIComponent).join(',')), tag: opts.tag, signal: opts.signal, query: {excludeContent: true}, }) const missing = new Set<string>() for (const omitted of body.omitted || []) { if (omitted.reason !== 'existence') continue missing.add(omitted.id) } for (const id of batchIds) { if (!missing.has(id)) existing.add(id) } } return existing } /** @internal */ export function _getReleaseDocumentsObservable<R extends Record<string, Any>>( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, releaseId: string, opts: BaseMutationOptions = {}, ): Observable<RawQueryResponse<SanityDocument<R>[]>> { return _observe(opts.signal, (signal) => _getReleaseDocuments<R>(client, httpRequest, releaseId, {...opts, signal}), ) } /** * Promise-based sibling of {@link _getReleaseDocumentsObservable}. * * @internal */ export function _getReleaseDocuments<R extends Record<string, Any>>( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, releaseId: string, opts: BaseMutationOptions = {}, ): Promise<RawQueryResponse<SanityDocument<R>[]>> { return _dataRequest( client, httpRequest, 'query', { query: '*[sanity::partOfRelease($releaseId)]', params: { releaseId, }, }, opts, ) } /** @internal */ export function _createIfNotExistsObservable<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, doc: IdentifiedSanityDocumentStub<R>, options?: | AllDocumentIdsMutationOptions | AllDocumentsMutationOptions | BaseMutationOptions | FirstDocumentIdMutationOptions | FirstDocumentMutationOptions, ): Observable< SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult > { return _observe(options?.signal, (signal) => _createIfNotExists<R>(client, httpRequest, doc, {...options, signal}), ) } /** @internal */ export function _createOrReplaceObservable<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, doc: IdentifiedSanityDocumentStub<R>, options?: | AllDocumentIdsMutationOptions | AllDocumentsMutationOptions | BaseMutationOptions | FirstDocumentIdMutationOptions | FirstDocumentMutationOptions, ): Observable< SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult > { return _observe(options?.signal, (signal) => _createOrReplace<R>(client, httpRequest, doc, {...options, signal}), ) } /** @internal */ export function _createVersionObservable<R extends Record<string, Any>>( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, doc: IdentifiedSanityDocumentStub<R>, publishedId: string, options?: BaseActionOptions, ): Observable<SingleActionResult> { return _observe(options?.signal, (signal) => _createVersion<R>(client, httpRequest, doc, publishedId, {...options, signal}), ) } /** @internal */ export function _createVersionFromBaseObservable( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, publishedId?: string, baseId?: string, releaseId?: string, ifBaseRevisionId?: string, options?: BaseActionOptions, ): Observable<SingleActionResult> { return _observe(options?.signal, (signal) => _createVersionFromBase(client, httpRequest, publishedId, baseId, releaseId, ifBaseRevisionId, { ...options, signal, }), ) } /** @internal */ export function _deleteObservable<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, selection: string | MutationSelection, options?: | AllDocumentIdsMutationOptions | AllDocumentsMutationOptions | BaseMutationOptions | FirstDocumentIdMutationOptions | FirstDocumentMutationOptions, ): Observable< SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult > { return _observe(options?.signal, (signal) => _delete<R>(client, httpRequest, selection, {...options, signal}), ) } /** @internal */ export function _discardVersionObservable( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, versionId: string, purge: boolean = false, options?: BaseActionOptions, ): Observable<SingleActionResult> { return _observe(options?.signal, (signal) => _discardVersion(client, httpRequest, versionId, purge, {...options, signal}), ) } /** @internal */ export function _replaceVersionObservable<R extends Record<string, Any>>( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, doc: IdentifiedSanityDocumentStub<R>, options?: BaseActionOptions, ): Observable<SingleActionResult> { return _observe(options?.signal, (signal) => _replaceVersion<R>(client, httpRequest, doc, {...options, signal}), ) } /** @internal */ export function _unpublishVersionObservable( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, versionId: string, publishedId: string, options?: BaseActionOptions, ): Observable<SingleActionResult> { return _observe(options?.signal, (signal) => _unpublishVersion(client, httpRequest, versionId, publishedId, { ...options, signal, }), ) } /** @internal */ export function _mutateObservable<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, mutations: Mutation<R>[] | Patch | ObservablePatch | Transaction | ObservableTransaction, options?: | AllDocumentIdsMutationOptions | AllDocumentsMutationOptions | BaseMutationOptions | FirstDocumentIdMutationOptions | FirstDocumentMutationOptions, ): Observable< SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult > { return _observe(options?.signal, (signal) => _mutate<R>(client, httpRequest, mutations, {...options, signal}), ) } /** * @internal */ export function _actionObservable( client: Client, httpRequest: HttpRequest, actions: Action | Action[], options?: BaseActionOptions, ): Observable<SingleActionResult | MultipleActionResult> { return _observe(options?.signal, (signal) => _action(client, httpRequest, actions, {...options, signal}), ) } /** * Build the transport request options for a data endpoint (`query` / `mutate` * / `actions`). Shared by the observable and promise request paths. * * @internal */ function _dataRequestOptions( client: Client, endpoint: string, body: Any, options: Any = {}, ): {reqOptions: Any; isMutation: boolean; returnFirst: boolean} { const isMutation = endpoint === 'mutate' const isAction = endpoint === 'actions' const isQuery = endpoint === 'query' // Check if the query string is within a configured threshold, // in which case we can use GET. Otherwise, use POST. const strQuery = isMutation || isAction ? '' : encodeQueryString(body) const useGet = !isMutation && !isAction && strQuery.length < getQuerySizeLimit const stringQuery = useGet ? strQuery : '' const returnFirst = options.returnFirst const {timeout, token, tag, headers, returnQuery, lastLiveEventId, cacheMode} = options const url = _getDataUrl(client, endpoint, stringQuery) const reqOptions = { method: useGet ? 'GET' : 'POST', url, body: useGet ? undefined : body, query: isMutation && getMutationQuery(options), timeout, headers, token, tag, returnQuery, perspective: options.perspective, variant: options.variant, resultSourceMap: options.resultSourceMap, lastLiveEventId: Array.isArray(lastLiveEventId) ? lastLiveEventId[0] : lastLiveEventId, cacheMode: cacheMode, canUseCdn: isQuery, signal: options.signal, fetch: options.fetch, useAbortSignal: options.useAbortSignal, useCdn: options.useCdn, } return {reqOptions, isMutation, returnFirst} } /** * Shape the raw response body of a data request. For mutations this reduces * the API response to documents or ids; everything else passes through. Pure * function shared by the observable and promise request paths. * * @internal */ function _mapDataResponse( res: Any, isMutation: boolean, returnFirst: boolean, returnDocuments: boolean, ): Any { if (!isMutation) { return res } // Should we return documents? const results = res.results || [] if (returnDocuments) { return returnFirst ? results[0] && results[0].document : results.map((mut: Any) => mut.document) } // Return a reduced subset const key = returnFirst ? 'documentId' : 'documentIds' const ids = returnFirst ? results[0] && results[0].id : results.map((mut: Any) => mut.id) return { transactionId: res.transactionId, results: results, [key]: ids, } } /** * @internal */ export function _dataRequest( client: Client, httpRequest: HttpRequest, endpoint: string, body: Any, options: Any = {}, ): Promise<Any> { const {reqOptions, isMutation, returnFirst} = _dataRequestOptions(client, endpoint, body, options) return _request(client, httpRequest, reqOptions).then((res: Any) => _mapDataResponse(res, isMutation, returnFirst, options.returnDocuments), ) } /** * @internal */ export function _createObservable<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, doc: Any, op: Any, options: Any = {}, ): Observable< SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult > { return _observe(options.signal, (signal) => _create<R>(client, httpRequest, doc, op, {...options, signal}), ) } /** * Promise-based sibling of {@link _createObservable}. * * @internal */ export function _create<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, doc: Any, op: Any, options: Any = {}, ): Promise< SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult > { const mutation = {[op]: doc} const opts = Object.assign({returnFirst: true, returnDocuments: true}, options) return _dataRequest(client, httpRequest, 'mutate', {mutations: [mutation]}, opts) } /** * Promise-based sibling of {@link _actionObservable}. * * @internal */ export function _action( client: Client, httpRequest: HttpRequest, actions: Action | Action[], options?: BaseActionOptions, ): Promise<SingleActionResult | MultipleActionResult> { const acts = Array.isArray(actions) ? actions : [actions] const transactionId = (options && options.transactionId) || undefined const skipCrossDatasetReferenceValidation = (options && options.skipCrossDatasetReferenceValidation) || undefined const dryRun = (options && options.dryRun) || undefined return _dataRequest( client, httpRequest, 'actions', {actions: acts, transactionId, skipCrossDatasetReferenceValidation, dryRun}, options, ) } /** * Promise-based sibling of {@link _mutateObservable}. * * @internal */ export function _mutate<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, mutations: Mutation<R>[] | Patch | ObservablePatch | Transaction | ObservableTransaction, options?: | AllDocumentIdsMutationOptions | AllDocumentsMutationOptions | BaseMutationOptions | FirstDocumentIdMutationOptions | FirstDocumentMutationOptions, ): Promise< SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult > { let mut: Mutation | Mutation[] if (mutations instanceof Patch || mutations instanceof ObservablePatch) { mut = {patch: mutations.serialize()} } else if (mutations instanceof Transaction || mutations instanceof ObservableTransaction) { mut = mutations.serialize() } else { mut = mutations } const muts = Array.isArray(mut) ? mut : [mut] const transactionId = (options && options.transactionId) || undefined return _dataRequest(client, httpRequest, 'mutate', {mutations: muts, transactionId}, options) } /** * Promise-based sibling of {@link _deleteObservable}. * * @internal */ export function _delete<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, selection: string | MutationSelection, options?: | AllDocumentIdsMutationOptions | AllDocumentsMutationOptions | BaseMutationOptions | FirstDocumentIdMutationOptions | FirstDocumentMutationOptions, ): Promise< SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult > { return _dataRequest( client, httpRequest, 'mutate', {mutations: [{delete: getSelection(selection)}]}, options, ) } /** * Promise-based sibling of {@link _createIfNotExistsObservable}. * * @internal */ export function _createIfNotExists<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, doc: IdentifiedSanityDocumentStub<R>, options?: | AllDocumentIdsMutationOptions | AllDocumentsMutationOptions | BaseMutationOptions | FirstDocumentIdMutationOptions | FirstDocumentMutationOptions, ): Promise< SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult > { validators.requireDocumentId('createIfNotExists', doc) return _create<R>(client, httpRequest, doc, 'createIfNotExists', options) } /** * Promise-based sibling of {@link _createOrReplaceObservable}. * * @internal */ export function _createOrReplace<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, doc: IdentifiedSanityDocumentStub<R>, options?: | AllDocumentIdsMutationOptions | AllDocumentsMutationOptions | BaseMutationOptions | FirstDocumentIdMutationOptions | FirstDocumentMutationOptions, ): Promise< SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult > { validators.requireDocumentId('createOrReplace', doc) return _create<R>(client, httpRequest, doc, 'createOrReplace', options) } /** * Promise-based sibling of {@link _createVersionObservable}. * * @internal */ export function _createVersion<R extends Record<string, Any>>( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, doc: IdentifiedSanityDocumentStub<R>, publishedId: string, options?: BaseActionOptions, ): Promise<SingleActionResult> { validators.requireDocumentId('createVersion', doc) validators.requireDocumentType('createVersion', doc) printCreateVersionWithBaseIdWarning() const createVersionAction: CreateVersionAction = { actionType: 'sanity.action.document.version.create', publishedId, document: doc, } return _action(client, httpRequest, createVersionAction, options) } /** * Promise-based sibling of {@link _createVersionFromBaseObservable}. * * @internal */ export function _createVersionFromBase( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, publishedId?: string, baseId?: string, releaseId?: string, ifBaseRevisionId?: string, options?: BaseActionOptions, ): Promise<SingleActionResult> { if (!baseId) { throw new Error('`createVersion()` requires `baseId` when no `document` is provided') } if (!publishedId) { throw new Error('`createVersion()` requires `publishedId` when `baseId` is provided') } validators.validateDocumentId('createVersion', baseId) validators.validateDocumentId('createVersion', publishedId) const createVersionAction: CreateVersionAction = { actionType: 'sanity.action.document.version.create', publishedId, baseId, versionId: releaseId ? getVersionId(publishedId, releaseId) : getDraftId(publishedId), ifBaseRevisionId, } return _action(client, httpRequest, createVersionAction, options) } /** * Promise-based sibling of {@link _discardVersionObservable}. * * @internal */ export function _discardVersion( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, versionId: string, purge: boolean = false, options?: BaseActionOptions, ): Promise<SingleActionResult> { const discardVersionAction: DiscardVersionAction = { actionType: 'sanity.action.document.version.discard', versionId, purge, } return _action(client, httpRequest, discardVersionAction, options) } /** * Promise-based sibling of {@link _replaceVersionObservable}. * * @internal */ export function _replaceVersion<R extends Record<string, Any>>( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, doc: IdentifiedSanityDocumentStub<R>, options?: BaseActionOptions, ): Promise<SingleActionResult> { validators.requireDocumentId('replaceVersion', doc) validators.requireDocumentType('replaceVersion', doc) const replaceVersionAction: ReplaceVersionAction = { actionType: 'sanity.action.document.version.replace', document: doc, } return _action(client, httpRequest, replaceVersionAction, options) } /** * Promise-based sibling of {@link _unpublishVersionObservable}. * * @internal */ export function _unpublishVersion( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, versionId: string, publishedId: string, options?: BaseActionOptions, ): Promise<SingleActionResult> { const unpublishVersionAction: UnpublishVersionAction = { actionType: 'sanity.action.document.version.unpublish', versionId, publishedId, } return _action(client, httpRequest, unpublishVersionAction, options) } const hasDataConfig = (client: Client) => { const config = client.config() return ( (config.dataset !== undefined && config.projectId !== undefined) || config.resource !== undefined ) } const isQuery = (client: Client, uri: string) => hasDataConfig(client) && uri.startsWith(_getDataUrl(client, 'query')) const isMutate = (client: Client, uri: string) => hasDataConfig(client) && uri.startsWith(_getDataUrl(client, 'mutate')) const isDoc = (client: Client, uri: string) => hasDataConfig(client) && uri.startsWith(_getDataUrl(client, 'doc', '')) const isListener = (client: Client, uri: string) => hasDataConfig(client) && uri.startsWith(_getDataUrl(client, 'listen')) const isHistory = (client: Client, uri: string) => hasDataConfig(client) && uri.startsWith(_getDataUrl(client, 'history', '')) const isData = (client: Client, uri: string) => uri.startsWith('/data/') || isQuery(client, uri) || isMutate(client, uri) || isDoc(client, uri) || isListener(client, uri) || isHistory(client, uri) /** * Build the final request options (URL, headers, query params, etc.) used by * both the regular request pipeline and the asset upload path. * * @internal */ export function _prepareRequest(client: Client, options: RequestObservableOptions): FetchRequest { if (options.uri !== undefined) { printDeprecatedUriOptionWarning() } // `uri` takes precedence over `url` to match the v8 behaviour this alias // restores. The types make passing both an error, so this only matters to // untyped callers. const uri = options.uri || options.url if (typeof uri !== 'string') { throw new TypeError('Request options must include a `url`') } const config = client.config() // If the `canUseCdn`-option is not set we detect it automatically based on the method + URL. // Only the /data endpoint is currently available through API-CDN. const canUseCdn = typeof options.canUseCdn === 'undefined' ? ['GET', 'HEAD'].indexOf(options.method || 'GET') >= 0 && isData(client, uri) : options.canUseCdn let useCdn = (options.useCdn ?? config.useCdn) && canUseCdn const tag = options.tag && config.requestTagPrefix ? [config.requestTagPrefix, options.tag].join('.') : options.tag || config.requestTagPrefix if (tag && options.tag !== null) { options.query = {tag: validate.requestTag(tag), ...options.query} } // GROQ query-only parameters if (['GET', 'HEAD', 'POST'].indexOf(options.method || 'GET') >= 0 && isQuery(client, uri)) { const resultSourceMap = options.resultSourceMap ?? config.resultSourceMap if (resultSourceMap !== undefined && resultSourceMap !== false) { options.query = {resultSourceMap, ...options.query} } const perspectiveOption = options.perspective || config.perspective if (typeof perspectiveOption !== 'undefined') { if (perspectiveOption === 'previewDrafts') { printPreviewDraftsDeprecationWarning() } validateApiPerspective(perspectiveOption) options.query = { perspective: Array.isArray(perspectiveOption) ? perspectiveOption.join(',') : perspectiveOption, ...options.query, } // If the perspective is set to `drafts` or multiple perspectives we can't use the CDN, the API will throw if ( ((Array.isArray(perspectiveOption) && perspectiveOption.length > 0) || // previewDrafts was renamed to drafts, but keep for backwards compat perspectiveOption === 'previewDrafts' || perspectiveOption === 'drafts') && useCdn ) { useCdn = false printCdnPreviewDraftsWarning() } } const variantOption = options.variant || config.variant if (typeof variantOption === 'string') { options.query = { variant: variantOption, ...options.query, } } if (typeof variantOption === 'object') { options.query = { variantCondition: variantConditionsToQueryArray(variantOption), ...options.query, } } if (options.lastLiveEventId) { options.query = {...options.query, lastLiveEventId: options.lastLiveEventId} } if (options.returnQuery === false) { options.query = {returnQuery: 'false', ...options.query} } if (useCdn && options.cacheMode == 'noStale') { options.query = {cacheMode: 'noStale', ...options.query} } } return requestOptions( config, Object.assign({}, options, { url: _getUrl(client, uri, useCdn), }), ) } /** * Wrap a promise-returning request in a cold, single-value Observable. * * Each subscription invokes `run` with a fresh `AbortSignal` that is aborted * when the subscriber unsubscribes — so unsubscribing cancels the in-flight * `fetch`. A caller-supplied `userSignal` is chained in too, so the request * still aborts when the caller's own signal fires. The single resolved value * (or rejection) is forwarded to the subscriber. * * This is the bridge that lets the observable client surface reuse the exact * same promise implementations as the promise client surface, with no * duplicated request logic. * * @internal */ export function _observe<R>( userSignal: AbortSignal | undefined, run: (signal: AbortSignal) => Promise<R>, ): Observable<R> { return new Observable<R>((subscriber) => { const controller = new AbortController() // `AbortSignal.any` rather than an `addEventListener` on the caller's // signal: that signal can be long-lived and reused across many requests, // and a listener per subscription would accumulate there (`{once: true}` // only cleans up if the signal actually fires). const signal = userSignal ? AbortSignal.any([userSignal, controller.signal]) : controller.signal run(signal).then( (value) => { subscriber.next(value) subscriber.complete() }, (err) => subscriber.error(err), ) return () => controller.abort() }) } /** * Promise-based sibling of {@link _requestObservable}. Resolves directly to * the parsed response body without ever constructing an Observable. The abort * signal is honored by the underlying `fetch` (it is threaded onto the request * options), so no RxJS-level teardown is required. * * @internal */ export function _request<R>(client: Client, httpRequest: HttpRequest, options: Any): Promise<R> { const reqOptions = _prepareRequest(client, options) return httpRequest(reqOptions, client.config().requestHandler).then((body) => body as R) } /** * Execute an HTTP request through the regular pipeline and resolve to the * parsed response body. Observable wrapper over {@link _request}. * * @internal */ export function _requestObservable<R>( client: Client, httpRequest: HttpRequest, options: Any, ): Observable<R> { return _observe(options.signal, (signal) => _request<R>(client, httpRequest, {...options, signal}), ) } /** * Execute an asset upload through the underlying requester directly, * exposing the full upload event stream (progress + response). * * Bypasses the body-only `HttpRequest` boundary so progress events from the * transport layer can surface to consumers, reading the resolved requester * straight off the initialized config. * * @internal */ export function _uploadObservable<T>( client: Client, options: RequestObservableOptions, ): Observable<UploadEvent<T>> { const reqOptions = _prepareRequest(client, options) const requester = client.config().requester const request = new Observable<Any>((subscriber) => requester(reqOptions).subscribe(subscriber), ).pipe( filter((event: Any) => event?.type === 'progress' || event?.type === 'response'), map((event: Any): UploadEvent<T> => event.type === 'progress' ? { type: 'progress', stage: event.stage, percent: event.percent, total: event.total, loaded: event.loaded, lengthComputable: event.lengthComputable, } : {type: 'response', body: event.body as T}, ), ) return options.signal ? request.pipe(_withAbortSignal(options.signal)) : request } /** * @internal */ export function _getDataUrl(client: Client, operation: string, path?: string): string { const config = client.config() const resource = config.resource if (resource) { validators.resourceConfig(config) const resourceBase = resourceDataBase(config) const uri = path !== undefined ? `${operation}/${path}` : operation return `${resourceBase}/${uri}`.replace(/\/($|\?)/, '$1') } const catalog = validators.hasDataset(config) const baseUri = `/${operation}/${catalog}` const uri = path !== undefined ? `${baseUri}/${path}` : baseUri return `/data${uri}`.replace(/\/($|\?)/, '$1') } /** * @internal */ export function _getUrl(client: Client, uri: string, canUseCdn = false): string { const {url, cdnUrl} = client.config() const base = canUseCdn ? cdnUrl : url return `${base}/${uri.replace(/^\//, '')}` } /** * @internal */ function _withAbortSignal<T>(signal: AbortSignal): MonoTypeOperatorFunction<T> { return (input) => { return new Observable((observer) => { const abort = () => observer.error(_createAbortError(signal)) if (signal && signal.aborted) { abort() return } const subscription = input.subscribe(observer) signal.addEventListener('abort', abort) return () => { signal.removeEventListener('abort', abort) subscription.unsubscribe() } }) } } /** * `DOMException` is globally available in every supported runtime * (Node 22.12+ and all modern browsers), so we can construct one directly. * * @internal */ function _createAbortError(signal?: AbortSignal): DOMException { return new DOMException(signal?.reason ?? 'The operation was aborted.', 'AbortError') } const resourceDataBase = (config: InitializedClientConfig): string => { const resource = config.resource if (!resource) { throw new Error('`resource` must be provided to perform resource queries') } const {type, id} = resource switch (type) { case 'dataset': { const segments = id.split('.') if (segments.length !== 2) { throw new Error('Dataset ID must be in the format "project.dataset"') } return `/projects/${segments[0]}/datasets/${segments[1]}` } case 'canvas': { return `/canvases/${id}` } case 'knowledge-base': { return `/knowledge-bases/${id}` } case 'media-library': { return `/media-libraries/${id}` } case 'dashboard': { return `/dashboards/${id}` } default: // @ts-expect-error - handle all supported resource types throw new Error(`Unsupported resource type: ${type.toString()}`) } } function variantConditionsToQueryArray( variantConditions: ClientVariantConditions, ): `${string}:${string}`[] { return Object.entries(variantConditions) .map<`${string}:${string}`>(([condition, value]) => `${condition}:${value}`) .toSorted() }