import _ from 'lodash'; import https from 'https'; import path from 'path'; import fse from 'fs-extra'; import { glob } from 'glob'; import { MutationEvent, SanityAssetDocument, SanityClient as SanityClientType, SanityDocument, SanityDocumentStub } from '@sanity/client'; import type * as ContentSourceTypes from '@stackbit/types'; import type { DocumentVersion, DocumentVersionWithDocument, Field, Model, Schema, UserCommandSpawner } from '@stackbit/types'; import { getVersion as stackbitUtilsGetVersion } from '@stackbit/types'; import type * as SanityTypes from '@sanity/types'; import { deferWhileRunning, getPackageManager, omitByNil } from '@stackbit/utils'; import * as fetcher from './sanity-schema-fetcher'; import { DocumentHistory, DocumentHistoryMap, fetchDocumentForRevision, fetchDocumentRevision, fetchDocumentsHistory, fetchScheduledActions, fetchUsers, SanityClient, SanityUser, testToken } from './sanity-api-client'; import { convertSchema, ModelContext, SchemaContext } from './sanity-schema-converter'; import { AssetContext, ContextualAsset, ContextualDocument, convertAndFilterScheduledActions, convertAssets, convertDocuments, ConvertDocumentsOptions, convertScheduledAction, DocumentContext, DRAFT_ID_PREFIX, getDraftObjectId, getPureObjectId, isDraftId } from './sanity-document-converter'; import { isLocalizedModelField } from './utils'; import { convertUpdateOperation, localizedValue, mapUpdateOperationFieldToSanityValue } from './sanity-operation-converter'; export interface ContentSourceOptions { /** * The root path of the project. Used to resolve relative paths. */ rootPath: string; /** * Sanity project id */ projectId: string; /** * Sanity token with access to the project. * For local development, use a token with read and write access. */ token: string; /** * Sanity dataset name. For exampe: 'production' */ dataset: string; /** * Sanity studio url. Used for deep linking to documents. */ studioUrl: string; /** * Sanity studio path. For example: 'studio'. * Usually the directory that contains your Sanity config file. */ studioPath?: string; /** * The command to use to install the Sanity studio. For example: 'npm install'. * By default we detect the package manager and use it to install the studio. */ studioInstallCommand?: string; /** * Glob pattern for Sanity schema files. Used to determine when a file changed schema has changed. */ schemaGlob?: string; /** * Sanity query used to listen for document changes. For example: '*[!(_id in path("_.**"))]' */ sanityQuery?: string; /** * The content change listener visibility. Defaults to 'transaction'. * 'transaction' - the listener will be notified of changes after the transaction is committed (fastest). * 'query' - the listener will be notified of changes when they are available for querying. */ listenerVisibility?: 'transaction' | 'query'; /** * When using Sanity Internationalized-Array plugin, specify the default language * that will be used by the Visual Editor. If not specified, the first language * in the `languages` will be selected. * * https://www.sanity.io/plugins/internationalized-array */ defaultLocale?: string; } export type UserContext = { accessToken: string; }; export type ContextualInitOptions = ContentSourceTypes.InitOptions; export type CacheWithContext = ContentSourceTypes.Cache; type ContentChanges = Required>; const ASSET_TYPES = ['sanity.imageAsset', 'cloudinary.asset', 'bynder.asset', 'aprimo.cdnasset']; const SANITY_API_VERSION = '1'; export class SanityContentSource implements ContentSourceTypes.ContentSourceInterface { private readonly projectId: string; private readonly dataset: string; private readonly token: string; private readonly studioPath?: string; private readonly studioInstallCommand?: string; private readonly schemaGlob?: string; private readonly studioUrl: string; private readonly sanityQuery: string; private readonly rootPath: string; private readonly listenerVisibility?: 'transaction' | 'query'; logger!: ContentSourceTypes.Logger; userLogger!: ContentSourceTypes.Logger; private localDev!: boolean; private runCommand!: ContentSourceTypes.CommandRunner; private userCommandSpawner?: UserCommandSpawner; private client!: SanityClientType; private contentChangeSubscription: any; private contentChangeSubscriptionInterval: any; private userMap: Record = {}; private cache!: CacheWithContext; private defaultLocale?: string; constructor(options: ContentSourceOptions) { this.projectId = options.projectId; this.dataset = options.dataset; this.token = options.token; this.studioUrl = options.studioUrl; this.sanityQuery = options.sanityQuery || '*[!(_id in path("_.**"))]'; this.rootPath = options.rootPath; this.studioPath = options.studioPath; this.studioInstallCommand = options.studioInstallCommand; this.schemaGlob = options.schemaGlob; this.listenerVisibility = options.listenerVisibility; this.defaultLocale = options.defaultLocale; if (!this.rootPath) { throw new Error(`Required parameter 'rootPath' is missing.`); } this.convertListenerResult = deferWhileRunning(this.convertListenerResult, { thisArg: this, debounceDelay: 50, debounceMaxDelay: 500, argsResolver: ({ nextArgs, prevArgs }) => { const [events] = nextArgs; const [prevEvents] = prevArgs ?? [[]]; return [[...prevEvents, ...events].filter(Boolean)] as typeof nextArgs; } }); // Normalize paths to be absolute this.studioPath = !this.studioPath || path.isAbsolute(this.studioPath) ? this.studioPath : path.join(this.rootPath, this.studioPath); } async getVersion(): Promise { return stackbitUtilsGetVersion({ packageJsonPath: path.join(__dirname, '../package.json') }); } getContentSourceType(): string { return 'sanity'; } getProjectId(): string { return `${this.projectId}:${this.dataset}`; } getProjectEnvironment(): string { return this.dataset; } getProjectManageUrl(): string { return this.studioUrl; } async init({ logger, userLogger, localDev, runCommand, userCommandSpawner, cache }: ContextualInitOptions): Promise { this.logger = logger.createLogger({ label: 'cms-sanity' }); this.userLogger = userLogger.createLogger({ label: 'cms-sanity' }); this.localDev = localDev; this.runCommand = runCommand; this.userCommandSpawner = userCommandSpawner; this.cache = cache; this.logger.debug('init', { localDev }); this.client = new SanityClient({ projectId: this.projectId, dataset: this.dataset, token: this.token, useCdn: false, apiVersion: SANITY_API_VERSION }); await this.validateConfig(); if (!localDev) { await this.installStudio(); } await this.reset(); } async reset() { this.logger.debug('reset'); } async destroy() { this.logger.debug('destroy'); } async validateConfig() { this.logger.debug('Validating config...'); if (this.studioPath && !(await fse.pathExists(this.studioPath))) { throw new Error(`Can't find Sanity Studio in ${this.studioPath}. Verify that the studio path is pointing to the right place.`); } if (_.isEmpty(this.token)) { throw new Error(`Please provide a Sanity token.`); } try { await this.client.fetch(`*[_type == 'system.group'] {_id}`); } catch (err: any) { if (err?.response?.body?.error === 'Dataset not found') { throw new Error(`Can't find specified Sanity dataset '${this.dataset}'. Verify that the dataset is pointing to an existing Sanity dataset.`); } else { throw new Error(`Can't connect to Sanity. Verify that the token has access to the Sanity project defined.`); } } } async installStudio() { if (this.studioInstallCommand) { this.userLogger.info('Installing Sanity Studio using custom command'); await this.runCommand(this.studioInstallCommand, [], { shell: true, cwd: this.studioPath ?? this.rootPath, logger: this.userLogger.createLogger({ label: 'sanity-studio-install' }) }); } else { if (!this.studioPath || this.studioPath === this.rootPath) { this.logger.debug('Studio path is the same as root path. Skipping studio installation.'); return; } const packageManager = await getPackageManager(this.studioPath); if (!packageManager) { throw new Error("No package manager detected. Can't install Sanity Studio."); } this.userLogger.info(`Installing Sanity Studio using ${packageManager.name}`); await this.runCommand(packageManager.cmd, packageManager.args, { env: packageManager.env, cwd: this.studioPath, logger: this.userLogger.createLogger({ label: 'sanity-studio-install' }) }); } } startWatchingContentUpdates() { this.logger.debug('startWatchingContentUpdates'); const createSanityListener = () => { //TODO Sanity types for listener visibility are broken in this @sanity/client version return this.client.listen(this.sanityQuery, {}, { visibility: this.listenerVisibility === 'query' ? 'query' : undefined }).subscribe({ next: async (event) => { if (event.type !== 'mutation') { return; } if (_.startsWith(event.documentId, '_.') || event.identity === '' || _.startsWith(event.result?.resultType, 'system.')) { return; } this.logger.debug('SanityListener: content changed', { event: event?.transition, transactionId: event?.transactionId, eventId: event?.eventId, documentId: event?.documentId }); await this.convertListenerResult([event]).catch((err) => { this.logger.error('SanityListener: convertListenerResult threw an error', err); }); this.logger.debug('SanityListener: content changed handler done', { event: event?.transition, transactionId: event?.transactionId, eventId: event?.eventId, documentId: event?.documentId }); }, error: (error) => { this.logger.error('SanityListener: threw error', error); }, complete: () => { this.logger.debug('SanityListener: complete received'); } }); }; if (this.contentChangeSubscription) { this.stopWatchingContentUpdates(); } this.contentChangeSubscription = createSanityListener(); this.contentChangeSubscriptionInterval = setInterval(() => { const newListener = createSanityListener(); this.contentChangeSubscription.unsubscribe(); this.contentChangeSubscription = newListener; }, 1000 * 60 * 20); } stopWatchingContentUpdates() { this.logger.debug('stopWatchingContentUpdates'); if (this.contentChangeSubscription) { this.logger.debug('stopping content change listener'); this.contentChangeSubscription.unsubscribe(); this.contentChangeSubscription = null; clearInterval(this.contentChangeSubscriptionInterval); this.contentChangeSubscriptionInterval = null; } } private async convertListenerResult(events: MutationEvent[]): Promise { const result: ContentChanges = { documents: [], assets: [], scheduledActions: [], deletedDocumentIds: [], deletedAssetIds: [], deletedScheduledActionIds: [] }; const logContext = { event: events?.map((event) => event?.transition)?.join(','), transactionId: events?.map((event) => event?.transactionId)?.join(','), eventId: events?.map((event) => event?.eventId)?.join(','), documentId: events?.map((event) => event?.documentId)?.join(',') }; this.logger.debug('SanityListener: content changed, convert started', logContext); // fetch document history once for all events const documentIds = _.uniq( events.filter((event) => event.transition !== 'disappear' && event.result && isDraftId(event.documentId)).map((event) => event.documentId) ).filter(Boolean) as string[]; const documentsHistory = await this.getDocumentsHistory(documentIds); // fetch updated scheduled actions after a second delay to allow sanity to update the status of any executed scheduled action const pureDocumentIds = _.uniq(events.map((event) => getPureObjectId(event.documentId))).filter(Boolean); setTimeout(async () => { try { const sanitySchedules = await fetchScheduledActions({ projectId: this.projectId, dataset: this.dataset }, this.client, { documentIds: pureDocumentIds }); const remoteScheduledActions = convertAndFilterScheduledActions(sanitySchedules); const cachedScheduledActions = pureDocumentIds.flatMap((documentId) => this.cache.getScheduledActionsForDocumentId(documentId)); const updatedActions = _.differenceWith(remoteScheduledActions, cachedScheduledActions, _.isEqual); if (updatedActions.length) { this.logger.debug('SanityListener: Scheduled actions changed, updating', { updatedSchedulesCount: updatedActions.length }); this.cache.updateContent({ scheduledActions: updatedActions }); } } catch (err) { this.logger.debug('SanityListener: Failed to fetch Scheduled actions for updated documents', { ...logContext, error: err }); } }, 1000); // save local cache of documents and assets to be reused between events const documentMap: Record = {}; const assetMap: Record = {}; for (const event of events) { const pureObjectId = getPureObjectId(event.documentId); const document = documentMap[pureObjectId] || this.cache.getDocumentById(pureObjectId); const asset = this.cache.getAssetById(pureObjectId); const isDraft = isDraftId(event.documentId); // cleanup deleted ids - either prevents duplicates or removes ids that are no longer deleted const existingDeletedDocumentIdIndex = result.deletedDocumentIds.indexOf(pureObjectId); if (existingDeletedDocumentIdIndex >= 0) { result.deletedDocumentIds = result.deletedDocumentIds.splice(existingDeletedDocumentIdIndex, 1); } const existingDeletedAssetIdIndex = result.deletedAssetIds.indexOf(pureObjectId); if (existingDeletedAssetIdIndex >= 0) { result.deletedAssetIds = result.deletedAssetIds.splice(existingDeletedAssetIdIndex, 1); } if (event.transition === 'disappear') { if (document) { const { context } = document; if ((isDraft && !context.publishedDocument) || (!isDraft && !context.draftDocument)) { // notify deleted only if already in content cache if (this.cache.getDocumentById(pureObjectId)) { result.deletedDocumentIds.push(pureObjectId); } // remove now-deleted document from result if (documentMap[pureObjectId]) { delete documentMap[pureObjectId]; } } else { const documents = [isDraft ? context.publishedDocument! : context.draftDocument!]; const [convertedDocuments] = this.convertDocuments({ documents, getModelByName: this.cache.getModelByName, studioUrl: this.studioUrl, documentsHistory }); if (convertedDocuments) { documentMap[pureObjectId] = convertedDocuments; } } } else if (asset) { result.deletedAssetIds.push(pureObjectId); if (assetMap[pureObjectId]) { delete assetMap[pureObjectId]; } } } if (event.result) { const publishedDocument = isDraft ? document?.context.publishedDocument : event.result; const draftDocument = isDraft ? event.result : document?.context.draftDocument; const documents = [publishedDocument, draftDocument].filter(Boolean) as SanityDocument[]; if (ASSET_TYPES.includes(event.result._type)) { const convertedAssets = convertAssets({ assets: documents, documentsHistory }); assetMap[pureObjectId] = convertedAssets[0]!; } else { const [convertedDocuments] = this.convertDocuments({ documents: documents, getModelByName: this.cache.getModelByName, studioUrl: this.studioUrl, documentsHistory }); if (convertedDocuments) { documentMap[pureObjectId] = convertedDocuments; } } } } result.documents = Object.values(documentMap); result.assets = Object.values(assetMap); this.logger.debug('SanityListener: content changed, convert done', logContext); return this.cache.updateContent(result); } private convertVersionsFromDocumentHistory(versions: DocumentHistory[]): DocumentVersion[] { return versions.map((version) => { return { id: version.id, documentId: getPureObjectId(version.documentIDs[0]!), srcType: this.getContentSourceType(), srcProjectId: this.projectId, createdAt: version.timestamp, createdBy: this.userMap[version.author]?.email }; }); } private convertVersionForDocument(version: DocumentHistory, document: ContextualDocument): DocumentVersionWithDocument { const [documentVersion] = this.convertVersionsFromDocumentHistory([version]); if (!documentVersion) { throw new Error('Document version could not be converted'); } return { ...documentVersion, document }; } async getSanitySchema(): Promise<{ models: SanityTypes.SchemaTypeDefinition[] }> { let sanitySchema; try { sanitySchema = await fetcher.spawnFetchSchema({ studioPath: this.studioPath ?? this.rootPath, repoPath: this.rootPath, spawnRunner: this.userCommandSpawner, logger: this.logger }); } catch (err) { if (!_.isEmpty(err) && _.isString(err)) { this.userLogger.info(err); } this.userLogger.error('Error fetching Sanity schema'); throw err; } return sanitySchema; } async getSchema(): Promise> { this.logger.debug('getSchema'); const sanitySchema = await this.getSanitySchema(); const { models, locales } = convertSchema({ schema: sanitySchema, logger: this.logger, defaultLocale: this.defaultLocale }); return { models, locales, context: null }; } async getDocumentsHistory(documentIds: string[]): Promise { this.logger.debug('getDocumentsHistory started', documentIds.length); const draftDocumentIds = documentIds.filter((documentId) => isDraftId(documentId)); const historyData = await fetchDocumentsHistory({ documentIds: draftDocumentIds, dataset: this.dataset, client: this.client }); const notCachedDocAuthors = historyData.reduce((acc: string[], doc: DocumentHistory) => { const user = this.userMap[doc.author]; if (!user && !acc.includes(doc.author) && doc.author !== 'system' && doc.author !== '') { acc.push(doc.author); } return acc; }, []); if (notCachedDocAuthors.length) { try { const authors = await fetchUsers(notCachedDocAuthors, this.client); for (const author of authors) { this.userMap[author.id] = author; } } catch (err) { this.logger.error('getDocumentsHistory error fetching authors', err); this.userLogger.warn('Error fetching users from Sanity. Make sure the Sanity token has the appropriate permissions.'); } } this.logger.debug('getDocumentsHistory done fetch'); return draftDocumentIds.reduce((acc: DocumentHistoryMap, documentId: string) => { const docHistory = historyData.filter(({ documentIDs }: DocumentHistory) => documentIDs.includes(documentId)); const docHistoryWithAuthor = []; for (const history of docHistory) { const author = this.userMap[history.author]; if (!author) { this.logger.debug(`getDocumentsHistory author is missing in userMap`, { authorId: history.author }); continue; } if (author.email) { docHistoryWithAuthor.push({ ...history, author: author.email }); } } if (docHistoryWithAuthor.length) { acc[documentId] = docHistoryWithAuthor; } return acc; }, {}); } // centralized place that handles document conversion to allow easy content source extension convertDocuments(options: ConvertDocumentsOptions) { return convertDocuments(options); } async getDocuments(): Promise { this.logger.debug('getDocuments'); const documents: SanityDocument[] = await this.client.fetch(this.sanityQuery); this.logger.debug(`fetched ${documents.length} entries from project ${this.projectId} and dataset ${this.dataset}`); const documentsHistory = await this.getDocumentsHistory(documents.map((document) => document._id)); return this.convertDocuments({ documents: documents.filter((document: SanityDocument) => !ASSET_TYPES.includes(document._type)), getModelByName: this.cache.getModelByName, documentsHistory, studioUrl: this.studioUrl }); } async getAssets() { const documents: SanityDocument[] = await this.client.fetch('*[_type in $types]', { types: ASSET_TYPES }); this.logger.debug(`fetched ${documents.length} assets from project ${this.projectId} and dataset ${this.dataset}`); const documentsHistory = await this.getDocumentsHistory(documents.map((document) => document._id)); return convertAssets({ assets: documents.filter((document: SanityDocument) => ASSET_TYPES.includes(document._type)), documentsHistory }); } async hasAccess({ userContext }: { userContext?: UserContext }): Promise<{ hasConnection: boolean; hasPermissions: boolean }> { if (!this.localDev) { if (!userContext?.accessToken) { return { hasConnection: false, hasPermissions: false }; } let tokenExpired = true; try { const testTokenResult = await testToken(userContext.accessToken); const accessTokenExpiresAt = testTokenResult?.accessTokenExpiresAt; if (accessTokenExpiresAt) { const expiresAt = new Date(accessTokenExpiresAt); tokenExpired = new Date().getTime() - expiresAt.getTime() >= 0; } } catch (e) { // pass } if (tokenExpired) { return { hasConnection: false, hasPermissions: false }; } } try { const userClient = this.getApiClientForUser({ userContext }); await userClient.fetch(`*[_type == 'system.group'] {_id}`); return { hasConnection: true, hasPermissions: true }; } catch (error) { this.logger.debug('Sanity: failed to access project', { error }); return { hasConnection: true, hasPermissions: false }; } } async onFilesChange({ updatedFiles }: { updatedFiles: string[] }) { let invalidateSchema = false; if (this.studioPath) { const relStudioPath = path.relative(this.rootPath, this.studioPath); const sanityPackageJson = path.join(relStudioPath, 'package.json'); const needsStudioInstall = _.some(updatedFiles, (updatedFile) => updatedFile === sanityPackageJson); invalidateSchema = _.some(updatedFiles, (updatedFile) => updatedFile.startsWith(relStudioPath)); if (needsStudioInstall) { this.logger.debug('Sanity Studio package.json changed, reinstalling...'); await this.installStudio(); } } if (this.schemaGlob) { const existingSchemaFiles = await glob(this.schemaGlob, { cwd: this.rootPath, absolute: false }); invalidateSchema = !_.isEmpty(_.intersection(updatedFiles, existingSchemaFiles)); } return { invalidateSchema }; } async createDocument({ updateOperationFields, model, locale, userContext }: { updateOperationFields: Record; model: Model; locale?: string; userContext?: UserContext; }): Promise<{ documentId: string }> { const sanityDocument: SanityDocumentStub = { _id: DRAFT_ID_PREFIX, _type: model.name }; try { _.forEach(updateOperationFields, (updateOperationField, fieldName) => { const childModelField = _.find(model.fields, (field: Field) => field.name === fieldName); if (!childModelField) { throw new Error(`No model field found for field: ${fieldName}`); } const value = mapUpdateOperationFieldToSanityValue({ updateOperationField, getModelByName: this.cache.getModelByName, modelField: childModelField, rootModel: model, modelFieldPath: [fieldName], locale }); if (isLocalizedModelField(childModelField)) { _.set(sanityDocument, fieldName, [ localizedValue({ value, model, modelFieldPath: [fieldName], locale }) ]); } else { _.set(sanityDocument, fieldName, value); } }); const userClient = this.getApiClientForUser({ userContext }); const result = await userClient.create(sanityDocument); const pureObjectId = getPureObjectId(result._id); return { documentId: pureObjectId }; } catch (error: any) { this.logger.error('Error creating document', error); throw new Error(`Error creating document. ${error.message}`); } } async updateDocument({ document, operations, userContext }: { document: ContextualDocument; operations: ContentSourceTypes.UpdateOperation[]; userContext?: UserContext; }): Promise { this.logger.debug('updateDocument'); const documentId = document.id; const userClient = this.getApiClientForUser({ userContext }); const draftObjectId = getDraftObjectId(documentId); const sanityDocument = (document.context.draftDocument || document.context.publishedDocument)!; const modelName = document.modelName; const model = this.cache.getModelByName(modelName); try { if (!model) { throw new Error(`Could not find document model '${modelName}'.`); } const transaction = _.reduce( operations, (transaction, operation) => { const patchObject = convertUpdateOperation({ operation, sanityDocument, getModelByName: this.cache.getModelByName, model }); return transaction.patch(draftObjectId, patchObject); }, userClient.transaction().createIfNotExists({ ...sanityDocument, _id: draftObjectId }) ); await transaction.commit({ visibility: this.listenerVisibility === 'query' ? 'sync' : 'async', returnDocuments: true }); } catch (error: any) { this.logger.error(`Error updating document ${document.id}`, error); throw new Error(`Error updating document ${document.id}. ${error.message}`); } } async deleteDocument({ document, userContext }: { document: ContextualDocument; userContext?: UserContext }): Promise { const userClient = this.getApiClientForUser({ userContext }); const draftObjectId = getDraftObjectId(document.id); const pureObjectId = getPureObjectId(document.id); await Promise.all([userClient.delete(pureObjectId), userClient.delete(draftObjectId)]); } async uploadAsset({ url, base64, fileName, mimeType, userContext }: { url?: string; base64?: string; fileName: string; mimeType: string; userContext?: UserContext; }): Promise { const userClient = this.getApiClientForUser({ userContext }); const assetType = mimeType.startsWith('image/') ? 'image' : 'file'; let uploadResult: SanityAssetDocument; if (url) { uploadResult = await new Promise((resolve, reject) => { https.get(url, (downloadStream: any) => { userClient.assets.upload(assetType, downloadStream, { filename: fileName }).then(resolve).catch(reject); }); }); } else { const buffer = Buffer.from(base64!, 'base64'); uploadResult = await userClient.assets.upload(assetType, buffer, { filename: fileName }); } const documentsHistory = await this.getDocumentsHistory([uploadResult._id]); const assets = convertAssets({ assets: [uploadResult!], documentsHistory }); return assets[0]!; } async validateDocuments({ documents, assets, locale, userContext }: { documents: ContextualDocument[]; assets: ContextualAsset[]; locale?: string; userContext?: UserContext; }): Promise<{ errors: ContentSourceTypes.ValidationError[] }> { return { errors: [] }; } async publishDocuments({ documents, assets, userContext }: { documents: ContextualDocument[]; assets: ContextualAsset[]; userContext?: UserContext; }): Promise { const userClient = this.getApiClientForUser({ userContext }); const publishedDocumentIds = documents.map((document) => getPureObjectId(document.id)); const draftDocumentIds = publishedDocumentIds.map((documentId) => getDraftObjectId(documentId)); const query = '*[ _id in $documentIds ]'; const sanityDocuments: SanityDocument[] = await userClient.fetch(query, { documentIds: [...draftDocumentIds, ...publishedDocumentIds] }); const draftDocuments = sanityDocuments.filter((document) => isDraftId(document._id)); const transaction = _.reduce( draftDocuments, (transaction, document) => { const documentId = document._id; const publishedDocument = _.find(sanityDocuments, { _id: getPureObjectId(documentId) }); if (publishedDocument) { // borrowed form Sanity folks - https://github.com/sanity-io/sanity/blob/c3c875ed51bf49ebceedc40abe50ad17ccbf489b/packages/%40sanity/desk-tool/src/pane/DocumentPane.js#L860 // Hack until other mutations support revision locking transaction = transaction.patch(publishedDocument._id, { unset: ['_reserved_prop_'], ifRevisionID: publishedDocument._rev }); } // you should omit it to be sure it get's the right timestamp from the server const documentForPublishing = _.omit(document, ['_updatedAt']) as SanityDocumentStub; return transaction .createOrReplace({ ...documentForPublishing, _id: getPureObjectId(documentId) }) .delete(documentId); }, userClient.transaction() ); await transaction.commit(); } async getScheduledActions(): Promise { this.logger.debug('getScheduledPublishes'); const sanitySchedules = await fetchScheduledActions({ projectId: this.projectId, dataset: this.dataset }, this.client); return convertAndFilterScheduledActions(sanitySchedules); } async createScheduledAction({ documentIds, name, action, executeAt, userContext }: { documentIds: string[]; name: string; action: ContentSourceTypes.ScheduledActionActionType; executeAt: string; userContext?: UserContext; }): Promise<{ newScheduledActionId: string }> { this.logger.debug('createScheduledAction'); const userClient = this.getApiClientForUser({ userContext }); const sanitySchedule = await userClient.request({ method: 'POST', uri: `/schedules/${this.projectId}/${this.dataset}`, body: { documents: documentIds.map((docId) => ({ documentId: docId })), executeAt: executeAt, name: name } }); const schedule = convertScheduledAction(sanitySchedule); await this.cache.updateContent({ scheduledActions: [schedule] }); this.logger.debug('createScheduledAction - Create success', { originalSchedule: sanitySchedule, schedule }); return { newScheduledActionId: schedule.id }; } async cancelScheduledAction({ scheduledActionId, userContext }: { scheduledActionId: string; userContext?: UserContext; }): Promise<{ cancelledScheduledActionId: string }> { this.logger.debug('cancelScheduledAction'); const userClient = this.getApiClientForUser({ userContext }); const response = await userClient .request({ method: 'PATCH', uri: `/schedules/${this.projectId}/${this.dataset}/${scheduledActionId}`, body: { state: 'cancelled' } }) .then(() => userClient.request({ method: 'GET', uri: `/schedules/${this.projectId}/${this.dataset}/${scheduledActionId}` }) ); const sanitySchedule = response.schedules[0]; const schedule = convertScheduledAction(sanitySchedule); await this.cache.updateContent({ scheduledActions: [schedule] }); this.logger.debug('cancelScheduledAction - Cancel success', { originalSchedule: sanitySchedule, schedule }); return { cancelledScheduledActionId: schedule.id }; } async updateScheduledAction({ scheduledActionId, documentIds, name, executeAt, userContext }: { scheduledActionId: string; documentIds?: string[]; name?: string; executeAt?: string; userContext?: UserContext; }): Promise<{ updatedScheduledActionId: string }> { this.logger.debug('updateScheduledAction'); const updateObj = omitByNil({ documents: documentIds?.map((documentId) => ({ documentId })), name, executeAt }); const userClient = this.getApiClientForUser({ userContext }); const response = await userClient .request({ method: 'PATCH', uri: `/schedules/${this.projectId}/${this.dataset}/${scheduledActionId}`, body: updateObj }) .then(() => userClient.request({ method: 'GET', uri: `/schedules/${this.projectId}/${this.dataset}/${scheduledActionId}` }) ); const sanitySchedule = response.schedules[0]; const schedule = convertScheduledAction(sanitySchedule); await this.cache.updateContent({ scheduledActions: [schedule] }); this.logger.debug('updateScheduledAction - Update success', { originalSchedule: sanitySchedule, schedule }); return { updatedScheduledActionId: schedule.id }; } getApiClientForUser({ userContext }: { userContext?: UserContext }): SanityClientType { if (this.localDev) { return this.client; } const userAccessToken = userContext?.accessToken; if (!userAccessToken) { throw new Error(`Permissions error: user does not have an access token for project: '${this.projectId}'.`); } return new SanityClient({ projectId: this.projectId, dataset: this.dataset, token: userAccessToken, useCdn: false, apiVersion: SANITY_API_VERSION }); } async getDocumentVersions({ documentId }: { documentId: string }): Promise<{ versions: DocumentVersion[] }> { this.logger.debug('getDocumentVersions', { documentId }); const documentHistory = await fetchDocumentsHistory({ documentIds: [documentId, getDraftObjectId(documentId)], dataset: this.dataset, client: this.client, limitTime: false }); if (!documentHistory) { return { versions: [] }; } return { versions: this.convertVersionsFromDocumentHistory(documentHistory) }; } async getDocumentForVersion({ documentId, versionId }: { documentId: string; versionId: string }): Promise<{ version: DocumentVersionWithDocument }> { this.logger.debug('getDocumentForVersion', { documentId, versionId }); const draftDocumentId = getDraftObjectId(documentId); const [version, document] = await Promise.all([ fetchDocumentRevision({ documentId, draftDocumentId, versionId, dataset: this.dataset, client: this.client }), fetchDocumentForRevision({ documentId, draftDocumentId, versionId, dataset: this.dataset, client: this.client }) ]); const [contextualDocument] = this.convertDocuments({ documents: [document], getModelByName: this.cache.getModelByName, studioUrl: this.studioUrl }); if (!contextualDocument) { throw new Error(`Could not get document ${documentId} for revision ${versionId}`); } return { version: this.convertVersionForDocument(version, contextualDocument) }; } }