import _ from 'lodash'; import path from 'path'; import sanitizeFilename from 'sanitize-filename'; import * as CSITypes from '@stackbit/types'; import { isOneOfFieldTypes, ModelExtension, UserCommandSpawner, CommandRunner, ScheduledAction, DocumentSpecifier } from '@stackbit/types'; import { Config, extendModelsWithPresetsIds, getPresetDirs, getYamlModelDirs, ImageModel, loadPresets, loadYamlModelsFromFiles, mergeConfigModelsWithExternalModels, mergeConfigModelsWithModelsFromFiles, Model, Preset, PresetMap } from '@stackbit/sdk'; import { append, DeferredPromise, deferredPromise, deferWhileRunning, isTruthy, mapPromise, reducePromise } from '@stackbit/utils'; import * as ContentStoreTypes from './types'; import { Timer } from './utils/timer'; import { ContentSourceData, ContentSourceRawData, ContentStoreEvent, ContentStoreEventType, OnFilesChangeResponse, ProvisionalData, ReferenceMap, SearchFilter } from './types'; import { searchDocuments } from './utils/search-utils'; import { mapCSIAssetsToStoreAssets, mapCSIDocumentsToStoreDocuments } from './utils/csi-to-store-docs-converter'; import { getContentSourceId, getContentSourceDataByIdOrThrow, getContentSourceIdForContentSource, getCSIDocumentsAndAssetsFromContentSourceDataByIds, getUserContextForSrcType, groupDocumentsByContentSource, groupModelsByContentSource, isContentChangesEmpty, isContentChangeResultEmpty, contentChangeResultCounts, updateOperationValueFieldWithCrossReference, getErrorAtLine, findContentSourcesDataForTypeOrId, getDocumentFieldForLocale, getCacheDir, getObjectId } from './content-store-utils'; import { getSiteMapEntriesFromStackbitConfig, updateSiteMapEntriesWithContentChanges, getDocumentFieldLabelValueForSiteMapEntry, SiteMapEntryGroups } from './utils/site-map'; import { mapAssetsToLocalizedApiImages, mapDocumentsToLocalizedApiObjects, mapStoreAssetsToAPIAssets, replaceAssetUrlIfNeeded } from './utils/store-to-api-docs-converter'; import { mapDocumentsToApiDocuments } from './utils/store-to-api-v2-docs-converter'; import { convertOperationField, createDocumentRecursively, CreateDocumentThunk, getCreateDocumentThunk } from './utils/create-update-csi-docs'; import { mergeObjectWithDocument } from './utils/duplicate-document'; import { getModelMap, normalizeModels, validateModels } from './utils/model-utils'; import { ASSET_MODEL, IMAGE_MODEL } from './common/common-schema'; import { getDocumentObjectFromPreset, getPresetFromDocument } from './utils/preset-utils'; import { BackCompatContentSourceInterface, backwardCompatibleContentSource } from './utils/backward-compatibility'; import { createConfigDelegate, getCreateConfigDelegateThunk } from './utils/config-delegate'; import { GitService } from './services'; import { getAssetSourcesForClient } from './utils/asset-sources-utils'; import { archiveDocumentHooked, deleteDocumentHooked, publishDocumentHooked, unarchiveDocumentHooked, unpublishDocumentHooked, updateDocumentHooked } from './utils/document-hooks'; import { resolveCustomActionsById, getGlobalAndBulkAPIActions, runCustomAction, getRunningActions } from './utils/custom-actions'; import { getSanitizedTreeViews, removeHiddenTreeViews } from './utils/tree-views'; import { getModelFieldAtFieldPath } from './utils/field-path-utils'; import { logCreateDocument, logDuplicateDocument, logDocumentEvent, logDocumentsEvent, logUpdateDocument, logUploadAssets, pluralize, logUpdateAsset } from './utils/user-log-utils'; import { ContentEngine, PluginRef, contentEngine } from '@netlify/content-engine'; import { mapDocumentVersionsToApiDocumentVersions } from './utils/csi-to-api-docs-converter'; import { NoopFileCache, FileCache } from './utils/file-cache'; import { getContentSourceFilteredModelsForUser, getFilteredAssetsForUser, getFilteredDocumentsForUser } from './utils/filtered-entities'; import { STACKBIT_PRESET_MODEL_NAME } from './consts'; import { syncContentSource, updateProvisionalDataFromContentSource } from './utils/content-store-data-manager'; import { getReferenceMap } from './utils/references-utils'; import { utils } from './'; import { internalValidateContent, validateUpdateOperations } from './utils/document-validations'; export type HandleConfigAssets = ({ models, presets }: { models?: T[]; presets?: PresetMap }) => Promise<{ models: T[]; presets: PresetMap }>; export interface ContentSourceOptions { logger: CSITypes.Logger; userLogger: CSITypes.Logger; isLocalDev: boolean; isDevServer: boolean; staticAssetsPublicPath: string; webhookUrl?: string; runCommand: CommandRunner; git: GitService; userCommandSpawner?: UserCommandSpawner; //TODO remove onSchemaChangeCallback: () => void; onContentChangeCallback: (contentChanges: ContentStoreTypes.ContentChangeResult) => void; onActionStateChangeCallback: (actionResult: ContentStoreTypes.CustomActionStateChange) => void; handleConfigAssets: HandleConfigAssets; devAppRestartNeeded?: () => void; } type ContentStoreEventQueue = ContentStoreEvent[]; export class ContentStore { private readonly logger: CSITypes.Logger; private readonly userLogger: CSITypes.Logger; private readonly userCommandSpawner?: UserCommandSpawner; private readonly isLocalDev: boolean; private readonly isDevServer: boolean; private readonly staticAssetsPublicPath: string; private readonly webhookUrl?: string; private readonly runCommand: CommandRunner; private readonly git: GitService; private readonly onSchemaChangeCallback: () => void; private readonly onContentChangeCallback: (contentChanges: ContentStoreTypes.ContentChangeResult) => void; private readonly onActionStateChangeCallback: (actionStateChange: ContentStoreTypes.CustomActionStateChange) => void; private readonly handleConfigAssets: HandleConfigAssets; private readonly devAppRestartNeeded?: () => void; private contentSources: BackCompatContentSourceInterface[] = []; private contentSourceDataById: Record = {}; private presetsContentSource?: BackCompatContentSourceInterface; private contentUpdatesWatchTimer: Timer; private stackbitConfig: Config | null = null; private yamlModels: Model[] = []; private configModels: Model[] = []; private modelExtensions: ModelExtension[] | null = null; private presets: PresetMap = {}; private siteMapEntryGroups: SiteMapEntryGroups = {}; private processingContentSourcesPromise: DeferredPromise | null = null; private contentStoreEventQueue: ContentStoreEventQueue = []; private treeViews: CSITypes.TreeViewNode[] = []; private customActionRunStateMap: ContentStoreTypes.CustomActionRunStateMap = {}; private contentEngine?: ContentEngine | null; private referenceMap: ReferenceMap = {}; constructor(options: ContentSourceOptions) { this.logger = options.logger.createLogger({ label: 'content-store' }); this.userLogger = options.userLogger.createLogger({ label: 'content-store' }); this.isLocalDev = options.isLocalDev; this.isDevServer = options.isDevServer; this.staticAssetsPublicPath = options.staticAssetsPublicPath; this.webhookUrl = options.webhookUrl; this.runCommand = options.runCommand; this.git = options.git; this.userCommandSpawner = options.userCommandSpawner; this.onSchemaChangeCallback = options.onSchemaChangeCallback; this.onContentChangeCallback = options.onContentChangeCallback; this.onActionStateChangeCallback = options.onActionStateChangeCallback; this.handleConfigAssets = options.handleConfigAssets; this.contentUpdatesWatchTimer = new Timer({ timerCallback: () => this.handleTimerTimeout(), logger: this.logger }); this.devAppRestartNeeded = options.devAppRestartNeeded; // The `loadContentSourcesAndProcessData` method can be called multiple times rapidly for several reasons: // stackbit.config.js updated, one of the preset or the yaml-model files changed, one of the content-source's // schema files updated (for example Sanity's schema files). // Therefore, to prevent parallel executions of this method and corrupting the ContentStore state, // debounce rapid invocations of this method and defer invocation while this method is running. this.loadContentSourcesAndProcessData = deferWhileRunning(this.loadContentSourcesAndProcessData, { thisArg: this, debounceDelay: 100, debounceMaxDelay: 500, // When the loadContentSourcesAndProcessData is called multiple times with different arguments, // ensure that the deferred call will be called with the lowest denominator of the passed arguments. argsResolver: ({ nextArgs, prevArgs }) => { // If at least one call had "init: true" then call the deferred // function with "init: true" and without invalidateSchemaForContentSourceIds. const init = nextArgs[0].init || Boolean(prevArgs?.[0].init); if (init) { return [{ init }] as typeof nextArgs; } // If at least one call had "startWatchingContentUpdates: true" then call the deferred // function with "startWatchingContentUpdates: true". const startWatchingContentUpdates = nextArgs[0].startWatchingContentUpdates || Boolean(prevArgs?.[0].startWatchingContentUpdates); // If at least one call had "invalidateSchemaForContentSourceIds: undefined" (signal to reload all content sources) // then call the deferred function with "invalidateSchemaForContentSourceIds: undefined", // otherwise call it with union of content source ids. const invalidateSchemaForContentSourceIds = typeof nextArgs[0].invalidateSchemaForContentSourceIds === 'undefined' || (prevArgs && typeof prevArgs[0].invalidateSchemaForContentSourceIds === 'undefined') ? undefined : _.union(nextArgs[0].invalidateSchemaForContentSourceIds, prevArgs?.[0].invalidateSchemaForContentSourceIds); return [{ init, startWatchingContentUpdates, invalidateSchemaForContentSourceIds }] as typeof nextArgs; } }); this.processContentStoreEvents = deferWhileRunning(this.processContentStoreEvents, { thisArg: this }); } async init({ stackbitConfig }: { stackbitConfig: Config | null }): Promise { this.logger.debug('init'); this.stackbitConfig = stackbitConfig; if (stackbitConfig) { if (stackbitConfig.modelExtensions) { this.modelExtensions = stackbitConfig.modelExtensions; } else { this.yamlModels = await this.loadYamlModels({ stackbitConfig }); this.configModels = this.mergeConfigModels(stackbitConfig.models ?? [], this.yamlModels); } } await this.loadContentSourcesAndProcessData({ init: true }); this.contentUpdatesWatchTimer.startTimer(); } async onStackbitConfigChange({ stackbitConfig }: { stackbitConfig: Config | null }) { this.logger.debug('onStackbitConfigChange'); this.stackbitConfig = stackbitConfig; if (stackbitConfig) { if (stackbitConfig.modelExtensions) { this.modelExtensions = stackbitConfig.modelExtensions; } else { this.configModels = this.mergeConfigModels(stackbitConfig.models ?? [], this.yamlModels); } } await this.loadContentSourcesAndProcessData({ init: true }); } /** * This method is called when contentUpdatesWatchTimer receives timeout. * This happens when the user is not using the Stackbit app for some time * but container is not hibernated. * It then notifies all content sources to stop watching for content * changes, which in turn stops polling CMS for content changes and helps * reducing the CMS API usage. */ private handleTimerTimeout() { for (const contentSourceInstance of this.contentSources) { contentSourceInstance.stopWatchingContentUpdates?.(); } } /** * This method is called when user interacts with Stackbit application. * It is used to reset contentUpdatesWatchTimer. When the timer is over * all content sources are notified to stop watching for content updates. */ async keepAlive() { if (this.contentUpdatesWatchTimer.isRunning()) { this.contentUpdatesWatchTimer.resetTimer(); return; } this.logger.debug('keepAlive => contentUpdatesWatchTimer is not running => load content source data'); this.contentUpdatesWatchTimer.startTimer(); await this.loadContentSourcesAndProcessData({ init: false, startWatchingContentUpdates: true }); } async destroy() { this.contentUpdatesWatchTimer.stopTimer(); for (const contentSourceInstance of this.contentSources) { contentSourceInstance.stopWatchingContentUpdates?.(); await contentSourceInstance.destroy?.(); } // TODO: add destroy() method to the contentEngine to ensure it cleans // and stops any servers, listeners and timeouts. // this.contentEngine?.destroy(); } async onFilesChange(updatedFiles: string[], dryRun?: boolean): Promise { this.logger.debug('onFilesChange'); const result: OnFilesChangeResponse = { contentChanged: false, codeChanged: false, contentFiles: [] }; if (this.stackbitConfig && !this.stackbitConfig.modelExtensions) { // Check if any of the yaml models files were changed. If yaml model files were changed, // reload them and merge them with models defined in stackbit config. const modelDirs = getYamlModelDirs(this.stackbitConfig); const yamlModelsChanged = updatedFiles.find((updatedFile) => _.some(modelDirs, (modelDir) => updatedFile.startsWith(modelDir))); if (yamlModelsChanged) { result.codeChanged = true; if (!dryRun && !this.isDevServer) { this.logger.debug('identified change in stackbit model files'); this.yamlModels = await this.loadYamlModels({ stackbitConfig: this.stackbitConfig }); this.configModels = this.mergeConfigModels(this.stackbitConfig.models ?? [], this.yamlModels); this.pushContentSourceEvent({ eventName: ContentStoreEventType.YamlModelFilesChange }); } } } let totalContentFiles = 0; if (this.stackbitConfig) { // Check if any of the preset files were changed. If presets were changed, reload them. const presetDirs = getPresetDirs(this.stackbitConfig); const presetsChanged = updatedFiles.filter((updatedFile) => _.some(presetDirs, (presetDir) => updatedFile.startsWith(presetDir))); if (presetsChanged.length && !this.usesContentSourcePresets()) { totalContentFiles += presetsChanged.length; result.contentChanged = true; result.contentFiles.push(...presetsChanged); if (!dryRun) { this.logger.debug('identified change in stackbit preset files'); this.presets = await this.loadPresetsFromConfig({ stackbitConfig: this.stackbitConfig }); this.pushContentSourceEvent({ eventName: ContentStoreEventType.PresetFilesChange }); } } } for (const contentSourceInstance of this.contentSources) { const contentSourceId = getContentSourceIdForContentSource(contentSourceInstance); const contentSourceData = this.contentSourceDataById[contentSourceId]; if (contentSourceData?.destroyed) { this.logger.debug(`contentSource '${contentSourceId}' was destroyed, don't call onFilesChange`); continue; } this.logger.debug(`call onFilesChange for contentSource: ${contentSourceId}`); // isGitCms is internal flag which used to show us it's a git/fs content source const isGitCS = (contentSourceInstance as any).isGitCms; // dont execute on dev servers unless it's git const shouldExecute = !this.isDevServer || isGitCS; const onFilesChangeResult = shouldExecute ? await contentSourceInstance.onFilesChange({ updatedFiles }) : undefined; const changedContentFiles = (onFilesChangeResult?.contentChanges?.documents?.length ?? 0) + (onFilesChangeResult?.contentChanges?.assets?.length ?? 0) + (onFilesChangeResult?.contentChanges?.deletedDocumentIds?.length ?? 0) + (onFilesChangeResult?.contentChanges?.deletedAssetIds?.length ?? 0); totalContentFiles += changedContentFiles; if (changedContentFiles > 0) { result.contentChanged = true; const allChangedEntities = [...(onFilesChangeResult?.contentChanges?.documents ?? []), ...(onFilesChangeResult?.contentChanges?.assets ?? [])]; [...(onFilesChangeResult?.contentChanges?.deletedDocumentIds ?? []), ...(onFilesChangeResult?.contentChanges?.deletedAssetIds ?? [])].forEach( (documentId) => { const document = contentSourceData?.csiDocumentMap[documentId]; if (document) { allChangedEntities.push(document); } } ); allChangedEntities.forEach((entity) => { if (typeof entity.context === 'object' && entity.context && 'filePath' in entity.context && typeof entity.context.filePath === 'string') { result.contentFiles.push(entity.context.filePath); } }); } if (onFilesChangeResult?.invalidateSchema) { result.codeChanged = true; } // If the schema was changed in a specific content source, there is no need to process and notify for content changes. // Because the schema changes will trigger loadContentSourcesAndProcessData and reload all models and content of that // content source and send the schemaChanged notification that will cause the Studio to reload the schema and documents. if (!dryRun && shouldExecute) { if (onFilesChangeResult?.invalidateSchema) { this.logger.debug(`schema was invalidated for contentSource: ${contentSourceId}`); this.pushContentSourceEvent({ eventName: ContentStoreEventType.ContentSourceInvalidateSchema, contentSourceId: contentSourceId }); } else if (onFilesChangeResult && !isContentChangesEmpty(onFilesChangeResult.contentChanges)) { this.logger.debug(`content was changed for contentSource: ${contentSourceId}`); this.pushContentSourceEvent({ eventName: ContentStoreEventType.ContentSourceContentChange, contentSourceId: contentSourceId, contentChanges: onFilesChangeResult.contentChanges }); } } } if (totalContentFiles < updatedFiles.length) { result.codeChanged = true; } if (!dryRun) { await this.processContentStoreEvents(); } return result; } private async loadYamlModels({ stackbitConfig }: { stackbitConfig: Config }): Promise { const yamlModelsResult = await loadYamlModelsFromFiles(stackbitConfig); for (const error of yamlModelsResult.errors) { this.userLogger.warn(error.message); } return yamlModelsResult.models; } private mergeConfigModels(configModels: Model[], modelsFromFiles: Model[]) { const configModelsResult = mergeConfigModelsWithModelsFromFiles(configModels, modelsFromFiles); for (const error of configModelsResult.errors) { this.userLogger.warn(error.message); } return configModelsResult.models; } private async loadPresetsFromConfig({ stackbitConfig }: { stackbitConfig: Config }): Promise { const contentSources = stackbitConfig?.contentSources ?? []; const singleContentSource = contentSources.length === 1 ? contentSources[0] : null; const presetResult = await loadPresets({ config: stackbitConfig, ...(singleContentSource ? { fallbackSrcType: singleContentSource.getContentSourceType(), fallbackSrcProjectId: singleContentSource.getProjectId() } : null) }); for (const error of presetResult.errors) { this.userLogger.warn(error.message); } const { presets } = await this.handleConfigAssets({ presets: presetResult.presets }); return presets; } private async loadPresetsFromContentSource(contentSourceData: ContentSourceRawData): Promise { const presets = _.reduce( contentSourceData.csiDocuments, (result: Record, csiDocument) => { if (csiDocument.modelName === STACKBIT_PRESET_MODEL_NAME) { const preset = getPresetFromDocument({ srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, csiDocument, csiAssetMap: contentSourceData.csiAssetMap, logger: this.logger }); if (preset) { result[csiDocument.id] = preset; } } return result; }, {} ); return presets; } /** * If any content sources implement the `getContentEngineConfig` method then this function gets all * of these plugin definitions and creates the content engine. */ private async createContentEngineIfNeeded() { const logger = this.userLogger.createLogger({ label: 'content-engine' }); // TODO: The stackbit.config.ts can be updated with new connectors, or // new connector options. In this case, we need to destroy existing // contentEngine and create a new one. if (this.contentEngine) { logger.debug( `Content Engine already exists, cannot recreate it as it's running in this process. To recreate it with updated config options the current process must be restarted.` ); return; } const contentSources = this.contentSources || []; const plugins = Array.from( new Set(contentSources.flatMap((contentSource) => contentSource.getContentEngineConfig?.()?.plugins).filter(Boolean)) ) as PluginRef[]; if (plugins.length < 1) { return; } logger.info('Creating content engine'); this.contentEngine = contentEngine({ directory: this.stackbitConfig?.dirPath, pluginDirectories: [path.join(this.stackbitConfig?.dirPath || process.cwd(), `.stackbit/.cache/remote-content-sources`)], port: this.stackbitConfig?.contentEngine?.port, host: this.stackbitConfig?.contentEngine?.host, engineConfig: { plugins }, // content engine must run in the same process so that unified connectors can share state in memory runInSubProcess: false // TODO: pass the logger and the userLogger to the contentEngine to // allow printing internal and user-facing logs. // logger // userLogger }); } /** * This function reloads the data of the specified content-sources, while * reusing the cached data of the rest of the content-sources, then processes * the content sources' data by merging it with models defined in * stackbit.config.js and yaml-model files. * * This function is wrapped by `deferWhileRunning` ensuring this method is * invoked one at a time. * * @param {boolean} init Flag specifying if the content sources need to be initialized or reset. * The content sources need to be (re-)initialized only when the stackbit.config.js was reloaded. * In all other cases content sources can be reset. * @param {string[] | undefined} invalidateSchemaForContentSourceIds Array of content source IDs to reload. * If not provided or set to "undefined", will reload all content sources. * If set to empty array will not reload any content sources and only process their cached data. * @private */ private async loadContentSourcesAndProcessData({ init, startWatchingContentUpdates, invalidateSchemaForContentSourceIds }: { init: boolean; startWatchingContentUpdates?: boolean; invalidateSchemaForContentSourceIds?: string[]; }) { this.logger.debug('loadContentSourcesAndProcessData', { init, invalidateSchemaForContentSourceIds }); if (this.processingContentSourcesPromise) { // for internal monitoring this.logger.error('ALERT, called loadContentSourcesAndProcessData while still processing the previous call'); } this.processingContentSourcesPromise = deferredPromise(); // On init, get the new content source instances from the config and wrap them with backward compatibility Proxy. // Otherwise, reuse existing proxy wrapped backward compatible content sources. const contentSources = init || this.contentSources.length === 0 ? (this.stackbitConfig?.contentSources ?? []).map((contentSource) => { return backwardCompatibleContentSource(contentSource); }) : this.contentSources; this.contentSources = contentSources; if (init) { this.logger.debug('init content sources'); await this.createContentEngineIfNeeded(); } const promises = contentSources.map((contentSourceInstance): Promise => { const contentSourceId = getContentSourceIdForContentSource(contentSourceInstance); if (init || !invalidateSchemaForContentSourceIds || invalidateSchemaForContentSourceIds.includes(contentSourceId)) { return this.loadContentSourceData({ contentSourceInstance, init, startWatchingContentUpdates }); } else { return Promise.resolve(_.omit(this.contentSourceDataById[contentSourceId], ['models', 'modelMap', 'documents', 'documentMap'])); } }); const contentSourceRawDataArr = await Promise.all(promises); // find first content source that supports presets for (let i = 0; i < contentSources.length; i++) { const contentSourceDataRaw = contentSourceRawDataArr[i]; if (contentSourceDataRaw?.csiModelMap?.[STACKBIT_PRESET_MODEL_NAME]) { this.presetsContentSource = contentSourceDataRaw.instance; // reload presets from content source only if needed if (init || !invalidateSchemaForContentSourceIds || invalidateSchemaForContentSourceIds.includes(contentSourceDataRaw.id)) { this.presets = await this.loadPresetsFromContentSource(contentSourceDataRaw); } break; } } // fallback to loading presets from config as usual if (init && this.stackbitConfig && !this.presetsContentSource) { this.presets = await this.loadPresetsFromConfig({ stackbitConfig: this.stackbitConfig }); } // update all content sources at once to prevent race conditions this.contentSourceDataById = await this.processData({ stackbitConfig: this.stackbitConfig, configModels: this.modelExtensions ?? (this.configModels as ModelExtension[]) ?? [], presets: this.presets, contentSourceRawDataArr: contentSourceRawDataArr }); const configDelegate = createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }); // generate create site map entries this.siteMapEntryGroups = await getSiteMapEntriesFromStackbitConfig({ stackbitConfig: this.stackbitConfig, contentSourceDataById: this.contentSourceDataById, configDelegate }); this.treeViews = await getSanitizedTreeViews({ configDelegate, stackbitConfig: this.stackbitConfig, contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }); this.calculateReferenceMap(); if (!init) { this.onSchemaChangeCallback(); } this.logger.debug('loadContentSourcesAndProcessData finished', { init, invalidateSchemaForContentSourceIds }); const processingPromise = this.processingContentSourcesPromise; this.processingContentSourcesPromise = null; processingPromise.resolve(); } private async processContentStoreEvents() { // If the ContentStore is loading content sources, i.e., the loadContentSourcesAndProcessData() is running, // wait for it to finish to prevent parallel data updates. if (this.processingContentSourcesPromise) { this.logger.debug('processContentStoreEvents, processing content sources, delaying execution'); await this.processingContentSourcesPromise.promise; } if (this.contentStoreEventQueue.length === 0) { return; } const contentSourceEvents = this.contentStoreEventQueue; this.contentStoreEventQueue = []; this.logger.debug( 'processContentStoreEvents: ' + contentSourceEvents.map((event) => event.eventName + ('contentSourceId' in event ? ` (${event.contentSourceId})` : '')).join(', ') ); const invalidateSchemaForContentSourceIds: string[] = []; const contentChanges: ContentStoreTypes.ContentChangeResult = { createdDocuments: [], createdAssets: [], createdScheduledActions: [], updatedDocuments: [], updatedAssets: [], updatedScheduledActions: [], deletedDocuments: [], deletedAssets: [], deletedScheduledActions: [] }; let invalidateSchema = false; let presetsUpdated = false; for (const contentSourceEvent of contentSourceEvents) { if ( contentSourceEvent.eventName === ContentStoreEventType.YamlModelFilesChange || contentSourceEvent.eventName === ContentStoreEventType.PresetFilesChange ) { invalidateSchema = true; } else if (contentSourceEvent.eventName === ContentStoreEventType.ContentSourceInvalidateSchema) { invalidateSchema = true; invalidateSchemaForContentSourceIds.push(contentSourceEvent.contentSourceId); } else if ( contentSourceEvent.eventName === ContentStoreEventType.ContentSourceContentChange || contentSourceEvent.eventName === ContentStoreEventType.ContentSourceRequestSync ) { let result: { contentChangeResult: ContentStoreTypes.ContentChangeResult; presetsUpdated: boolean }; if (contentSourceEvent.eventName === ContentStoreEventType.ContentSourceRequestSync) { const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceEvent.contentSourceId); const { contentChangesData, syncContext } = await syncContentSource({ contentSourceData, contentSourceEvent }); result = this.onContentChange(contentSourceEvent.contentSourceId, contentChangesData); contentSourceData.syncContext = syncContext; // Sync results to disk const syncFieldCache = new FileCache({ dirPath: getCacheDir(this.stackbitConfig!), keyPrefix: `${contentSourceEvent.contentSourceId}/sync.` }); if (syncContext?.documentsSyncContext || syncContext?.assetsSyncContext) { await syncFieldCache.set('context', syncContext); if ( result.contentChangeResult.createdDocuments.length || result.contentChangeResult.updatedDocuments.length || result.contentChangeResult.deletedDocuments.length ) { await syncFieldCache.set('documents', contentSourceData.csiDocuments); } if ( result.contentChangeResult.createdAssets.length || result.contentChangeResult.updatedAssets.length || result.contentChangeResult.deletedAssets.length ) { await syncFieldCache.set('assets', contentSourceData.csiAssets); } } else { await syncFieldCache.remove('context'); await syncFieldCache.remove('schema'); await syncFieldCache.remove('documents'); await syncFieldCache.remove('assets'); } } else { result = this.onContentChange(contentSourceEvent.contentSourceId, contentSourceEvent.contentChanges); } contentChanges.createdDocuments = contentChanges.createdDocuments.concat(result.contentChangeResult.createdDocuments); contentChanges.createdAssets = contentChanges.createdAssets.concat(result.contentChangeResult.createdAssets); contentChanges.createdScheduledActions = contentChanges.createdScheduledActions.concat(result.contentChangeResult.createdScheduledActions); contentChanges.updatedDocuments = contentChanges.updatedDocuments.concat(result.contentChangeResult.updatedDocuments); contentChanges.updatedAssets = contentChanges.updatedAssets.concat(result.contentChangeResult.updatedAssets); contentChanges.updatedScheduledActions = contentChanges.updatedScheduledActions.concat(result.contentChangeResult.updatedScheduledActions); contentChanges.deletedDocuments = contentChanges.deletedDocuments.concat(result.contentChangeResult.deletedDocuments); contentChanges.deletedAssets = contentChanges.deletedAssets.concat(result.contentChangeResult.deletedAssets); contentChanges.deletedScheduledActions = contentChanges.deletedScheduledActions.concat(result.contentChangeResult.deletedScheduledActions); if (result.presetsUpdated) { presetsUpdated = true; } } } // If the schema was changed, call loadContentSourcesAndProcessData method, this will reload all the SiteMapEntries and call the onSchemaChangeCallback. // As soon as the Studio receives the schemaChanged notification it will reload all the models and the documents. if (invalidateSchema) { this.logger.debug('processContentStoreEvents => invalidateSchema'); await this.loadContentSourcesAndProcessData({ init: false, invalidateSchemaForContentSourceIds }); } else { this.logger.debug('processContentStoreEvents => content changes', { ...contentChangeResultCounts(contentChanges), presetsUpdated }); const configDelegate = createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }); // If the schema wasn't changed, update SiteMapEntries with the changed content. this.siteMapEntryGroups = await updateSiteMapEntriesWithContentChanges({ siteMapEntryGroups: this.siteMapEntryGroups, contentChanges: contentChanges, stackbitConfig: this.stackbitConfig, contentSourceDataById: this.contentSourceDataById, configDelegate }); this.treeViews = await getSanitizedTreeViews({ configDelegate, stackbitConfig: this.stackbitConfig, contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }); // If presets were updated, call onSchemaChangeCallback to notify the Studio. // The Studio will refresh the models and the documents, so no need to notify // content changes in this case. if (presetsUpdated) { this.onSchemaChangeCallback(); } else if (!isContentChangeResultEmpty(contentChanges)) { this.calculateReferenceMap(); this.onContentChangeCallback(contentChanges); } } } private pushContentSourceEvent(contentStoreEvent: ContentStoreEvent) { if ( contentStoreEvent.eventName === ContentStoreEventType.ContentSourceContentChange || contentStoreEvent.eventName === ContentStoreEventType.ContentSourceRequestSync ) { // If a content source enqueued the 'contentSourceInvalidateSchema' event, // don't push the 'contentSourceContentChange' or the 'contentSourceRequestSync' events, // because 'contentSourceInvalidateSchema' will reload all the content source data anyway. const hasContentSourceSchemaChangeEvent = this.contentStoreEventQueue.find( (event) => event.eventName === ContentStoreEventType.ContentSourceInvalidateSchema && event.contentSourceId === contentStoreEvent.contentSourceId ); if (!hasContentSourceSchemaChangeEvent) { this.contentStoreEventQueue.push(contentStoreEvent); } } else if (contentStoreEvent.eventName === ContentStoreEventType.ContentSourceInvalidateSchema) { // Clear any 'contentSourceContentChange' and 'contentSourceRequestSync' events for a content source, // the 'contentSourceInvalidateSchema' will reload all the content source data. this.clearEventsForContentSourceId(contentStoreEvent.contentSourceId); this.contentStoreEventQueue.push(contentStoreEvent); } else if (contentStoreEvent.eventName === ContentStoreEventType.YamlModelFilesChange) { this.contentStoreEventQueue = this.contentStoreEventQueue.filter((event) => event.eventName !== ContentStoreEventType.YamlModelFilesChange); this.contentStoreEventQueue.push(contentStoreEvent); } else if (contentStoreEvent.eventName === ContentStoreEventType.PresetFilesChange) { this.contentStoreEventQueue = this.contentStoreEventQueue.filter((event) => event.eventName !== ContentStoreEventType.PresetFilesChange); this.contentStoreEventQueue.push(contentStoreEvent); } } private clearEventsForContentSourceId(contentSourceId: string) { this.contentStoreEventQueue = this.contentStoreEventQueue.filter((contentSourceEvent) => { if ( contentSourceEvent.eventName === ContentStoreEventType.ContentSourceContentChange || contentSourceEvent.eventName === ContentStoreEventType.ContentSourceRequestSync || contentSourceEvent.eventName === ContentStoreEventType.ContentSourceInvalidateSchema ) { return contentSourceEvent.contentSourceId !== contentSourceId; } return true; }); } private async loadContentSourceData({ contentSourceInstance, init, startWatchingContentUpdates }: { contentSourceInstance: BackCompatContentSourceInterface; init: boolean; startWatchingContentUpdates?: boolean; }): Promise { const contentSourceId = getContentSourceIdForContentSource(contentSourceInstance); const contentEngineConfig = contentSourceInstance.getContentEngineConfig?.(); // If contentEngine is available, start its sync(), store its promise, // and await for it at the end of this function instead of awaiting here. // Unified connectors are synchronized on the CSI methods such as the // getSchema() which is called below, so the promise returned by the // sync() method is resolved after the getSchema() method is called. // Waiting for this promise before calling the CSI methods will result // in a dead-lock. const contentEngineSyncPromise = contentEngineConfig && this.contentEngine ? this.contentEngine.sync({ buildSchema: true, connector: contentEngineConfig.connector }) : Promise.resolve(); this.logger.debug('loadContentSourceData', { contentSourceId, init }); // clear content source events emitted by this content source because all the content source data is reloaded this.clearEventsForContentSourceId(contentSourceId); const syncFieldCache = new FileCache({ dirPath: getCacheDir(this.stackbitConfig!), keyPrefix: `${contentSourceId}/sync.` }); const cachedSyncContext = (await syncFieldCache.get('context')) as ContentSourceData['syncContext']; const cachedSchema = (await syncFieldCache.get('schema')) as CSITypes.Schema; const cachedDocuments = (await syncFieldCache.get('documents')) as CSITypes.Document[]; const cachedAssets = (await syncFieldCache.get('assets')) as CSITypes.Asset[]; const provisionalData: ProvisionalData = { srcType: contentSourceInstance.getContentSourceType(), srcProjectId: contentSourceInstance.getProjectId(), syncContext: cachedSyncContext, ...(cachedSchema ? { csiSchema: cachedSchema, csiModelMap: _.keyBy(cachedSchema.models, 'name') } : null), ...(cachedDocuments ? { csiDocuments: cachedDocuments, csiDocumentMap: _.keyBy(cachedDocuments, 'id') } : null), ...(cachedAssets ? { csiAssets: cachedAssets, csiAssetMap: _.keyBy(cachedAssets, 'id') } : null) }; const getContentSourceDataForCurrentInstance = ( methodName: keyof CSITypes.Cache | 'getModelMap' | 'getDocument' | 'getAsset' ): ProvisionalData | undefined => { const contentSourceData = this.contentSourceDataById[contentSourceId]; if (!contentSourceData) { // When loading the content sources for the first time, this.contentSourceDataById will be empty. // However, while being loaded, content sources may call cache methods, for example a content // source may call getModelByName from within getDocuments. In this case, return locally cached data. if (this.processingContentSourcesPromise) { return provisionalData; } const atLine = getErrorAtLine(2, getContentSourceDataForCurrentInstance); const errorMessage = `Error executing 'cache.${methodName}' method${atLine}. The content source with id '${contentSourceId}' was not found.`; this.logger.error(errorMessage); return; } if (!this.contentSources.includes(contentSourceInstance)) { const atLine = getErrorAtLine(2, getContentSourceDataForCurrentInstance); const errorMessage = `Content source life cycle error! The content source with id '${contentSourceId}' called the 'cache.${methodName}' ` + `method${atLine} after the destroy() method was called.`; this.logger.error(errorMessage); return; } // While loading the content source, it may call cache methods, when this happens, return the // stale data overridden with the most frequent loaded data if (this.processingContentSourcesPromise) { return Object.assign(contentSourceData, provisionalData); } return contentSourceData; }; const fileCache = this.isLocalDev ? new FileCache({ dirPath: getCacheDir(this.stackbitConfig!), keyPrefix: `${contentSourceId}/src.` }) : new NoopFileCache(); const cache: CSITypes.Cache = { getSchema: () => { const contentSourceData = getContentSourceDataForCurrentInstance('getSchema'); return contentSourceData?.csiSchema ?? { models: [], locales: [], context: null }; }, getModelByName: (modelName) => { const contentSourceData = getContentSourceDataForCurrentInstance('getModelByName'); return contentSourceData?.csiModelMap?.[modelName]; }, getDocuments: () => { const contentSourceData = getContentSourceDataForCurrentInstance('getDocuments'); return contentSourceData?.csiDocuments ?? []; }, getDocumentById: (documentId) => { const contentSourceData = getContentSourceDataForCurrentInstance('getDocumentById'); return contentSourceData?.csiDocumentMap?.[documentId]; }, getAssets: () => { const contentSourceData = getContentSourceDataForCurrentInstance('getAssets'); return contentSourceData?.csiAssets ?? []; }, getAssetById: (assetId) => { const contentSourceData = getContentSourceDataForCurrentInstance('getAssetById'); return contentSourceData?.csiAssetMap?.[assetId]; }, getScheduledActions: () => { const contentSourceData = getContentSourceDataForCurrentInstance('getScheduledActions'); return contentSourceData?.scheduledActions ?? []; }, getScheduledActionsForDocumentId: (documentId: string) => { const contentSourceData = getContentSourceDataForCurrentInstance('getScheduledActionsForDocumentId'); return contentSourceData?.scheduledActions?.filter((scheduledAction) => scheduledAction.documentIds.includes(documentId)) ?? []; }, getSyncContext: () => { const contentSourceData = getContentSourceDataForCurrentInstance('getScheduledActionsForDocumentId'); return contentSourceData?.syncContext ?? {}; }, clearSyncContext: async (options): Promise => { const contentSourceData = getContentSourceDataForCurrentInstance('getScheduledActionsForDocumentId'); if (contentSourceData?.syncContext) { if (typeof options?.clearDocumentsSyncContext === 'undefined' || options.clearDocumentsSyncContext) { delete contentSourceData.syncContext.documentsSyncContext; } if (typeof options?.clearAssetsSyncContext === 'undefined' || options.clearAssetsSyncContext) { delete contentSourceData.syncContext.assetsSyncContext; } if (contentSourceData.syncContext.documentsSyncContext || contentSourceData.syncContext.assetsSyncContext) { await syncFieldCache.set('context', contentSourceData.syncContext); if (!contentSourceData.syncContext.documentsSyncContext) { await syncFieldCache.set('documents', []); } if (!contentSourceData.syncContext.assetsSyncContext) { await syncFieldCache.set('assets', []); } } else { await syncFieldCache.remove('context'); await syncFieldCache.remove('schema'); await syncFieldCache.remove('documents'); await syncFieldCache.remove('assets'); } } }, requestSync: async (options): Promise => { const contentSourceData = getContentSourceDataForCurrentInstance('requestSync'); if (!contentSourceData) { return; } this.pushContentSourceEvent({ eventName: ContentStoreEventType.ContentSourceRequestSync, contentSourceId: contentSourceId, options: options }); await this.processContentStoreEvents(); }, updateContent: async (contentChanges: CSITypes.ContentChanges): Promise => { if (isContentChangesEmpty(contentChanges)) { return; } const contentSourceData = getContentSourceDataForCurrentInstance('updateContent'); if (!contentSourceData) { return; } this.logger.debug('content source called updateContent', { contentSourceId }); this.pushContentSourceEvent({ eventName: ContentStoreEventType.ContentSourceContentChange, contentSourceId: contentSourceId, contentChanges: contentChanges }); await this.processContentStoreEvents(); }, invalidateSchema: async () => { const contentSourceData = getContentSourceDataForCurrentInstance('invalidateSchema'); if (!contentSourceData) { return; } this.logger.debug('content source called invalidateSchema', { contentSourceId }); // TODO: reset schemaContext/syncContext this.pushContentSourceEvent({ eventName: ContentStoreEventType.ContentSourceInvalidateSchema, contentSourceId: contentSourceId }); await this.processContentStoreEvents(); }, get: async (key) => { const contentSourceData = getContentSourceDataForCurrentInstance('get'); if (!contentSourceData) { return; } return fileCache.get(key); }, set: async (key, value) => { const contentSourceData = getContentSourceDataForCurrentInstance('set'); if (!contentSourceData) { return; } return fileCache.set(key, value); }, remove: async (key) => { const contentSourceData = getContentSourceDataForCurrentInstance('remove'); if (!contentSourceData) { return; } return fileCache.remove(key); } }; if (init) { this.userLogger.info( `Initializing content source: ${contentSourceInstance.getContentSourceType()} (project: ${contentSourceInstance.getProjectId()})` ); // When stackbit.config.js reloads, it loads new content source instances. // Previously loaded content source instances must be destroyed. const contentSourceData = this.contentSourceDataById[contentSourceId]; if (contentSourceData && contentSourceData.instance !== contentSourceInstance) { this.logger.debug('destroy previous content source instance', { contentSourceId }); try { contentSourceData.instance.stopWatchingContentUpdates?.(); await contentSourceData.instance.destroy(); } catch (error) { this.logger.debug('error destroying content source instance', { error }); } contentSourceData.destroyed = true; } // If an instance that wasn't destroyed calls one of the InitOptions method don't return any data. await contentSourceInstance.init({ logger: this.logger, userLogger: this.userLogger, localDev: this.isLocalDev, stackbitConfigFilePath: this.stackbitConfig!.filePath, webhookUrl: this.getWebhookUrl(contentSourceInstance.getContentSourceType(), contentSourceInstance.getProjectId()), userCommandSpawner: this.userCommandSpawner, devAppRestartNeeded: this.devAppRestartNeeded, cache: cache, runCommand: this.runCommand, git: this.git }); } else { this.userLogger.info( `Resetting content source: ${contentSourceInstance.getContentSourceType()} (project: ${contentSourceInstance.getProjectId()})` ); await contentSourceInstance.reset(); } const version = await contentSourceInstance.getVersion(); const csiSchema = await contentSourceInstance.getSchema(); const csiModels = csiSchema.models; const csiModelMap = _.keyBy(csiModels, 'name'); const locales = csiSchema.locales; const defaultLocaleCode = locales?.find((locale) => locale.default)?.code; provisionalData.csiSchema = csiSchema; provisionalData.csiModelMap = csiModelMap; // TODO: if schema was changed, invalidate syncContext - or provide utility to compare schemas const { csiDocuments, csiDocumentMap, csiAssets, csiAssetMap, syncContext } = await updateProvisionalDataFromContentSource({ contentSourceInstance, csiModelMap, provisionalData }); let scheduledActions: ScheduledAction[] = []; try { scheduledActions = (await contentSourceInstance.getScheduledActions?.()) ?? []; provisionalData.scheduledActions = scheduledActions; } catch (err) { this.userLogger.warn('Failed to fetch scheduled actions:', { error: err }); } if (syncContext?.documentsSyncContext || syncContext?.assetsSyncContext) { await syncFieldCache.set('context', syncContext); await syncFieldCache.set('schema', csiSchema); await syncFieldCache.set('documents', csiDocuments); await syncFieldCache.set('assets', csiAssets); } else { await syncFieldCache.remove('context'); await syncFieldCache.remove('schema'); await syncFieldCache.remove('documents'); await syncFieldCache.remove('assets'); } const enabledScheduledActions = !!( contentSourceInstance.createScheduledAction && contentSourceInstance.updateScheduledAction && contentSourceInstance.cancelScheduledAction && contentSourceInstance.getScheduledActions ); const enabledDocumentVersions = !!contentSourceInstance.getDocumentVersions; const enabledUnpublish = !!contentSourceInstance.unpublishDocuments; const enabledArchive = !!contentSourceInstance.archiveDocument; const enabledUnarchive = !!contentSourceInstance.unarchiveDocument; const enabledAssetsEditing = !!contentSourceInstance.updateAsset; const contentStoreAssets = mapCSIAssetsToStoreAssets({ csiAssets: csiAssets, contentSourceInstance, defaultLocaleCode }); const assetMap = _.keyBy(contentStoreAssets, 'srcObjectId'); this.logger.debug('loaded content source data', { contentSourceId, defaultLocaleCode, localesCount: locales?.length ?? 0, modelCount: csiModels.length, documentCount: csiDocuments.length, assetCount: csiAssets.length }); this.userLogger.info( `→ Loaded ${contentSourceInstance.getContentSourceType()} content source data (project: ${contentSourceInstance.getProjectId()}): ` + `${csiModels.length} ${pluralize('model', csiModels.length)}, ` + `${csiDocuments.length} ${pluralize('document', csiDocuments.length)} and ` + `${csiAssets.length} ${pluralize('asset', csiAssets.length)}` ); if (init || startWatchingContentUpdates) { // backward compatibility contentSourceInstance.startWatchingContentUpdates?.({ getModelMap: () => { const contentSourceData = getContentSourceDataForCurrentInstance('getModelMap'); return contentSourceData?.csiModelMap ?? {}; }, getDocument: ({ documentId }) => { const contentSourceData = getContentSourceDataForCurrentInstance('getDocument'); return contentSourceData?.csiDocumentMap?.[documentId]; }, getAsset: ({ assetId }) => { const contentSourceData = getContentSourceDataForCurrentInstance('getAsset'); return contentSourceData?.csiAssetMap?.[assetId]; }, onContentChange: cache.updateContent, onSchemaChange: cache.invalidateSchema }); } await contentEngineSyncPromise; return { id: contentSourceId, version: version, srcType: contentSourceInstance.getContentSourceType(), srcProjectId: contentSourceInstance.getProjectId(), instance: contentSourceInstance, destroyed: false, locales: locales, defaultLocaleCode: defaultLocaleCode, csiSchema: csiSchema, csiModels: csiModels, csiModelMap: csiModelMap, csiDocuments: csiDocuments, csiDocumentMap: csiDocumentMap, csiAssets: csiAssets, csiAssetMap: csiAssetMap, assets: contentStoreAssets, assetMap: assetMap, scheduledActions: scheduledActions, syncContext: syncContext, enabledFeatures: { unpublish: enabledUnpublish, archive: enabledArchive, unarchive: enabledUnarchive, scheduledActions: enabledScheduledActions, documentVersions: enabledDocumentVersions, assetsEditing: enabledAssetsEditing } }; } private onContentChange( contentSourceId: string, contentChanges: CSITypes.ContentChanges ): { contentChangeResult: ContentStoreTypes.ContentChangeResult; presetsUpdated: boolean } { // certain content changes, like preset changes are interpreted as schema changes let presetsUpdated = false; const contentChangesFull: Required = { documents: contentChanges.documents ?? [], assets: contentChanges.assets ?? [], scheduledActions: contentChanges.scheduledActions ?? [], deletedDocumentIds: contentChanges.deletedDocumentIds ?? [], deletedAssetIds: contentChanges.deletedAssetIds ?? [], deletedScheduledActionIds: contentChanges.deletedScheduledActionIds ?? [] }; this.logger.debug('onContentChange', { contentSourceId, documentCount: contentChangesFull.documents.length, assetCount: contentChangesFull.assets.length, scheduledActionCount: contentChangesFull.scheduledActions.length, deletedDocumentCount: contentChangesFull.deletedDocumentIds.length, deletedAssetCount: contentChangesFull.deletedAssetIds.length, deletedScheduledActionCount: contentChangesFull.deletedScheduledActionIds.length }); const result: ContentStoreTypes.ContentChangeResult = { createdDocuments: [], createdAssets: [], createdScheduledActions: [], updatedDocuments: [], updatedAssets: [], updatedScheduledActions: [], deletedDocuments: [], deletedAssets: [], deletedScheduledActions: [] }; const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); // update contentSourceData with deleted documents contentChangesFull.deletedDocumentIds.forEach((docId) => { // remove preset, make sure there is something to remove first because // were explicitly calling onContentChange from deletePreset as well if (this.presets[docId] && contentSourceData.csiDocumentMap[docId]?.modelName === STACKBIT_PRESET_MODEL_NAME) { presetsUpdated = true; const preset = this.presets[docId]!; const model = contentSourceData.modelMap[preset.modelName]; delete this.presets[docId]; if (model && model.presets) { const presetIdIndex = model.presets.findIndex((presetId) => presetId === docId); model.presets.splice(presetIdIndex, 1); } } // delete document from documents map delete contentSourceData.documentMap[docId]; delete contentSourceData.csiDocumentMap[docId]; // delete document from document array const index = contentSourceData.documents.findIndex((document) => document.srcObjectId === docId); if (index !== -1) { // the indexes of documents and csiDocuments are always the same as they are always updated at the same time contentSourceData.documents.splice(index, 1); contentSourceData.csiDocuments.splice(index, 1); } result.deletedDocuments.push({ srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, srcObjectId: docId }); }); // update contentSourceData with deleted assets contentChangesFull.deletedAssetIds.forEach((assetId) => { // delete document from asset map delete contentSourceData.assetMap[assetId]; delete contentSourceData.csiAssetMap[assetId]; // delete document from asset array const index = contentSourceData.assets.findIndex((asset) => asset.srcObjectId === assetId); if (index !== -1) { // the indexes of assets and csiAssets are always the same as they are always updated at the same time contentSourceData.assets.splice(index, 1); contentSourceData.csiAssets.splice(index, 1); } result.deletedAssets.push({ srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, srcObjectId: assetId }); }); // update contentSourceData with deleted scheduledActions contentChangesFull.deletedScheduledActionIds.forEach((scheduledActionId) => { // delete scheduledAction from scheduledActions array const index = contentSourceData.scheduledActions.findIndex((scheduledAction) => scheduledAction.id === scheduledActionId); if (index !== -1) { contentSourceData.scheduledActions.splice(index, 1); } result.deletedScheduledActions.push({ srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, scheduledActionId: scheduledActionId }); }); // map csi documents through stackbitConfig.mapDocuments let mappedDocs = contentChangesFull.documents; if (this.stackbitConfig?.mapDocuments) { const csiDocumentsWithSource = contentChangesFull.documents.map( (csiDocument): CSITypes.DocumentWithSource => ({ srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, ...csiDocument }) ); const modelsWithSource = contentSourceData.models.map((model): CSITypes.ModelWithSource => { return { srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, ...model }; }); mappedDocs = this.stackbitConfig?.mapDocuments?.({ documents: _.cloneDeep(csiDocumentsWithSource), models: _.cloneDeep(modelsWithSource) }) ?? csiDocumentsWithSource; } // map csi documents and assets to content store documents and assets const documents = mapCSIDocumentsToStoreDocuments({ csiDocuments: mappedDocs, contentSourceInstance: contentSourceData.instance, modelMap: contentSourceData.modelMap, defaultLocaleCode: contentSourceData.defaultLocaleCode, assetSources: this.stackbitConfig?.assetSources ?? [], createConfigDelegate: getCreateConfigDelegateThunk({ getContentSourceDataById: () => this.contentSourceDataById, logger: this.userLogger }), logger: this.userLogger }); const assets = mapCSIAssetsToStoreAssets({ csiAssets: contentChangesFull.assets, contentSourceInstance: contentSourceData.instance, defaultLocaleCode: contentSourceData.defaultLocaleCode }); // update contentSourceData with new or updated documents and assets Object.assign(contentSourceData.csiDocumentMap, _.keyBy(contentChangesFull.documents, 'id')); Object.assign(contentSourceData.csiAssetMap, _.keyBy(contentChangesFull.assets, 'id')); Object.assign(contentSourceData.documentMap, _.keyBy(documents, 'srcObjectId')); Object.assign(contentSourceData.assetMap, _.keyBy(assets, 'srcObjectId')); for (let idx = 0; idx < documents.length; idx++) { // the indexes of mapped documents and documents from changeEvent are the same const document = documents[idx]!; const csiDocument = contentChangesFull.documents[idx]!; const dataIndex = contentSourceData.documents.findIndex((existingDoc) => existingDoc.srcObjectId === document.srcObjectId); const isNewDoc = dataIndex === -1; if (isNewDoc) { contentSourceData.documents.push(document); contentSourceData.csiDocuments.push(csiDocument); } else { contentSourceData.documents.splice(dataIndex, 1, document); contentSourceData.csiDocuments.splice(dataIndex, 1, csiDocument); } if (csiDocument.modelName === STACKBIT_PRESET_MODEL_NAME) { presetsUpdated = true; const preset = getPresetFromDocument({ srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, csiDocument, csiAssetMap: contentSourceData.csiAssetMap, logger: this.logger }); if (preset) { this.presets[csiDocument.id] = preset; if (dataIndex === -1) { //TODO recalculate assets as well contentSourceData.modelMap[preset.modelName]?.presets?.push(csiDocument.id); } } } result[isNewDoc ? 'createdDocuments' : 'updatedDocuments'].push({ srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, srcObjectId: document.srcObjectId }); } for (let idx = 0; idx < assets.length; idx++) { // the indexes of mapped assets and assets from changeEvent are the same const asset = assets[idx]!; const csiAsset = contentChangesFull.assets[idx]!; const index = contentSourceData.assets.findIndex((existingAsset) => existingAsset.srcObjectId === asset.srcObjectId); const isNewAsset = index === -1; if (isNewAsset) { contentSourceData.assets.push(asset); contentSourceData.csiAssets.push(csiAsset); } else { // the indexes of assets and csiAssets are always the same as they are always updated at the same time contentSourceData.assets.splice(index, 1, asset); contentSourceData.csiAssets.splice(index, 1, csiAsset); } result[isNewAsset ? 'createdAssets' : 'updatedAssets'].push({ srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, srcObjectId: asset.srcObjectId }); } const scheduledActions = contentChangesFull.scheduledActions; for (let idx = 0; idx < scheduledActions.length; idx++) { // the indexes of mapped assets and assets from changeEvent are the same const scheduledAction = scheduledActions[idx]!; const index = contentSourceData.scheduledActions.findIndex((existingScheduledAction) => existingScheduledAction.id === scheduledAction.id); const isNewAction = index === -1; if (isNewAction) { contentSourceData.scheduledActions.push(scheduledAction); } else { contentSourceData.scheduledActions.splice(index, 1, scheduledAction); } result[isNewAction ? 'createdScheduledActions' : 'updatedScheduledActions'].push({ srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, scheduledActionId: scheduledAction.id }); } return { contentChangeResult: result, presetsUpdated }; } private async processData({ stackbitConfig, configModels, presets, contentSourceRawDataArr }: { stackbitConfig: Config | null; configModels: ModelExtension[]; presets: Record; contentSourceRawDataArr: ContentSourceRawData[]; }): Promise> { this.logger.debug('processData'); // Group models from all content sources by their names const csiModelGroups = contentSourceRawDataArr.reduce((modelGroups: Record, csData) => { return csData.csiModels.reduce((modelGroups, model) => { if (!(model.name in modelGroups)) { modelGroups[model.name] = []; } modelGroups[model.name]!.push({ srcType: csData.srcType, srcProjectId: csData.srcProjectId, ...model }); return modelGroups; }, modelGroups); }, {}); // Match config models to the group of content source models with the same name. // Then, match the config model to content source model by comparing srcType and // srcProjectId. If after the comparison, there are more than one model left, // log a warning and filter out that config model so it won't be merged with any // of the content source models. const nonMatchedModels: { configModel: ModelExtension; matchedCSIModels: CSITypes.ModelWithSource[] }[] = []; const configModelsByContentSourceId = configModels.reduce((modelGroups: Record, configModel) => { const csiModels = csiModelGroups[configModel.name]; if (!csiModels) { nonMatchedModels.push({ configModel, matchedCSIModels: [] }); return modelGroups; } const matchedCSIModels = csiModels.filter((model) => { const matchesType = !configModel.srcType || model.srcType === configModel.srcType; const matchesId = !configModel.srcProjectId || model.srcProjectId === configModel.srcProjectId; return matchesType && matchesId; }); if (matchedCSIModels.length !== 1) { nonMatchedModels.push({ configModel, matchedCSIModels }); return modelGroups; } const contentSource = matchedCSIModels[0]!; const contentSourceId = getContentSourceId(contentSource.srcType, contentSource.srcProjectId); append(modelGroups, contentSourceId, configModel); return modelGroups; }, {}); // Log model matching warnings using user logger for (const { configModel, matchedCSIModels } of nonMatchedModels) { let configModelMessage = `model name: '${configModel.name}'`; if (configModel.srcType) { configModelMessage += `, srcType: '${configModel.srcType}'`; } if (configModel.srcProjectId) { configModelMessage += `, srcProjectId: '${configModel.srcProjectId}'`; } configModelMessage = configModelMessage + ` defined in stackbit config`; let contentSourceModelsMessage; if (matchedCSIModels.length) { const matchesModelsMessage = matchedCSIModels.map((model) => `srcType: '${model.srcType}', srcProjectId: '${model.srcProjectId}'`).join('; '); contentSourceModelsMessage = ` matches more that 1 model in the following content sources: ${matchesModelsMessage}`; } else { contentSourceModelsMessage = ' does not match any content source model'; } this.userLogger.warn(configModelMessage + contentSourceModelsMessage); } const modelsWithSource = contentSourceRawDataArr.reduce((accum: CSITypes.ModelWithSource[], csData) => { const contentSourceId = getContentSourceId(csData.srcType, csData.srcProjectId); const mergedModels = mergeConfigModelsWithExternalModels({ configModels: configModelsByContentSourceId[contentSourceId] ?? [], externalModels: csData.csiModels, logger: this.userLogger }); const modelsWithSource = mergedModels.map((model): CSITypes.ModelWithSource => { return { srcType: csData.srcType, srcProjectId: csData.srcProjectId, ...model }; }); return accum.concat(modelsWithSource); }, []); // TODO: Is there a better way than deep cloning objects before passing them to user methods? // Not cloning mutable objects will break the internal state if user mutates the objects. const mappedModels = stackbitConfig?.mapModels?.({ models: _.cloneDeep(modelsWithSource) }) ?? modelsWithSource; const normalizedModels = normalizeModels({ models: mappedModels, logger: this.userLogger }); const validatedModels = validateModels({ models: normalizedModels, logger: this.userLogger }); const modelsWithPresetsIds = extendModelsWithPresetsIds({ models: validatedModels, presets }); const { models } = await this.handleConfigAssets({ models: modelsWithPresetsIds }); let documentMapByContentSource: Record> | null = null; if (stackbitConfig?.mapDocuments) { const csiDocumentsWithSource = contentSourceRawDataArr.reduce((accum: CSITypes.DocumentWithSource[], csData) => { const csiDocumentsWithSource = csData.csiDocuments.map( (csiDocument): CSITypes.DocumentWithSource => ({ srcType: csData.srcType, srcProjectId: csData.srcProjectId, ...csiDocument }) ); return accum.concat(csiDocumentsWithSource); }, []); // TODO: Is there a better way than deep cloning objects before passing them to user methods? // Not cloning mutable objects will break the internal state if user mutates the objects. const mappedDocs = stackbitConfig?.mapDocuments?.({ documents: _.cloneDeep(csiDocumentsWithSource), models: _.cloneDeep(models) }) ?? csiDocumentsWithSource; documentMapByContentSource = groupDocumentsByContentSource({ documents: mappedDocs }); } const modelMapByContentSource = groupModelsByContentSource({ models: models }); const contentSourceDataArr = contentSourceRawDataArr.map((csData): ContentSourceData => { const modelMap = _.get(modelMapByContentSource, [csData.srcType, csData.srcProjectId], {}); const csiDocuments = documentMapByContentSource ? _.get(documentMapByContentSource, [csData.srcType, csData.srcProjectId], []) : csData.csiDocuments; const documents = mapCSIDocumentsToStoreDocuments({ csiDocuments: csiDocuments, contentSourceInstance: csData.instance, modelMap: modelMap, defaultLocaleCode: csData.defaultLocaleCode, assetSources: this.stackbitConfig?.assetSources ?? [], createConfigDelegate: getCreateConfigDelegateThunk({ getContentSourceDataById: () => this.contentSourceDataById, logger: this.userLogger }), logger: this.userLogger }); return { ...csData, models: Object.values(modelMap), modelMap, documents, documentMap: _.keyBy(documents, 'srcObjectId') }; }); this.logger.debug('processData finished'); return _.keyBy(contentSourceDataArr, 'id'); } private calculateReferenceMap() { const documents = [...this.getDocuments(), ...this.getAssets()]; this.referenceMap = utils.deepFreeze(getReferenceMap(documents)); } getReferenceMap() { return this.referenceMap; } getContentSourceMeta(): { srcType: string; srcProjectId: string; srcVersion: string; csiVersion: string; supportsUnpublish: boolean; supportsArchive: boolean; supportsUnarchive: boolean; supportsScheduledActions: boolean; supportsDocumentVersions: boolean; supportsAssetsEditing: boolean; }[] { return _.reduce( this.contentSourceDataById, ( result: { srcType: string; srcProjectId: string; srcVersion: string; csiVersion: string; supportsUnpublish: boolean; supportsArchive: boolean; supportsUnarchive: boolean; supportsScheduledActions: boolean; supportsDocumentVersions: boolean; supportsAssetsEditing: boolean; }[], contentSourceData ) => { return result.concat({ srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, srcVersion: contentSourceData.version.contentSourceVersion, csiVersion: contentSourceData.version.interfaceVersion, supportsUnpublish: contentSourceData?.enabledFeatures.unpublish ?? false, supportsArchive: contentSourceData?.enabledFeatures.archive ?? false, supportsUnarchive: contentSourceData?.enabledFeatures.unarchive ?? false, supportsScheduledActions: contentSourceData?.enabledFeatures.scheduledActions ?? false, supportsDocumentVersions: contentSourceData?.enabledFeatures.documentVersions ?? false, supportsAssetsEditing: contentSourceData?.enabledFeatures.assetsEditing ?? false }); }, [] ); } getAssetSources(): CSITypes.DistributiveOmit[] { return getAssetSourcesForClient(this.stackbitConfig); } getModels({ user }: { user?: ContentStoreTypes.User } = {}): Record>> { const configDelegate = createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }); return _.reduce( this.contentSourceDataById, (result: Record>>, contentSourceData) => { const contentSourceType = contentSourceData.instance.getContentSourceType(); const srcProjectId = contentSourceData.instance.getProjectId(); const filteredModels = getContentSourceFilteredModelsForUser({ user, configDelegate, contentSourceData, permissionsForModel: this.stackbitConfig?.permissionsForModel }); const modelsMap = getModelMap({ models: filteredModels }); // if `projectId` is number (even as string) e.g., '1234', _.set() will create an array of length 1235 and insert the item at the end. // _.setWith(..., Object) ensures the values are always created as object keys, not as array indexes. _.setWith(result, [contentSourceType, srcProjectId], modelsMap, Object); _.setWith(result, [contentSourceType, srcProjectId, '__image_model'], IMAGE_MODEL, Object); _.setWith(result, [contentSourceType, srcProjectId, '__asset_model'], ASSET_MODEL, Object); return result; }, {} ); } getLocales(): ContentStoreTypes.ContentStoreLocale[] { return _.reduce( this.contentSourceDataById, (result: ContentStoreTypes.ContentStoreLocale[], contentSourceData) => { return result.concat({ srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, locales: contentSourceData.locales?.map((locale) => locale.code) ?? [], defaultLocale: contentSourceData.defaultLocaleCode }); }, [] ); } async getGlobalActions({ pageUrl, user, locale, currentPageDocument }: { pageUrl?: string; user?: ContentStoreTypes.User; locale?: string; currentPageDocument?: ContentStoreTypes.APICustomActionDocumentSpecifier; }): Promise<(ContentStoreTypes.APICustomActionGlobal | ContentStoreTypes.APICustomActionBulk | ContentStoreTypes.APICustomActionModel)[]> { return getGlobalAndBulkAPIActions({ stackbitConfig: this.stackbitConfig, customActionRunStateMap: this.customActionRunStateMap, contentSourceDataById: this.contentSourceDataById, userLogger: this.userLogger, locale, pageUrl, user, currentPageDocument }); } async getCustomActions(getActionRequest: ContentStoreTypes.APIGetCustomActionRequest): Promise { return resolveCustomActionsById({ getActionRequest, customActionRunStateMap: this.customActionRunStateMap, contentSourceDataById: this.contentSourceDataById, stackbitConfig: this.stackbitConfig, userLogger: this.userLogger }); } async getRunningCustomActionsForUser( getRunningActionsRequest: ContentStoreTypes.APIGetRunningCustomActionsRequest ): Promise { return getRunningActions({ getRunningActionsRequest, customActionRunStateMap: this.customActionRunStateMap, contentSourceDataById: this.contentSourceDataById, stackbitConfig: this.stackbitConfig, userLogger: this.userLogger }); } async runCustomAction(runActionRequest: ContentStoreTypes.APIRunCustomActionRequest): Promise { // This method runs the action but doesn't wait for the action to finish and returns. // The result is delivered asynchronously via "customActionStateChanged" notification. const { actionId } = await runCustomAction({ runActionRequest: runActionRequest, contentSourceDataById: this.contentSourceDataById, customActionRunStateMap: this.customActionRunStateMap, userLogger: this.userLogger, stackbitConfig: this.stackbitConfig, presets: this.presets, onProgress: (actionStateChange) => { this.onActionStateChangeCallback(actionStateChange); } }); return { actionId }; } getPresets({ locale }: { locale?: string } = {}): Record { if (!this.presets || !locale) { return this.presets ?? {}; } return _.pickBy(this.presets, (preset) => !preset.locale || preset.locale === locale); } getContentSourceEnvironment({ srcProjectId, srcType }: { srcProjectId: string; srcType: string }): string { const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); return contentSourceData.instance.getProjectEnvironment(); } usesContentSourcePresets() { return Boolean(this.presetsContentSource); } async hasAccess({ srcType, srcProjectId, user }: { srcType?: string; srcProjectId?: string; user?: ContentStoreTypes.User; }): Promise { let contentSourceDataArr: ContentSourceData[]; if (srcType && srcProjectId) { const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); contentSourceDataArr = [contentSourceData]; } else { contentSourceDataArr = Object.values(this.contentSourceDataById); } return reducePromise( contentSourceDataArr, async (accum: ContentStoreTypes.HasAccessResult, contentSourceData) => { const srcType = contentSourceData.srcType; const srcProjectId = contentSourceData.srcProjectId; const userContext = getUserContextForSrcType(srcType, user); const result = await contentSourceData.instance.hasAccess({ userContext }); return { hasConnection: accum.hasConnection && result.hasConnection, hasPermissions: accum.hasPermissions && result.hasPermissions, contentSources: accum.contentSources.concat({ srcType, srcProjectId, ...result }) }; }, { hasConnection: true, hasPermissions: true, contentSources: [] } ); } hasChanges({ srcType, srcProjectId, documents }: { srcType?: string; srcProjectId?: string; documents?: { srcType: string; srcProjectId: string; srcObjectId: string }[]; }): { hasChanges: boolean; changedObjects: { srcType: string; srcProjectId: string; srcObjectId: string; }[]; } { let result: (ContentStoreTypes.Document | ContentStoreTypes.Asset)[]; if (srcType && srcProjectId) { const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); result = [...contentSourceData.documents, ...contentSourceData.assets]; } else if (documents && documents.length > 0) { const documentsBySourceId = _.groupBy(documents, (document) => getContentSourceId(document.srcType, document.srcProjectId)); result = _.reduce( documentsBySourceId, (result: (ContentStoreTypes.Document | ContentStoreTypes.Asset)[], documents, contentSourceId) => { const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); for (const document of documents) { if (document.srcObjectId in contentSourceData.documentMap) { result.push(contentSourceData.documentMap[document.srcObjectId]!); } else if (document.srcObjectId in contentSourceData.assetMap) { result.push(contentSourceData.assetMap[document.srcObjectId]!); } } return result; }, [] ); } else { result = _.reduce( this.contentSourceDataById, (result: (ContentStoreTypes.Document | ContentStoreTypes.Asset)[], contentSourceData) => { return result.concat(contentSourceData.documents, contentSourceData.assets); }, [] ); } const changedDocuments = result.filter((document) => document.status === 'added' || document.status === 'modified'); return { hasChanges: !_.isEmpty(changedDocuments), changedObjects: changedDocuments.map((item) => ({ srcType: item.srcType, srcProjectId: item.srcProjectId, srcObjectId: item.srcObjectId })) }; } getSiteMapEntries({ locale, user }: { locale?: string; user?: ContentStoreTypes.User } = {}): CSITypes.SiteMapEntry[] { const siteMapEntries = _.reduce( this.siteMapEntryGroups, (accum: CSITypes.SiteMapEntry[], siteMapEntryGroup) => { return _.reduce( siteMapEntryGroup, (accum: CSITypes.SiteMapEntry[], siteMapEntry) => { if (!_.isEmpty(locale)) { // filter out in wrong locale if (siteMapEntry.locale && siteMapEntry.locale !== locale) { return accum; } } if ('document' in siteMapEntry) { // check for hidden documents const contentSourceId = getContentSourceId(siteMapEntry.document.srcType, siteMapEntry.document.srcProjectId); const document = getContentSourceDataByIdOrThrow(contentSourceId, this.contentSourceDataById)?.documentMap[ siteMapEntry.document.id ]; if (document) { const [filteredDocument] = getFilteredDocumentsForUser({ user, documents: [document], permissionsForModel: this.stackbitConfig?.permissionsForModel, permissionsForDocument: this.stackbitConfig?.permissionsForDocument, contentSourceDataById: this.contentSourceDataById, createConfigDelegate: getCreateConfigDelegateThunk({ getContentSourceDataById: () => this.contentSourceDataById, logger: this.userLogger }), logger: this.userLogger }); if (filteredDocument && filteredDocument.hidden) { return accum; } } } if (!siteMapEntry.label) { const fieldLabelValue = getDocumentFieldLabelValueForSiteMapEntry({ siteMapEntry, locale, contentSourceDataById: this.contentSourceDataById }); siteMapEntry = { ...siteMapEntry, label: fieldLabelValue ?? siteMapEntry.urlPath }; } accum.push(siteMapEntry); return accum; }, accum ); }, [] ); if (user && this.stackbitConfig?.transformSitemap) { const configDelegate = createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }); return this.stackbitConfig.transformSitemap({ ...configDelegate, sitemap: _.cloneDeep(siteMapEntries), userContext: user }); } return siteMapEntries; } getTreeViews({ user }: { user?: ContentStoreTypes.User } = {}): CSITypes.TreeViewNode[] { let treeViews = this.treeViews; if (this.stackbitConfig?.transformTreeViews && user) { const configDelegate = createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }); treeViews = this.stackbitConfig.transformTreeViews({ ...configDelegate, treeViews: _.cloneDeep(treeViews), userContext: user }); } return removeHiddenTreeViews({ treeViews, getDocumentForUser: (opts) => { const contentSourceId = getContentSourceId(opts.srcType, opts.srcProjectId); const document = getContentSourceDataByIdOrThrow(contentSourceId, this.contentSourceDataById)?.documentMap[opts.srcDocumentId]; if (!document || !user) { return document; } const [filteredDocument] = getFilteredDocumentsForUser({ user, documents: [document], permissionsForModel: this.stackbitConfig?.permissionsForModel, permissionsForDocument: this.stackbitConfig?.permissionsForDocument, contentSourceDataById: this.contentSourceDataById, createConfigDelegate: getCreateConfigDelegateThunk({ getContentSourceDataById: () => this.contentSourceDataById, logger: this.userLogger }), logger: this.userLogger }); return filteredDocument; } }); } getSiteMapEntriesForDocument({ srcType, srcProjectId, srcDocumentId, locale }: { srcType: string; srcProjectId: string; srcDocumentId: string; locale?: string; }): CSITypes.SiteMapEntry[] { const key = getObjectId(srcType, srcProjectId, srcDocumentId); const siteMapEntryGroup = this.siteMapEntryGroups[key]; const siteMapEntries = _.values(siteMapEntryGroup); return _.isEmpty(locale) ? siteMapEntries : siteMapEntries.filter((siteMapEntry) => !siteMapEntry.locale || siteMapEntry.locale === locale); } getDocument({ srcDocumentId, srcProjectId, srcType, user }: { srcDocumentId: string; srcProjectId: string; srcType: string; user?: ContentStoreTypes.User; }): ContentStoreTypes.Document | undefined { const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const document = contentSourceData.documentMap[srcDocumentId]; if (!document) { return document; } const [filteredDocument] = getFilteredDocumentsForUser({ user, documents: [document], permissionsForModel: this.stackbitConfig?.permissionsForModel, permissionsForDocument: this.stackbitConfig?.permissionsForDocument, contentSourceDataById: this.contentSourceDataById, createConfigDelegate: getCreateConfigDelegateThunk({ getContentSourceDataById: () => this.contentSourceDataById, logger: this.userLogger }), logger: this.userLogger }); return filteredDocument ?? document; } getDocumentsByContext({ context, srcProjectId, srcType }: { context: any; srcProjectId?: string; srcType: string }): ContentStoreTypes.Document[] { const contentSourcesData: ContentStoreTypes.ContentSourceData[] = findContentSourcesDataForTypeOrId({ contentSourceDataById: this.contentSourceDataById, srcType, srcProjectId }); return _.reduce( contentSourcesData, (documents: ContentStoreTypes.Document[], csData: ContentStoreTypes.ContentSourceData) => { const matchingDocuments = _.filter(csData.csiDocuments, { context }) .map((document) => csData.documentMap[document.id]) .filter(Boolean); return [...documents, ...matchingDocuments] as ContentStoreTypes.Document[]; }, [] ); } getDocuments({ locale, user }: { locale?: string; user?: ContentStoreTypes.User } = {}): ContentStoreTypes.Document[] { const documents = _.reduce( this.contentSourceDataById, (documents: ContentStoreTypes.Document[], contentSourceData) => { const currentDocuments = _.isEmpty(locale) ? contentSourceData.documents : contentSourceData.documents.filter((document) => !document.locale || document.locale === locale); const filteredDocuments = currentDocuments.filter((document) => document.srcModelName !== STACKBIT_PRESET_MODEL_NAME); return documents.concat(filteredDocuments); }, [] ); const filteredDocuments = getFilteredDocumentsForUser({ user, documents, permissionsForModel: this.stackbitConfig?.permissionsForModel, permissionsForDocument: this.stackbitConfig?.permissionsForDocument, contentSourceDataById: this.contentSourceDataById, createConfigDelegate: getCreateConfigDelegateThunk({ getContentSourceDataById: () => this.contentSourceDataById, logger: this.userLogger }), logger: this.userLogger }); return filteredDocuments; } getApiDocuments({ documentSpecs, user }: { documentSpecs?: DocumentSpecifier[]; user?: ContentStoreTypes.User } = {}): { documents: ContentStoreTypes.APIDocument[]; } { let filteredDocuments: ContentStoreTypes.Document[]; const deleteFieldsObjects: string[] = []; if (documentSpecs?.length) { // filter over documentSpecs filteredDocuments = documentSpecs?.reduce((acc: ContentStoreTypes.Document[], docSpec) => { const contentSourceId = getContentSourceId(docSpec.srcType, docSpec.srcProjectId); const document = this.contentSourceDataById[contentSourceId]?.documentMap[docSpec.srcDocumentId]; if (document && document.srcModelName !== STACKBIT_PRESET_MODEL_NAME) { if (docSpec.omitFields) { deleteFieldsObjects.push(getObjectId(document.srcType, document.srcProjectId, document.srcObjectId)); } acc.push(document); } return acc; }, []); } else { // filter over all documents filteredDocuments = _.reduce( this.contentSourceDataById, (accDocuments: ContentStoreTypes.Document[], contentSourceData) => { return accDocuments.concat(contentSourceData.documents.filter((document) => document.srcModelName !== STACKBIT_PRESET_MODEL_NAME)); }, [] ); } filteredDocuments = getFilteredDocumentsForUser({ user, documents: filteredDocuments, permissionsForModel: this.stackbitConfig?.permissionsForModel, permissionsForDocument: this.stackbitConfig?.permissionsForDocument, contentSourceDataById: this.contentSourceDataById, createConfigDelegate: getCreateConfigDelegateThunk({ getContentSourceDataById: () => this.contentSourceDataById, logger: this.userLogger }), logger: this.userLogger }); const documents = mapDocumentsToApiDocuments({ documents: filteredDocuments, contentSourceDataById: this.contentSourceDataById, delegate: createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }), referenceMap: this.referenceMap }); deleteFieldsObjects.forEach((objectId) => { const document = documents.find((document) => objectId === getObjectId(document.srcType, document.srcProjectId, document.srcObjectId)); delete document?.fields; }); return { documents }; } getCSIDocuments({ documentSpecs, srcType, srcProjectId, limit = 100, offset = 0 }: { documentSpecs?: DocumentSpecifier[]; srcType?: string; srcProjectId?: string; limit?: number; offset?: number; } = {}): { total: number; offset: number; documents: CSITypes.Document[]; } { // If document specs provided, return the specified documents. Don't use limit or offset. if (documentSpecs?.length) { const documents = documentSpecs.reduce((csiDocuments: CSITypes.Document[], docSpec) => { const contentSourceId = getContentSourceId(docSpec.srcType, docSpec.srcProjectId); const document = this.contentSourceDataById[contentSourceId]?.csiDocumentMap[docSpec.srcDocumentId]; if (document && document.modelName !== STACKBIT_PRESET_MODEL_NAME) { csiDocuments.push(document); } return csiDocuments; }, []); return { total: documents.length, offset: 0, documents }; } else { // Filter content sources by srcType and srcProjectId, then accumulate all their // csiDocuments and return sliced array according to offset and limit. const contentSourcesData = findContentSourcesDataForTypeOrId({ contentSourceDataById: this.contentSourceDataById, srcType, srcProjectId }); const documents = _.reduce( contentSourcesData, (csiDocuments: CSITypes.Document[], contentSourceData) => { return csiDocuments.concat(contentSourceData.csiDocuments.filter((document) => document.modelName !== STACKBIT_PRESET_MODEL_NAME)); }, [] ); return { total: documents.length, offset, documents: documents.slice(offset, offset + limit) }; } } async getStagedChanges({ scope, locale, shallow, objects, user }: { scope: 'all' | 'code' | 'content'; locale?: string; shallow?: boolean; objects: { srcObjectId: string; srcProjectId: string; srcType: string; srcEnvironment?: string }[]; user?: ContentStoreTypes.User; }): Promise { const documents = this.getDocuments({ user, locale }); const assets = this.getAssets({ user, locale }); const allObjects = [...documents, ...assets]; const isPublishingSpecificDocuments = scope === 'content' && objects.length; let publishableObjects: (ContentStoreTypes.Document | ContentStoreTypes.Asset)[]; // filter content only if scope is content, if scope is all ignore objects if (isPublishingSpecificDocuments) { const objectIds = objects.map((object) => object.srcObjectId); // also add all images which has 'added' status to the list publishableObjects = [ ..._.filter(documents, (document) => objectIds.includes(document.srcObjectId)), ..._.filter(assets, (asset) => objectIds.includes(asset.srcObjectId) || asset.status === 'added') ]; } else { // scope === 'all' or scope === 'content' with empty objects publishableObjects = allObjects; } const visited: string[] = publishableObjects.map((object) => getObjectId(object.srcType, object.srcProjectId, object.srcObjectId)); const findUsedChangedObjects = ( rootObject: ContentStoreTypes.Document, fields: ContentStoreTypes.DocumentField[] ): Array => { return _.flatMap(fields, (field) => { const localizedField = getDocumentFieldForLocale(field, locale); if (localizedField?.type === 'list') { return findUsedChangedObjects(rootObject, localizedField.items); } else if (localizedField?.type === 'object' || localizedField?.type === 'model') { return localizedField.isUnset ? [] : findUsedChangedObjects(rootObject, Object.values(localizedField.fields)); } else if (localizedField?.type === 'reference') { if (localizedField.isUnset) { return []; } const { refId } = localizedField; const objectStrId = getObjectId(rootObject.srcType, rootObject.srcProjectId, refId); if (visited.includes(objectStrId)) { return []; } visited.push(objectStrId); const refObject = _.find(allObjects, { srcObjectId: refId, srcType: rootObject.srcType, srcProjectId: rootObject.srcProjectId }); if (refObject) { if (refObject.type === 'asset') { return refObject.isChanged ? [refObject] : []; } return [...(refObject.isChanged ? [refObject] : []), ...findUsedChangedObjects(refObject, Object.values(refObject.fields))]; } } return []; }); }; const changes = publishableObjects.reduce((result: ContentStoreTypes.StagedChange[], object: ContentStoreTypes.Document | ContentStoreTypes.Asset) => { const changedObjects = []; if (object.isChanged) { changedObjects.push(object); } if (isPublishingSpecificDocuments && object.type !== 'asset' && !shallow) { // when publishing specific documents, go over changed fields and see if their reference objects are not changed in case // it's not already in the publishableObjects list; changedObjects.push(...findUsedChangedObjects(object, Object.values(object.fields))); } result.push( ...changedObjects.map( (object): ContentStoreTypes.StagedChange => ({ changeId: object.srcObjectId, changeType: object.type === 'document' ? 'content' : 'asset', label: object.type === 'asset' ? object.srcObjectLabel : object.getPreview({ locale }).previewTitle, status: object.status, srcModelName: object.srcModelName, srcObjectId: object.srcObjectId, srcProjectId: object.srcProjectId, srcEnvironment: object.srcEnvironment, srcType: object.srcType, createdAt: object.createdAt, createdBy: object.createdBy, updatedAt: object.updatedAt, updatedBy: object.updatedBy || [], locale: object.locale, ...('permissions' in object ? { permissions: object.permissions } : {}) }) ) ); return result; }, []); return changes; } getAsset({ srcAssetId, srcProjectId, srcType }: { srcAssetId: string; srcProjectId: string; srcType: string }): ContentStoreTypes.Asset | undefined { const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); return contentSourceData.assetMap[srcAssetId]; } getAssets({ locale, user }: { locale?: string; user?: ContentStoreTypes.User } = {}): ContentStoreTypes.Asset[] { const assets = _.reduce( this.contentSourceDataById, (assets: ContentStoreTypes.Asset[], contentSourceData) => { const currentAssets = _.isEmpty(locale) ? contentSourceData.assets : contentSourceData.assets.filter((asset) => !asset.locale || asset.locale === locale); return assets.concat(currentAssets); }, [] ); const filteredAssets = getFilteredAssetsForUser({ user, assets, filterAsset: this.stackbitConfig?.filterAsset, contentSourceDataById: this.contentSourceDataById, configDelegate: createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }) }); return filteredAssets; } getLocalizedApiObjects({ locale, objectIds, user }: { locale?: string; objectIds?: string[]; user?: ContentStoreTypes.User; }): ContentStoreTypes.APIObject[] { const hasExplicitLocale = !_.isEmpty(locale); return _.reduce( this.contentSourceDataById, (objects: ContentStoreTypes.APIObject[], contentSourceData) => { let documents = objectIds ? contentSourceData.documents.filter((document) => objectIds.includes(document.srcObjectId)) : contentSourceData.documents; documents = hasExplicitLocale ? documents.filter((document) => !document.locale || document.locale === locale) : documents; let assets = objectIds ? contentSourceData.assets.filter((asset) => objectIds.includes(asset.srcObjectId)) : contentSourceData.assets; assets = hasExplicitLocale ? assets.filter((asset) => !asset.locale || asset.locale === locale) : assets; const currentLocale = locale ?? contentSourceData.defaultLocaleCode; let filteredDocuments = documents.filter((document) => document.srcModelName !== STACKBIT_PRESET_MODEL_NAME); filteredDocuments = getFilteredDocumentsForUser({ user, documents: filteredDocuments, permissionsForModel: this.stackbitConfig?.permissionsForModel, permissionsForDocument: this.stackbitConfig?.permissionsForDocument, contentSourceDataById: this.contentSourceDataById, createConfigDelegate: getCreateConfigDelegateThunk({ getContentSourceDataById: () => this.contentSourceDataById, logger: this.userLogger }), logger: this.userLogger }); assets = getFilteredAssetsForUser({ user, assets, filterAsset: this.stackbitConfig?.filterAsset, contentSourceDataById: this.contentSourceDataById, configDelegate: createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }) }); const documentObjects = mapDocumentsToLocalizedApiObjects({ documents: filteredDocuments, locale: currentLocale, delegate: createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }) }); const imageObjects = mapAssetsToLocalizedApiImages(assets, this.staticAssetsPublicPath, currentLocale); return objects.concat(documentObjects, imageObjects); }, [] ); } getApiAssets( options: | { srcType?: string; srcProjectId?: string; pageSize?: number; pageNum?: number; searchQuery?: string; user?: ContentStoreTypes.User; } | { pageSize?: number; pageNum?: number; searchQuery?: string; user?: ContentStoreTypes.User; assetSpecs?: DocumentSpecifier[]; version?: 'v2'; locale?: string; } = {} ): { assets: ContentStoreTypes.APIAsset[] | ContentStoreTypes.Asset[]; pageSize: number; pageNum: number; totalPages: number; } { let assets: ContentStoreTypes.APIAsset[] | ContentStoreTypes.Asset[]; const { pageSize = 20, pageNum = 1, searchQuery, user } = options; if ('version' in options && options.version === 'v2') { const { assetSpecs, locale } = options; return this.getApiAssetsV2({ assetSpecs, pageSize, pageNum, searchQuery, user, locale }); } if ('srcProjectId' in options && options.srcProjectId && options.srcType) { const { srcProjectId, srcType } = options; const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const filteredAssets = getFilteredAssetsForUser({ user, assets: contentSourceData.assets, filterAsset: this.stackbitConfig?.filterAsset, contentSourceDataById: this.contentSourceDataById, configDelegate: createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }) }); assets = mapStoreAssetsToAPIAssets(filteredAssets, this.staticAssetsPublicPath, contentSourceData.defaultLocaleCode); } else { assets = _.reduce( this.contentSourceDataById, (result: ContentStoreTypes.APIAsset[], contentSourceData) => { const filteredAssets = getFilteredAssetsForUser({ user, assets: contentSourceData.assets, filterAsset: this.stackbitConfig?.filterAsset, contentSourceDataById: this.contentSourceDataById, configDelegate: createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }) }); const assets = mapStoreAssetsToAPIAssets(filteredAssets, this.staticAssetsPublicPath, contentSourceData.defaultLocaleCode); return result.concat(assets); }, [] ); } let filteredFiles = assets; if (searchQuery) { const sanitizedSearchQuery = sanitizeFilename(searchQuery).toLowerCase(); filteredFiles = assets.filter((asset) => asset.fileName && path.basename(asset.fileName).toLowerCase().includes(sanitizedSearchQuery)); } const sortedAssets = _.orderBy(filteredFiles, ['fileName'], ['asc']); const skip = (pageNum - 1) * pageSize; const totalPages = Math.ceil(filteredFiles.length / pageSize); const pagesAssets = sortedAssets.slice(skip, skip + pageSize); return { assets: pagesAssets, pageSize: pageSize, pageNum: pageNum, totalPages: totalPages }; } getApiAssetsV2({ assetSpecs, user, pageSize = 20, pageNum = 1, searchQuery, locale }: { srcType?: string; srcProjectId?: string; pageSize?: number; pageNum?: number; searchQuery?: string; user?: ContentStoreTypes.User; assetSpecs?: DocumentSpecifier[]; locale?: string; } = {}): { assets: ContentStoreTypes.Asset[]; pageSize: number; pageNum: number; totalPages: number; } { let contentSourceAssets; if (assetSpecs?.length) { contentSourceAssets = assetSpecs?.reduce((acc: ContentStoreTypes.Asset[], assetSpec) => { const contentSourceId = getContentSourceId(assetSpec.srcType, assetSpec.srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const asset = contentSourceData?.assetMap[assetSpec.srcDocumentId]; if (asset) { acc.push(asset); } return acc; }, []); } else { contentSourceAssets = _.reduce( this.contentSourceDataById, (result: ContentStoreTypes.Asset[], contentSourceData) => result.concat(...contentSourceData.assets), [] ); } let filteredAssets = getFilteredAssetsForUser({ user, assets: contentSourceAssets, filterAsset: this.stackbitConfig?.filterAsset, contentSourceDataById: this.contentSourceDataById, configDelegate: createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }) }); if (searchQuery) { const sanitizedSearchQuery = sanitizeFilename(searchQuery).toLowerCase(); filteredAssets = filteredAssets.filter((asset) => { const assetField = Object.values(asset.fields).find((field): field is ContentStoreTypes.AssetFileField => field.type === 'assetFile'); if (!assetField) { return; } let fileName; if (assetField.localized) { fileName = locale ? assetField.locales[locale]?.fileName : undefined; } else { fileName = assetField.fileName; } return fileName && path.basename(fileName).toLowerCase().includes(sanitizedSearchQuery); }); } filteredAssets = filteredAssets.map((asset) => { const assetField = Object.values(asset.fields).find((field): field is ContentStoreTypes.AssetFileField => field.type === 'assetFile'); if (!assetField) { return asset; } if (assetField.localized) { if (locale && assetField.locales[locale]) { assetField.locales[locale] = { ...assetField.locales[locale], locale, url: replaceAssetUrlIfNeeded(this.staticAssetsPublicPath, assetField.locales[locale]!.url) ?? this.staticAssetsPublicPath }; } } else { assetField.url = replaceAssetUrlIfNeeded(this.staticAssetsPublicPath, assetField.url) ?? this.staticAssetsPublicPath; } return asset; }); const sortedAssets = _.orderBy(filteredAssets, ['fileName'], ['asc']); let skip; let totalPages; let pagesAssets; // don't sort out assets in case assetsSpecs defined if (assetSpecs?.length) { skip = 0; totalPages = 1; pageNum = 1; pagesAssets = sortedAssets; pageSize = sortedAssets.length; } else { skip = (pageNum - 1) * pageSize; totalPages = Math.ceil(filteredAssets.length / pageSize); pagesAssets = assetSpecs?.length ? sortedAssets : sortedAssets.slice(skip, skip + pageSize); } return { assets: pagesAssets, pageSize: pageSize, pageNum: pageNum, totalPages: totalPages }; } async createAndLinkDocument({ srcType, srcProjectId, srcDocumentId, fieldPath, modelName, refSrcType, refProjectId, object, index, locale, user }: { srcType: string; srcProjectId: string; srcDocumentId: string; fieldPath: (string | number)[]; modelName?: string; refSrcType?: string; refProjectId?: string; object?: Record; index?: number; locale?: string; user?: ContentStoreTypes.User; }): Promise<{ srcDocumentId: string; createdDocumentId: string }> { this.logger.debug('createAndLinkDocument', { srcType, srcProjectId, srcDocumentId, fieldPath, modelName, refSrcType, refProjectId, index, locale }); const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); // get the document that is being updated const document = contentSourceData.documentMap[srcDocumentId]; const csiDocument = contentSourceData.csiDocumentMap[srcDocumentId]; if (!document || !csiDocument) { throw new Error(`Document not found: '${srcDocumentId}'. Source: '${contentSourceData.id}'.`); } const modelMap = contentSourceData.modelMap; const csiModelMap = contentSourceData.csiModelMap; // get the 'reference' model field in the updated document that will be used to link the new document locale = locale ?? contentSourceData.defaultLocaleCode; const modelField = getModelFieldAtFieldPath({ document, fieldPath, modelMap, locale }); const csiModelField = getModelFieldAtFieldPath({ document, fieldPath, modelMap: csiModelMap, locale }); if (!modelField || !csiModelField) { throw Error(`Field path not found:'${fieldPath.join('.')}'.`); } const fieldProps = modelField.type === 'list' ? modelField.items! : modelField; const csiFieldProps = csiModelField.type === 'list' ? csiModelField.items! : csiModelField; if (fieldProps.type !== 'reference' && fieldProps.type !== 'cross-reference') { throw new Error(`createAndLinkDocument can only be used on fields of type reference at field path: '${fieldPath.join('.')}'`); } // get the model name for the new document if (!modelName && fieldProps.models.length === 1) { if (fieldProps.type === 'reference') { modelName = fieldProps.models[0]; } else if (fieldProps.type === 'cross-reference') { modelName = fieldProps.models[0]!.modelName; } } if (!modelName) { throw new Error(`modelName is required for createAndLinkDocument. Field path: '${fieldPath.join('.')}'.`); } if (fieldProps.type === 'reference') { refSrcType = srcType; refProjectId = srcProjectId; } else if (!refSrcType || !refProjectId) { throw new Error(`refSrcType and refProjectId are required for linking fields of type cross-reference for field path: '${fieldPath.join('.')}'.`); } // create the new document const result = await this.createDocument({ object: object, srcProjectId: refProjectId, srcType: refSrcType, modelName: modelName, locale: locale, user: user }); // update the document by linking the field to the created document const userContext = getUserContextForSrcType(srcType, user); let field: CSITypes.UpdateOperationField; if (fieldProps.type === 'reference') { field = { type: 'reference', refType: 'document', refId: result.srcDocumentId } as CSITypes.UpdateOperationReferenceField; } else { if (!isOneOfFieldTypes(csiFieldProps.type, ['string', 'text', 'json', 'cross-reference'])) { throw new Error( `Invalid type for cross-reference field: ${ csiFieldProps.type }. Must be one of: string, text, json, cross-reference. Field path: '${fieldPath.join('.')}'.` ); } field = updateOperationValueFieldWithCrossReference(csiFieldProps.type, { refId: result.srcDocumentId, refSrcType: refSrcType, refProjectId: refProjectId }); } const operations: CSITypes.UpdateOperation[] = [ modelField.type === 'list' ? { opType: 'insert', fieldPath: fieldPath, modelField: csiModelField as CSITypes.FieldList, locale: locale, index: index, item: field } : { opType: 'set', fieldPath: fieldPath, modelField: csiModelField, locale: locale, field: field } ]; await this.updateDocumentHooked({ updateDocumentOptions: { document: csiDocument, modelMap: csiModelMap, userContext: userContext, operations: operations }, contentSourceData: contentSourceData, user: user }); return { srcDocumentId: srcDocumentId, createdDocumentId: result.srcDocumentId }; } async createPreset({ preset, thumbnailAsset, user }: { preset: Preset; thumbnailAsset: ContentStoreTypes.UploadAssetData; user?: ContentStoreTypes.User; }): Promise<{ srcDocumentId: string }> { if (!this.presetsContentSource) { throw new Error('Error saving preset: No content source available.'); } let thumbnail: string | undefined; if (thumbnailAsset) { const assets = await this.uploadAssets({ srcType: this.presetsContentSource.getContentSourceType(), srcProjectId: this.presetsContentSource.getProjectId(), assets: [thumbnailAsset], user }); thumbnail = assets[0]?.objectId; } const contentSourceData = this.getContentSourceDataByIdOrThrow(getContentSourceIdForContentSource(this.presetsContentSource)); const document = await this.createDocument({ srcType: this.presetsContentSource.getContentSourceType(), srcProjectId: this.presetsContentSource.getProjectId(), modelName: STACKBIT_PRESET_MODEL_NAME, object: { ...getDocumentObjectFromPreset(preset, contentSourceData.modelMap[STACKBIT_PRESET_MODEL_NAME]), thumbnail }, user }); return { srcDocumentId: document.srcDocumentId }; } async deletePreset({ presetId, user }: { presetId: string; user?: ContentStoreTypes.User }) { if (!this.presetsContentSource) { throw new Error('Error deleting preset: No content source available.'); } await this.deleteDocument({ srcType: this.presetsContentSource.getContentSourceType(), srcProjectId: this.presetsContentSource.getProjectId(), srcDocumentId: presetId, user }); // we delete presets immediately because some CMSs don't notify us // when documents have been deleted. const contentSourceId = getContentSourceIdForContentSource(this.presetsContentSource); this.pushContentSourceEvent({ eventName: ContentStoreEventType.ContentSourceContentChange, contentSourceId: contentSourceId, contentChanges: { documents: [], deletedDocumentIds: [presetId], assets: [], deletedAssetIds: [] } }); await this.processContentStoreEvents(); } async uploadAndLinkAsset({ srcType, srcProjectId, srcDocumentId, fieldPath, asset, index, locale, user }: { srcType: string; srcProjectId: string; srcDocumentId: string; fieldPath: (string | number)[]; asset: ContentStoreTypes.UploadAssetData; index?: number; locale?: string; user?: ContentStoreTypes.User; }): Promise<{ srcDocumentId: string }> { this.logger.debug('uploadAndLinkAsset', { srcType, srcProjectId, srcDocumentId, fieldPath, index, locale }); // get the document that is being updated const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const document = contentSourceData.documentMap[srcDocumentId]; const csiDocument = contentSourceData.csiDocumentMap[srcDocumentId]; if (!document || !csiDocument) { throw new Error(`Document not found: '${srcDocumentId}'. Source: '${contentSourceData.id}'.`); } const csiModelMap = contentSourceData.csiModelMap; // get the 'reference' model field in the updated document that will be used to link the new asset locale = locale ?? contentSourceData.defaultLocaleCode; const csiModelField = getModelFieldAtFieldPath({ document, fieldPath, modelMap: csiModelMap, locale }); if (!csiModelField) { throw Error(`Field path not found: '${fieldPath.join('.')}'.`); } const fieldProps = csiModelField.type === 'list' ? csiModelField.items! : csiModelField; if (fieldProps.type !== 'reference' && fieldProps.type !== 'image') { throw Error(`uploadAndLinkAsset can only be used on fields of type: reference, image. Field path: '${fieldPath.join('.')}'.`); } // upload the new asset const userContext = getUserContextForSrcType(srcType, user); const result = await contentSourceData.instance.uploadAsset({ url: asset.url, fileName: asset.metadata.name, mimeType: asset.metadata.type, locale: locale, userContext: userContext }); // update the document by linking the field to the created asset const field = { type: 'reference', refType: 'asset', refId: result.id } as const; const operations: CSITypes.UpdateOperation[] = [ csiModelField.type === 'list' ? { opType: 'insert', fieldPath: fieldPath, modelField: csiModelField, locale: locale, index: index, item: field } : { opType: 'set', fieldPath: fieldPath, modelField: csiModelField, locale: locale, field: field } ]; await this.updateDocumentHooked({ updateDocumentOptions: { document: csiDocument, modelMap: csiModelMap, userContext: userContext, operations: operations }, contentSourceData: contentSourceData, user: user }); return { srcDocumentId: srcDocumentId }; } async createDocument({ srcType, srcProjectId, modelName, object, locale, defaultLocaleDocumentId, user }: { srcType: string; srcProjectId: string; modelName: string; object?: Record; locale?: string; defaultLocaleDocumentId?: string; user?: ContentStoreTypes.User; }): Promise<{ srcDocumentId: string }> { this.logger.debug('createDocument', { srcType, srcProjectId, modelName, locale }); logCreateDocument({ userLogger: this.userLogger, srcType, srcProjectId, modelName, locale, object }); const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const resolvedLocale = locale ?? contentSourceData.defaultLocaleCode; const csiModel = contentSourceData.csiModelMap[modelName]; if (!csiModel) { throw new Error(`Error creating document: model not found: '${modelName}' (source: ${contentSourceId}).`); } if (this.stackbitConfig?.onContentCreate) { object = await this.stackbitConfig.onContentCreate({ object: object ?? {}, locale, model: { srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, ...csiModel }, ...createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }) }); if ('$$type' in object) { // onContentCreate changed the type of the object if (object.$$type !== modelName) { modelName = object.$$type; } delete object.$$type; } } const result = await createDocumentRecursively({ object, locale: resolvedLocale, userLogger: this.userLogger, modelName, contentSourceId, contentSourceDataById: this.contentSourceDataById, assetSources: this.stackbitConfig?.assetSources ?? [], createDocument: this.getCreateDocumentThunk({ defaultLocaleDocumentId, user }) }); this.logger.debug('created document', { srcType, srcProjectId, srcDocumentId: result.documentId, modelName }); return { srcDocumentId: result.documentId }; } async updateDocument({ srcType, srcProjectId, srcDocumentId, updateOperations, user }: { srcType: string; srcProjectId: string; srcDocumentId: string; updateOperations: ContentStoreTypes.UpdateOperation[]; user?: ContentStoreTypes.User; }): Promise<{ srcDocumentId: string }> { this.logger.debug('updateDocument'); logUpdateDocument({ userLogger: this.userLogger, srcType, srcProjectId, srcDocumentId, updateOperations }); const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const userContext = getUserContextForSrcType(srcType, user); const document = contentSourceData.documentMap[srcDocumentId]; const csiDocument = contentSourceData.csiDocumentMap[srcDocumentId]; if (!document || !csiDocument) { throw new Error(`Document not found: '${srcDocumentId}' (Source: '${contentSourceData.id}'.)`); } const modelMap = contentSourceData.modelMap; const csiModelMap = contentSourceData.csiModelMap; const documentModelName = document.srcModelName; const csiModel = csiModelMap[documentModelName]; if (!csiModel) { throw new Error(`Error updating document: could not find document model '${documentModelName}'.`); } const operations = await mapPromise(updateOperations, async (updateOperation): Promise => { const locale = updateOperation.locale ?? contentSourceData.defaultLocaleCode; const modelField = getModelFieldAtFieldPath({ document, fieldPath: updateOperation.fieldPath, modelMap, locale }); const csiModelField = getModelFieldAtFieldPath({ document, fieldPath: updateOperation.fieldPath, modelMap: csiModelMap, locale }); switch (updateOperation.opType) { case 'set': { const field = await convertOperationField({ operationField: updateOperation.field, fieldPath: [csiModel.name, ...updateOperation.fieldPath], modelField, csiModelField, locale, modelMap, csiModelMap, contentSourceId, contentSourceDataById: this.contentSourceDataById, assetSources: this.stackbitConfig?.assetSources ?? [], createDocument: this.getCreateDocumentThunk({ user }), userLogger: this.userLogger }); return { ...updateOperation, modelField: csiModelField, field }; } case 'unset': return { ...updateOperation, modelField: csiModelField }; case 'insert': { if (modelField.type !== 'list' || csiModelField.type !== 'list') { throw new Error('Invalid operation. Insert operations can be performed on list fields only.'); } const item = (await convertOperationField({ operationField: updateOperation.item, fieldPath: [csiModel.name, ...updateOperation.fieldPath], modelField: modelField.items, csiModelField: csiModelField.items, locale, modelMap, csiModelMap, contentSourceId, contentSourceDataById: this.contentSourceDataById, assetSources: this.stackbitConfig?.assetSources ?? [], createDocument: this.getCreateDocumentThunk({ user }), userLogger: this.userLogger })) as CSITypes.UpdateOperationListFieldItem; return { ...updateOperation, modelField: csiModelField, item }; } case 'remove': if (csiModelField.type !== 'list') { throw new Error('Invalid operation. Remove operations can be performed on list fields only.'); } return { ...updateOperation, modelField: csiModelField }; case 'reorder': if (csiModelField.type !== 'list') { throw new Error('Invalid operation. Reorder operations can be performed on list fields only.'); } return { ...updateOperation, modelField: csiModelField }; } }); await this.updateDocumentHooked({ updateDocumentOptions: { document: csiDocument, modelMap: csiModelMap, userContext, operations }, contentSourceData: contentSourceData, user: user }); return { srcDocumentId: srcDocumentId }; } async updateAsset({ srcType, srcProjectId, srcAssetId, updateOperations, user }: { srcType: string; srcProjectId: string; srcAssetId: string; updateOperations: ContentStoreTypes.UpdateOperation[]; user?: ContentStoreTypes.User; }): Promise<{ srcAssetId: string }> { this.logger.debug('updateAsset'); logUpdateAsset({ userLogger: this.userLogger, srcType, srcProjectId, srcAssetId, updateOperations }); const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const userContext = getUserContextForSrcType(srcType, user); const asset = contentSourceData.assetMap[srcAssetId]; const csiAsset = contentSourceData.csiAssetMap[srcAssetId]; if (!asset || !csiAsset) { throw new Error(`Asset not found: '${srcAssetId}' (Source: '${contentSourceData.id}'.)`); } const operations = await mapPromise(updateOperations, async (updateOperation): Promise => { const locale = updateOperation.locale ?? contentSourceData.defaultLocaleCode; const fieldPath = updateOperation.fieldPath; const fieldName = _.head(fieldPath); if (typeof fieldName !== 'string') { throw new Error('the first fieldPath must be string'); } const modelField = _.find(ASSET_MODEL.fields, { name: fieldName }); if (!modelField) { throw new Error(`field ${fieldName} doesn't exist`); } if (modelField.type === 'assetFile') { throw new Error('Update for assetFile is not supported'); } switch (updateOperation.opType) { case 'set': { const field = await convertOperationField({ operationField: updateOperation.field, fieldPath: [ASSET_MODEL.name, ...updateOperation.fieldPath], modelField, csiModelField: modelField, locale, modelMap: {}, csiModelMap: {}, contentSourceId, contentSourceDataById: this.contentSourceDataById, assetSources: this.stackbitConfig?.assetSources ?? [], createDocument: this.getCreateDocumentThunk({ user }), userLogger: this.userLogger }); return { ...updateOperation, modelField, field }; } case 'unset': return { ...updateOperation, modelField }; default: throw new Error('Invalid operation.'); } }); await contentSourceData.instance.updateAsset?.({ asset: csiAsset, operations, userContext }); return { srcAssetId }; } async duplicateDocument({ srcType, srcProjectId, srcDocumentId, object, locale, user }: { srcType: string; srcProjectId: string; srcDocumentId: string; object?: Record; locale?: string; user?: ContentStoreTypes.User; }): Promise<{ srcDocumentId: string }> { this.logger.debug('duplicateDocument', { srcType, srcProjectId, srcDocumentId, locale }); logDuplicateDocument({ userLogger: this.userLogger, srcType, srcProjectId, srcDocumentId, locale }); const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const document = contentSourceData.documentMap[srcDocumentId]; if (!document) { throw new Error(`Document not found: '${srcDocumentId}' (Source: '${contentSourceData.id}'.)`); } const model = contentSourceData.modelMap[document.srcModelName]; const csiModel = contentSourceData.csiModelMap[document.srcModelName]; if (!model || !csiModel) { throw new Error(`Model not found: '${document.srcModelName}' (source: ${contentSourceId})`); } const resolvedLocale = locale ?? contentSourceData.defaultLocaleCode; let extendedObject = mergeObjectWithDocument({ object, document, locale: resolvedLocale, contentSourceId, contentSourceDataById: this.contentSourceDataById, referenceBehavior: this.stackbitConfig?.presetReferenceBehavior, duplicatableModels: this.stackbitConfig?.duplicatableModels, nonDuplicatableModels: this.stackbitConfig?.nonDuplicatableModels }); if (this.stackbitConfig?.onContentCreate) { extendedObject = await this.stackbitConfig.onContentCreate({ object: extendedObject ?? {}, locale, model: { srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, ...csiModel }, ...createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }) }); } const result = await createDocumentRecursively({ object: extendedObject, locale: resolvedLocale, userLogger: this.userLogger, modelName: model.name, contentSourceId, contentSourceDataById: this.contentSourceDataById, assetSources: this.stackbitConfig?.assetSources ?? [], createDocument: this.getCreateDocumentThunk({ user }) }); this.logger.debug('duplicated document', { srcType, srcProjectId, srcDocumentId, newDocumentId: result.documentId, modelName: model.name }); return { srcDocumentId: result.documentId }; } private getCreateDocumentThunk({ defaultLocaleDocumentId, user }: { defaultLocaleDocumentId?: string; user?: ContentStoreTypes.User; }): CreateDocumentThunk { return getCreateDocumentThunk({ stackbitConfig: this.stackbitConfig, getContentSourceDataById: () => this.contentSourceDataById, logger: this.userLogger, defaultLocaleDocumentId: defaultLocaleDocumentId, user: user }); } private async updateDocumentHooked(options: { updateDocumentOptions: Parameters[0]; contentSourceData: ContentSourceData; user?: ContentStoreTypes.User; }) { await validateUpdateOperations({ updateOperations: options.updateDocumentOptions.operations, csiDocument: options.updateDocumentOptions.document, contentSourceData: options.contentSourceData, configDelegate: createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }) }); return updateDocumentHooked({ actionOptions: options.updateDocumentOptions, stackbitConfig: this.stackbitConfig, contentSourceData: options.contentSourceData, getContentSourceDataById: () => this.contentSourceDataById, user: options.user, logger: this.userLogger }); } async uploadAssets({ srcType, srcProjectId, assets, locale, user }: { srcType: string; srcProjectId: string; assets: ContentStoreTypes.UploadAssetData[]; locale?: string; user?: ContentStoreTypes.User; }): Promise { this.logger.debug('uploadAssets'); logUploadAssets({ userLogger: this.userLogger, srcType, srcProjectId, assets, locale }); const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const sourceAssets: CSITypes.Asset[] = []; const userContext = getUserContextForSrcType(srcType, user); locale = locale ?? contentSourceData.defaultLocaleCode; for (const asset of assets) { let base64 = undefined; if (asset.data) { const matchResult = asset.data.match(/;base64,([\s\S]+)$/); if (matchResult) { base64 = matchResult[1]; } } const sourceAsset = await contentSourceData.instance.uploadAsset({ url: asset.url, base64: base64, fileName: asset.metadata.name, mimeType: asset.metadata.type, locale: locale, userContext: userContext }); sourceAssets.push(sourceAsset); } const storeAssets = mapCSIAssetsToStoreAssets({ csiAssets: sourceAssets, contentSourceInstance: contentSourceData.instance, defaultLocaleCode: contentSourceData.defaultLocaleCode }); return mapStoreAssetsToAPIAssets(storeAssets, this.staticAssetsPublicPath, locale); } async deleteDocument({ srcType, srcProjectId, srcDocumentId, user }: { srcType: string; srcProjectId: string; srcDocumentId: string; user?: ContentStoreTypes.User; }) { this.logger.debug('deleteDocument'); logDocumentEvent('Delete', { userLogger: this.userLogger, srcType, srcProjectId, srcDocumentId }); const userContext = getUserContextForSrcType(srcType, user); const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const csiDocument = contentSourceData.csiDocumentMap[srcDocumentId]; if (!csiDocument) { throw new Error(`Document not found: '${srcDocumentId}' (Source: '${contentSourceData.id}'.)`); } await deleteDocumentHooked({ actionOptions: { document: csiDocument, userContext }, stackbitConfig: this.stackbitConfig, contentSourceData: contentSourceData, getContentSourceDataById: () => this.contentSourceDataById, user: user, logger: this.userLogger }); } async archiveDocument({ srcType, srcProjectId, srcDocumentId, user }: { srcType: string; srcProjectId: string; srcDocumentId: string; user?: ContentStoreTypes.User; }) { this.logger.debug('archiveDocument'); logDocumentEvent('Archive', { userLogger: this.userLogger, srcType, srcProjectId, srcDocumentId }); const userContext = getUserContextForSrcType(srcType, user); const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const csiDocument = contentSourceData.csiDocumentMap[srcDocumentId]; if (!csiDocument) { throw new Error(`Document not found: '${srcDocumentId}' (Source: '${contentSourceData.id}'.)`); } await archiveDocumentHooked({ actionOptions: { document: csiDocument, userContext }, stackbitConfig: this.stackbitConfig, contentSourceData: contentSourceData, getContentSourceDataById: () => this.contentSourceDataById, user: user, logger: this.userLogger }); } async unarchiveDocument({ srcType, srcProjectId, srcDocumentId, user }: { srcType: string; srcProjectId: string; srcDocumentId: string; user?: ContentStoreTypes.User; }) { this.logger.debug('unarchiveDocument'); logDocumentEvent('Unarchive', { userLogger: this.userLogger, srcType, srcProjectId, srcDocumentId }); const userContext = getUserContextForSrcType(srcType, user); const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const csiDocument = contentSourceData.csiDocumentMap[srcDocumentId]; if (!csiDocument) { throw new Error(`Document not found: '${srcDocumentId}' (Source: '${contentSourceData.id}'.)`); } await unarchiveDocumentHooked({ actionOptions: { document: csiDocument, userContext }, stackbitConfig: this.stackbitConfig, contentSourceData: contentSourceData, getContentSourceDataById: () => this.contentSourceDataById, user: user, logger: this.userLogger }); } getScheduledActions(): CSITypes.ScheduledActionWithSource[] { const scheduledActions = _.reduce( this.contentSourceDataById, (result: CSITypes.ScheduledActionWithSource[], contentSourceData) => { return result.concat( contentSourceData.scheduledActions.map((scheduledAction) => ({ ...scheduledAction, srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId })) ); }, [] ); return scheduledActions; } async updateScheduledAction({ srcType, srcProjectId, scheduledActionId, documentIds, name, executeAt, user }: { srcType: string; srcProjectId: string; scheduledActionId: string; name?: string; documentIds?: string[]; executeAt?: string; user?: ContentStoreTypes.User; }): Promise<{ updatedScheduledActionId: string }> { const userContext = getUserContextForSrcType(srcType, user); const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); this.logger.debug('updateScheduledAction', { srcType, srcProjectId, scheduledActionId }); if (!contentSourceData.instance.updateScheduledAction) { this.logger.error('Trying to call updateScheduledAction, but it is not implemented', { srcType, srcProjectId, scheduledActionId }); throw new Error('Content source plugin missing required method: updateScheduledAction'); } return contentSourceData.instance.updateScheduledAction({ scheduledActionId, documentIds, name, executeAt, userContext }); } async cancelScheduledAction({ srcType, srcProjectId, scheduledActionId, user }: { srcType: string; srcProjectId: string; scheduledActionId: string; user?: ContentStoreTypes.User; }): Promise<{ cancelledScheduledActionId: string }> { const userContext = getUserContextForSrcType(srcType, user); const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); this.logger.debug('cancelScheduledAction', { srcType, srcProjectId, scheduledActionId }); if (!contentSourceData.instance.cancelScheduledAction) { this.logger.error('Trying to call cancelScheduledAction, but it is not implemented', { srcType, srcProjectId, scheduledActionId }); throw new Error('Content source plugin missing required method: cancelScheduledAction'); } return contentSourceData.instance.cancelScheduledAction({ scheduledActionId, userContext }); } async createScheduledAction({ srcType, srcProjectId, documentIds, name, action, executeAt, user }: { srcType: string; srcProjectId: string; documentIds: string[]; name: string; action: CSITypes.ScheduledActionActionType; executeAt: string; user?: ContentStoreTypes.User; }): Promise<{ newScheduledActionId: string }> { const userContext = getUserContextForSrcType(srcType, user); const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); this.logger.debug('createScheduledAction', { srcType, srcProjectId, documentIds, name, action, executeAt }); if (!contentSourceData.instance.createScheduledAction) { this.logger.error('Trying to call createScheduledAction, but it is not implemented', { srcType, srcProjectId, documentIds, name, action, executeAt }); throw new Error('Content source plugin missing required method: createScheduledAction'); } return contentSourceData.instance.createScheduledAction({ documentIds, name, action, executeAt, userContext }); } async validateDocuments({ objects, locale, user }: { objects: { srcType: string; srcProjectId: string; srcObjectId: string }[]; locale?: string; user?: ContentStoreTypes.User; }): Promise<{ errors: ContentStoreTypes.ValidationError[] }> { this.logger.debug('validateDocuments'); const objectsBySourceId = _.groupBy(objects, (object) => getContentSourceId(object.srcType, object.srcProjectId)); let errors: ContentStoreTypes.ValidationError[] = []; for (const [contentSourceId, contentSourceObjects] of Object.entries(objectsBySourceId)) { const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); locale = locale ?? contentSourceData.defaultLocaleCode; const { documents, assets } = getCSIDocumentsAndAssetsFromContentSourceDataByIds(contentSourceData, contentSourceObjects); const userContext = getUserContextForSrcType(contentSourceData.srcType, user); const internalValidationErrors = internalValidateContent(documents, assets, contentSourceData, locale); const validationResult = await contentSourceData.instance.validateDocuments({ documents, assets, locale, userContext }); errors = errors.concat( internalValidationErrors, validationResult.errors.map((validationError) => ({ message: validationError.message, srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, srcObjectType: validationError.objectType, srcObjectId: validationError.objectId, fieldPath: validationError.fieldPath, isUniqueValidation: validationError.isUniqueValidation })) ); } return { errors }; /* validate for multiple sources const objectsBySourceId = _.groupBy(objects, (document) => getContentSourceId(document.srcType, document.srcProjectId)); const contentSourceIds = Object.keys(objectsBySourceId); return reducePromise( contentSourceIds, async (result: ContentStoreTypes.ValidationError[], contentSourceId) => { const documents = documentsBySourceId[contentSourceId]!; const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const validationErrors = await contentSourceData.instance.validateDocuments({ documentIds: documents.map((document) => document.srcObjectId) }); return result.concat(validationErrors); }, [] ); */ } async searchDocuments(data: { query?: string; filter?: SearchFilter; models: Array<{ srcProjectId: string; srcType: string; modelName: string; }>; locale?: string; user?: ContentStoreTypes.User; }): Promise<{ total: number; items: ContentStoreTypes.Document[]; }> { this.logger.debug('searchDocuments'); const locale = data.locale; const objectsBySourceId = _.groupBy(data.models, (object) => getContentSourceId(object.srcType, object.srcProjectId)); const contentSourceIds = Object.keys(objectsBySourceId); const documents: ContentStoreTypes.Document[] = []; const schema: Record>> = {}; const scheduledActions: ScheduledAction[] = []; const defaultLocales: Record = {}; contentSourceIds.forEach((contentSourceId) => { const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); _.set(schema, [contentSourceData.srcType, contentSourceData.srcProjectId], contentSourceData.modelMap); const contentSourceDocuments = _.isEmpty(locale) ? contentSourceData.documents : contentSourceData.documents.filter((document) => !document.locale || document.locale === locale); const contentSourceScheduledActions = contentSourceData.scheduledActions; const filteredDocuments = contentSourceDocuments.filter((document) => document.srcModelName !== STACKBIT_PRESET_MODEL_NAME); const userDocuments = getFilteredDocumentsForUser({ user: data.user, documents: filteredDocuments, permissionsForModel: this.stackbitConfig?.permissionsForModel, permissionsForDocument: this.stackbitConfig?.permissionsForDocument, contentSourceDataById: this.contentSourceDataById, createConfigDelegate: getCreateConfigDelegateThunk({ getContentSourceDataById: () => this.contentSourceDataById, logger: this.userLogger }), logger: this.userLogger }); // filter out hidden documents from the search const visibleDocuments = userDocuments.filter((document) => !document.hidden); documents.push(...visibleDocuments); scheduledActions.push(...contentSourceScheduledActions); if (contentSourceData.defaultLocaleCode) { defaultLocales[contentSourceId] = contentSourceData.defaultLocaleCode; } }); return searchDocuments({ ...data, documents, schema, locale, scheduledActions, defaultLocales }); } async getDocumentVersions({ srcType, srcProjectId, documentId, locale, user }: { srcType: string; srcProjectId: string; documentId: string; locale?: string; user?: ContentStoreTypes.User; }): Promise<{ versions: ContentStoreTypes.APIDocumentVersion[] }> { const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); locale = locale ?? contentSourceData.defaultLocaleCode; this.logger.debug('getDocumentVersions', { srcType, srcProjectId, documentId }); if (!contentSourceData.instance.getDocumentVersions) { this.logger.error('Trying to call getDocumentVersions, but it is not implemented', { srcType, srcProjectId, documentId }); throw new Error('Content source plugin missing required method: getDocumentVersions'); } const { versions } = await contentSourceData.instance.getDocumentVersions({ documentId }); const models = getContentSourceFilteredModelsForUser({ user, contentSourceData, configDelegate: createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }), permissionsForModel: this.stackbitConfig?.permissionsForModel }); const apiVersions = mapDocumentVersionsToApiDocumentVersions({ assetSources: this.stackbitConfig?.assetSources ?? [], createConfigDelegate: getCreateConfigDelegateThunk({ getContentSourceDataById: () => this.contentSourceDataById, logger: this.userLogger }), versions, contentSourceData, contentSourceDataById: this.contentSourceDataById, locale, modelMap: getModelMap({ models }) }); return { versions: apiVersions }; } async getDocumentForVersion({ srcType, srcProjectId, documentId, versionId, locale, user }: { srcType: string; srcProjectId: string; documentId: string; versionId: string; locale?: string; user?: ContentStoreTypes.User; }): Promise<{ version: ContentStoreTypes.APIDocumentVersionWithDocument }> { const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); locale = locale ?? contentSourceData.defaultLocaleCode; this.logger.debug('getDocumentForVersion', { srcType, srcProjectId, documentId, versionId }); if (!contentSourceData.instance.getDocumentForVersion) { this.logger.error('Trying to call getDocumentForVersion, but it is not implemented', { srcType, srcProjectId, documentId, versionId }); throw new Error('Content source plugin missing required method: getDocumentForVersion'); } const { version } = await contentSourceData.instance.getDocumentForVersion({ documentId, versionId }); const models = getContentSourceFilteredModelsForUser({ user, contentSourceData, configDelegate: createConfigDelegate({ contentSourceDataById: this.contentSourceDataById, logger: this.userLogger }), permissionsForModel: this.stackbitConfig?.permissionsForModel }); const [apiVersion] = mapDocumentVersionsToApiDocumentVersions({ versions: [version], assetSources: this.stackbitConfig?.assetSources ?? [], createConfigDelegate: getCreateConfigDelegateThunk({ getContentSourceDataById: () => this.contentSourceDataById, logger: this.userLogger }), contentSourceData, locale, contentSourceDataById: this.contentSourceDataById, modelMap: getModelMap({ models }) }); if (!apiVersion || !apiVersion.object || !apiVersion.document || !apiVersion.apiDocument) { throw new Error(`getDocumentForVersion could not transform document into api object for document ${version.documentId}`); } // unwrap TS types const { object, document, apiDocument } = apiVersion; return { version: { ...apiVersion, object, document, apiDocument } }; } async publishDocuments({ objects, user }: { objects: { srcType: string; srcProjectId: string; srcObjectId: string }[]; user?: ContentStoreTypes.User }) { this.logger.debug('publishDocuments'); logDocumentsEvent('Publish', { userLogger: this.userLogger, objects }); const objectsBySourceId = _.groupBy(objects, (object) => getContentSourceId(object.srcType, object.srcProjectId)); for (const [contentSourceId, contentSourceObjects] of Object.entries(objectsBySourceId)) { const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const userContext = getUserContextForSrcType(contentSourceData.srcType, user); const { documents, assets } = getCSIDocumentsAndAssetsFromContentSourceDataByIds(contentSourceData, contentSourceObjects); await publishDocumentHooked({ actionOptions: { documents, assets, userContext }, stackbitConfig: this.stackbitConfig, contentSourceData: contentSourceData, getContentSourceDataById: () => this.contentSourceDataById, user: user, logger: this.userLogger }); } } async unpublishDocuments({ objects, user }: { objects: { srcType: string; srcProjectId: string; srcObjectId: string }[]; user?: ContentStoreTypes.User }) { this.logger.debug('unpublishDocuments'); logDocumentsEvent('Unpublish', { userLogger: this.userLogger, objects }); const objectsBySourceId = _.groupBy(objects, (object) => getContentSourceId(object.srcType, object.srcProjectId)); for (const [contentSourceId, contentSourceObjects] of Object.entries(objectsBySourceId)) { const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const userContext = getUserContextForSrcType(contentSourceData.srcType, user); const { documents, assets } = getCSIDocumentsAndAssetsFromContentSourceDataByIds(contentSourceData, contentSourceObjects); await unpublishDocumentHooked({ actionOptions: { documents, assets, userContext }, stackbitConfig: this.stackbitConfig, contentSourceData: contentSourceData, getContentSourceDataById: () => this.contentSourceDataById, user: user, logger: this.userLogger }); } } private getContentSourceDataByIdOrThrow(contentSourceId: string): ContentSourceData { return getContentSourceDataByIdOrThrow(contentSourceId, this.contentSourceDataById); } async onWebhook({ srcType, srcProjectId, data, headers }: { srcType: string; srcProjectId: string; data: unknown; headers: Record }) { const contentSourceId = getContentSourceId(srcType, srcProjectId); const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId); const contentEngineConfig = contentSourceData.instance.getContentEngineConfig?.(); // If the current content source has a content-engine config, // then it is a unified connector, so we need to call contentEngine's sync(). if (contentEngineConfig && this.contentEngine) { // if there's a content-engine always pass the webhook body to it await this.contentEngine!.sync({ webhookBody: data as any, connector: contentEngineConfig.connector, buildSchema: false }); } else { return contentSourceData.instance.onWebhook?.({ data, headers }); } } getWebhookUrl(contentSourceType: string, projectId: string) { if (!this.webhookUrl) { return undefined; } return `${this.webhookUrl}/${encodeURIComponent(contentSourceType)}/${encodeURIComponent(projectId)}`; } async pullContent() { const hasGitCS = this.contentSources.some((contentSource) => (contentSource as any).isGitCms); this.logger.debug(`pullContent requested - ${hasGitCS ? 'has git content source' : 'no git content source'}`); if (!hasGitCS) { return { didPull: false }; } try { const changedFiles = await this.git.diffFilesWithFetchHead(); const filePaths = changedFiles.map((changedFile) => changedFile.filePath); const filesChangeResult = await this.onFilesChange(filePaths, true); if (filesChangeResult.codeChanged) { // when there are code files - pull files one by one and create a shadow repo for committing diffs const contentFiles = filesChangeResult.contentFiles.map((filePath) => changedFiles.find((file) => file.filePath === filePath)).filter(isTruthy); this.logger.debug(`pullContent code changed, ${changedFiles.length} total changed files, ${contentFiles.length} content changed files`, { changedFiles, contentFiles }); await this.git.pullFilesFromFetchHead(contentFiles); await this.git.createShadowRepo(); return { didPull: false }; } // when it's only content - just pull this.logger.debug(`pullContent content changed only, pulling git`); await this.git.pull(); return { didPull: true }; } catch (err) { this.logger.error('Could not pullContent', err); return { didPull: false }; } } }