@stackbit/cms-core 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,21 @@ import sanitizeFilename from 'sanitize-filename';
4
4
 
5
5
  import * as CSITypes from '@stackbit/types';
6
6
  import { getLocalizedFieldForLocale, UserCommandSpawner } from '@stackbit/types';
7
- import { Config, extendConfig, Field, loadConfigFromDir, Model, ImageModel, isImageModel, Preset, RawConfigWithPaths } from '@stackbit/sdk';
7
+ import {
8
+ Config,
9
+ Field,
10
+ Model,
11
+ ImageModel,
12
+ Preset,
13
+ PresetMap,
14
+ loadPresets,
15
+ getYamlModelDirs,
16
+ getPresetDirs,
17
+ loadYamlModelsFromFiles,
18
+ mergeConfigModelsWithModelsFromFiles,
19
+ extendModelsWithPresetsIds,
20
+ mergeConfigModelsWithExternalModels
21
+ } from '@stackbit/sdk';
8
22
  import { deferWhileRunning, mapPromise, reducePromise } from '@stackbit/utils';
9
23
 
10
24
  import * as ContentStoreTypes from './content-store-types';
@@ -12,31 +26,46 @@ import { Timer } from './utils/timer';
12
26
  import { SearchFilter } from './types/search-filter';
13
27
  import { searchDocuments } from './utils/search-utils';
14
28
  import { mapCSIAssetsToStoreAssets, mapCSIDocumentsToStoreDocuments } from './utils/csi-to-store-docs-converter';
15
- import { getContentSourceId, getContentSourceIdForContentSource, getModelFieldForFieldAtPath, getUserContextForSrcType } from './content-store-utils';
29
+ import {
30
+ getContentSourceId,
31
+ getContentSourceIdForContentSource,
32
+ getModelFieldForFieldAtPath,
33
+ getUserContextForSrcType,
34
+ groupModelsByContentSource,
35
+ groupDocumentsByContentSource
36
+ } from './content-store-utils';
16
37
  import { mapAssetsToLocalizedApiImages, mapDocumentsToLocalizedApiObjects, mapStoreAssetsToAPIAssets } from './utils/store-to-api-docs-converter';
17
38
  import { convertOperationField, createDocumentRecursively, getCreateDocumentThunk } from './utils/create-update-csi-docs';
39
+ import { normalizeModels, validateModels } from './utils/model-utils';
40
+ import { IMAGE_MODEL } from './common/common-schema';
41
+
42
+ export type HandleConfigAssets = <T extends Model>({ models, presets }: { models?: T[]; presets?: PresetMap }) => Promise<{ models: T[]; presets: PresetMap }>;
18
43
 
19
44
  export interface ContentSourceOptions {
20
45
  logger: ContentStoreTypes.Logger;
21
46
  userLogger: ContentStoreTypes.Logger;
22
47
  localDev: boolean;
48
+ webhookUrl?: string;
23
49
  userCommandSpawner?: UserCommandSpawner;
24
50
  onSchemaChangeCallback: () => void;
25
51
  onContentChangeCallback: (contentChanges: ContentStoreTypes.ContentChangeResult) => void;
26
- handleConfigAssets: (config: Config) => Promise<Config>;
52
+ handleConfigAssets: HandleConfigAssets;
53
+ devAppRestartNeeded?: () => void;
27
54
  }
28
55
 
29
56
  interface ContentSourceData {
30
57
  id: string;
31
58
  instance: CSITypes.ContentSourceInterface;
32
- type: string;
33
- projectId: string;
59
+ srcType: string;
60
+ srcProjectId: string;
34
61
  locales?: CSITypes.Locale[];
35
62
  defaultLocaleCode?: string;
36
63
  /* Array of extended and validated Models */
37
64
  models: Model[];
38
65
  /* Map of extended and validated Models by model name */
39
66
  modelMap: Record<string, Model>;
67
+ /* Array of original Models (as provided by content source) */
68
+ csiModels: CSITypes.Model[];
40
69
  /* Map of original Models (as provided by content source) by model name */
41
70
  csiModelMap: Record<string, CSITypes.Model>;
42
71
  /* Array of original content source Documents */
@@ -57,29 +86,37 @@ interface ContentSourceData {
57
86
  assetMap: Record<string, ContentStoreTypes.Asset>;
58
87
  }
59
88
 
89
+ type ContentSourceRawData = Omit<ContentSourceData, 'models' | 'modelMap' | 'documents' | 'documentMap'>;
90
+
60
91
  export class ContentStore {
61
92
  private readonly logger: ContentStoreTypes.Logger;
62
93
  private readonly userLogger: ContentStoreTypes.Logger;
63
94
  private readonly userCommandSpawner?: UserCommandSpawner;
64
95
  private readonly localDev: boolean;
96
+ private readonly webhookUrl?: string;
65
97
  private readonly onSchemaChangeCallback: () => void;
66
98
  private readonly onContentChangeCallback: (contentChanges: ContentStoreTypes.ContentChangeResult) => void;
67
- private readonly handleConfigAssets: (config: Config) => Promise<Config>;
99
+ private readonly handleConfigAssets: HandleConfigAssets;
100
+ private readonly devAppRestartNeeded?: () => void;
68
101
  private contentSources: CSITypes.ContentSourceInterface[] = [];
69
102
  private contentSourceDataById: Record<string, ContentSourceData> = {};
70
103
  private contentUpdatesWatchTimer: Timer;
71
- private rawStackbitConfig: RawConfigWithPaths | null = null;
72
- private presets?: Record<string, any>;
104
+ private stackbitConfig: Config | null = null;
105
+ private yamlModels: Model[] = [];
106
+ private configModels: Model[] = [];
107
+ private presets: PresetMap = {};
73
108
 
74
109
  constructor(options: ContentSourceOptions) {
75
110
  this.logger = options.logger.createLogger({ label: 'content-store' });
76
111
  this.userLogger = options.userLogger.createLogger({ label: 'content-store' });
77
112
  this.localDev = options.localDev;
113
+ this.webhookUrl = options.webhookUrl;
78
114
  this.userCommandSpawner = options.userCommandSpawner;
79
115
  this.onSchemaChangeCallback = options.onSchemaChangeCallback;
80
116
  this.onContentChangeCallback = options.onContentChangeCallback;
81
117
  this.handleConfigAssets = options.handleConfigAssets;
82
118
  this.contentUpdatesWatchTimer = new Timer({ timerCallback: () => this.handleTimerTimeout(), logger: this.logger });
119
+ this.devAppRestartNeeded = options.devAppRestartNeeded;
83
120
 
84
121
  // The `loadContentSourceData` method can be called for different
85
122
  // reasons: user restarted SSG from Stackbit, Stackbit Config updated,
@@ -114,6 +151,10 @@ export class ContentStore {
114
151
 
115
152
  async init({ stackbitConfig }: { stackbitConfig: Config | null }): Promise<void> {
116
153
  this.logger.debug('init');
154
+ if (stackbitConfig) {
155
+ this.yamlModels = await this.loadYamlModels({ stackbitConfig });
156
+ this.presets = await this.loadPresets({ stackbitConfig });
157
+ }
117
158
  await this.setStackbitConfig({ stackbitConfig, init: true });
118
159
  }
119
160
 
@@ -123,37 +164,19 @@ export class ContentStore {
123
164
  }
124
165
 
125
166
  private async setStackbitConfig({ stackbitConfig, init }: { stackbitConfig: Config | null; init: boolean }) {
126
- // TODO: Use stackbitConfig instead of loading rawStackbitConfig here
127
- // by splitting config loader into independent phases:
128
- // 1. load and validate only the root config props
129
- // 2. load content-source models
130
- // 3. merge content-source models with config.models or via config.mapModels, sanitize and validate
131
- // 4. load presets, adjust presets to have srcType and srcProjectId
132
- if (!stackbitConfig) {
133
- this.rawStackbitConfig = null;
134
- } else {
135
- const rawConfigResult = await loadConfigFromDir({
136
- dirPath: stackbitConfig.dirPath,
137
- logger: this.logger
138
- });
139
- for (const error of rawConfigResult.errors) {
140
- this.userLogger.warn(error.message);
141
- }
142
- if (rawConfigResult.config) {
143
- this.rawStackbitConfig = {
144
- ...rawConfigResult.config,
145
- contentSources: stackbitConfig.contentSources
146
- };
147
- } else {
148
- this.rawStackbitConfig = null;
149
- }
150
- }
151
- await this.loadContentSources({ init });
167
+ this.stackbitConfig = stackbitConfig;
168
+ this.configModels = this.mergeConfigModels(this.stackbitConfig, this.yamlModels);
169
+
170
+ await this.loadAllContentSourcesAndProcessData({ init });
152
171
  }
153
172
 
154
173
  /**
155
174
  * This method is called when contentUpdatesWatchTimer receives timeout.
156
- * It then notifies all content sources to stop watching for content changes.
175
+ * This happens when the user is not using the Stackbit app for some time
176
+ * but container is not hibernated.
177
+ * It then notifies all content sources to stop watching for content
178
+ * changes, which in turn stops polling CMS for content changes and helps
179
+ * reducing the CMS API usage.
157
180
  */
158
181
  private handleTimerTimeout() {
159
182
  for (const contentSourceInstance of this.contentSources) {
@@ -173,7 +196,7 @@ export class ContentStore {
173
196
  }
174
197
 
175
198
  this.logger.debug('keepAlive => contentUpdatesWatchTimer is not running => load content source data');
176
- await this.loadContentSources({ init: false });
199
+ await this.loadAllContentSourcesAndProcessData({ init: false });
177
200
  }
178
201
 
179
202
  /**
@@ -187,18 +210,55 @@ export class ContentStore {
187
210
  */
188
211
  async onContentSourceSchemaChange({ contentSourceId }: { contentSourceId: string }) {
189
212
  this.logger.debug('onContentSourceSchemaChange', { contentSourceId });
190
- const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
191
- this.contentSourceDataById[contentSourceId] = await this.loadContentSourceData({
192
- contentSourceInstance: contentSourceData.instance,
193
- init: false
194
- });
195
- this.onSchemaChangeCallback();
213
+ await this.reloadContentSourcesByIdAndProcessData({ contentSourceIds: [contentSourceId] });
196
214
  }
197
215
 
198
216
  async onFilesChange(updatedFiles: string[]): Promise<{ schemaChanged?: boolean; contentChanges: ContentStoreTypes.ContentChangeResult }> {
199
217
  this.logger.debug('onFilesChange');
200
218
 
201
- let someContentSourceSchemaUpdated = false;
219
+ let schemaChanged = false;
220
+
221
+ if (this.stackbitConfig) {
222
+ // Check if any of the yaml models files were changed. If yaml model files were changed,
223
+ // reload them and merge them with models defined in stackbit config.
224
+ const modelDirs = getYamlModelDirs(this.stackbitConfig);
225
+ const yamlModelsChanged = updatedFiles.find((updatedFile) => _.some(modelDirs, (modelDir) => updatedFile.startsWith(modelDir)));
226
+ if (yamlModelsChanged) {
227
+ this.logger.debug('identified change in stackbit model files');
228
+ schemaChanged = true;
229
+ this.yamlModels = await this.loadYamlModels({ stackbitConfig: this.stackbitConfig });
230
+ this.configModels = this.mergeConfigModels(this.stackbitConfig, this.yamlModels);
231
+ }
232
+
233
+ // Check if any of the preset files were changed. If presets were changed, reload them.
234
+ const presetDirs = getPresetDirs(this.stackbitConfig);
235
+ const presetsChanged = updatedFiles.find((updatedFile) => _.some(presetDirs, (presetDir) => updatedFile.startsWith(presetDir)));
236
+ if (presetsChanged) {
237
+ this.logger.debug('identified change in stackbit preset files');
238
+ schemaChanged = true;
239
+ this.presets = await this.loadPresets({ stackbitConfig: this.stackbitConfig });
240
+ }
241
+ }
242
+
243
+ const contentSourceIdsWithChangedSchema: string[] = [];
244
+ const contentChangeEvents: { contentSourceId: string; contentChangeEvent: CSITypes.ContentChangeEvent }[] = [];
245
+
246
+ for (const contentSourceInstance of this.contentSources) {
247
+ const contentSourceId = getContentSourceIdForContentSource(contentSourceInstance);
248
+ this.logger.debug(`call onFilesChange for contentSource: ${contentSourceId}`);
249
+ const onFilesChangeResult = (await contentSourceInstance.onFilesChange?.({ updatedFiles: updatedFiles })) ?? {};
250
+ this.logger.debug(`schemaChanged: ${onFilesChangeResult.schemaChanged}, has contentChangeEvent: ${!!onFilesChangeResult.contentChangeEvent}`);
251
+
252
+ // if schema is changed, there is no need to return contentChanges
253
+ // because schema changes reloads everything and implies content changes
254
+ if (onFilesChangeResult.schemaChanged) {
255
+ schemaChanged = true;
256
+ contentSourceIdsWithChangedSchema.push(contentSourceId);
257
+ } else if (onFilesChangeResult.contentChangeEvent) {
258
+ contentChangeEvents.push({ contentSourceId, contentChangeEvent: onFilesChangeResult.contentChangeEvent });
259
+ }
260
+ }
261
+
202
262
  const contentChanges: ContentStoreTypes.ContentChangeResult = {
203
263
  updatedDocuments: [],
204
264
  updatedAssets: [],
@@ -206,49 +266,115 @@ export class ContentStore {
206
266
  deletedAssets: []
207
267
  };
208
268
 
209
- for (const contentSourceInstance of this.contentSources) {
210
- const contentSourceId = getContentSourceIdForContentSource(contentSourceInstance);
211
- this.logger.debug(`call onFilesChange for contentSource: ${contentSourceId}`);
212
- const { schemaChanged, contentChangeEvent } = (await contentSourceInstance.onFilesChange?.({ updatedFiles: updatedFiles })) ?? {};
213
- this.logger.debug(`schemaChanged: ${schemaChanged}, has contentChangeEvent: ${!!contentChangeEvent}`);
214
- // if schema is changed, there is no need to return contentChanges
215
- // because schema changes reloads everything and implies content changes
216
- if (schemaChanged) {
217
- someContentSourceSchemaUpdated = true;
218
- this.contentSourceDataById[contentSourceId] = await this.loadContentSourceData({ contentSourceInstance, init: false });
219
- } else if (contentChangeEvent) {
269
+ // If the schema was changed, there is no need to accumulate or notify about content changes.
270
+ // The processData will update the store with the latest data. And once the Studio receives
271
+ // the schemaChanged notification it will reload all the models and the documents with their latest state.
272
+ if (schemaChanged) {
273
+ await this.reloadContentSourcesByIdAndProcessData({ contentSourceIds: contentSourceIdsWithChangedSchema });
274
+ } else {
275
+ contentChangeEvents.reduce((contentChanges, { contentSourceId, contentChangeEvent }) => {
220
276
  const contentChangeResult = this.onContentChange(contentSourceId, contentChangeEvent);
221
277
  contentChanges.updatedDocuments = contentChanges.updatedDocuments.concat(contentChangeResult.updatedDocuments);
222
278
  contentChanges.updatedAssets = contentChanges.updatedAssets.concat(contentChangeResult.updatedAssets);
223
279
  contentChanges.deletedDocuments = contentChanges.deletedDocuments.concat(contentChangeResult.deletedDocuments);
224
280
  contentChanges.deletedAssets = contentChanges.deletedAssets.concat(contentChangeResult.deletedAssets);
225
- }
281
+ return contentChanges;
282
+ }, contentChanges);
226
283
  }
227
284
 
228
285
  return {
229
- schemaChanged: someContentSourceSchemaUpdated,
286
+ schemaChanged: schemaChanged,
230
287
  contentChanges: contentChanges
231
288
  };
232
289
  }
233
290
 
234
- private async loadContentSources({ init }: { init: boolean }) {
235
- this.logger.debug('loadContentSources', { init });
291
+ private async loadYamlModels({ stackbitConfig }: { stackbitConfig: Config }): Promise<Model[]> {
292
+ const yamlModelsResult = await loadYamlModelsFromFiles(stackbitConfig);
293
+ for (const error of yamlModelsResult.errors) {
294
+ this.userLogger.warn(error.message);
295
+ }
296
+ return yamlModelsResult.models;
297
+ }
298
+
299
+ private mergeConfigModels(stackbitConfig: Config | null, modelsFromFiles: Model[]) {
300
+ const configModelsResult = mergeConfigModelsWithModelsFromFiles(stackbitConfig?.models ?? [], modelsFromFiles);
301
+ for (const error of configModelsResult.errors) {
302
+ this.userLogger.warn(error.message);
303
+ }
304
+ return configModelsResult.models;
305
+ }
306
+
307
+ private async loadPresets({ stackbitConfig }: { stackbitConfig: Config }): Promise<Record<string, Preset>> {
308
+ const contentSources = stackbitConfig?.contentSources ?? [];
309
+ const singleContentSource = contentSources.length === 1 ? contentSources[0] : null;
310
+ const presetResult = await loadPresets({
311
+ config: stackbitConfig,
312
+ ...(singleContentSource
313
+ ? {
314
+ fallbackSrcType: singleContentSource.getContentSourceType(),
315
+ fallbackSrcProjectId: singleContentSource.getProjectId()
316
+ }
317
+ : null)
318
+ });
319
+ for (const error of presetResult.errors) {
320
+ this.userLogger.warn(error.message);
321
+ }
322
+ const { presets } = await this.handleConfigAssets({ presets: presetResult.presets });
323
+ return presets;
324
+ }
325
+
326
+ private async loadAllContentSourcesAndProcessData({ init }: { init: boolean }) {
327
+ this.logger.debug('loadAllContentSourcesAndProcessData', { init });
236
328
 
237
329
  this.contentUpdatesWatchTimer.stopTimer();
238
330
 
239
- // TODO: move CSITypes to separate package so Config will be able to use them
240
- const contentSources: CSITypes.ContentSourceInterface[] = (this.rawStackbitConfig?.contentSources ?? []) as CSITypes.ContentSourceInterface[];
331
+ const contentSources = this.stackbitConfig?.contentSources ?? [];
241
332
 
242
333
  const promises = contentSources.map((contentSourceInstance) => {
243
334
  return this.loadContentSourceData({ contentSourceInstance, init });
244
335
  });
245
336
 
246
- const contentSourceDataArr = await Promise.all(promises);
247
- const contentSourceDataById: Record<string, ContentSourceData> = _.keyBy(contentSourceDataArr, 'id');
337
+ const contentSourceRawDataArr = await Promise.all(promises);
248
338
 
249
339
  // update all content sources at once to prevent race conditions
340
+ this.contentSourceDataById = await this.processData({
341
+ stackbitConfig: this.stackbitConfig,
342
+ configModels: this.configModels,
343
+ presets: this.presets,
344
+ contentSourceRawDataArr: contentSourceRawDataArr
345
+ });
250
346
  this.contentSources = contentSources;
251
- this.contentSourceDataById = contentSourceDataById;
347
+
348
+ this.contentUpdatesWatchTimer.startTimer();
349
+ }
350
+
351
+ private async reloadContentSourcesByIdAndProcessData({ contentSourceIds }: { contentSourceIds: string[] }) {
352
+ this.logger.debug('reloadContentSourcesByIdAndProcessData', { contentSourceIds });
353
+
354
+ this.contentUpdatesWatchTimer.stopTimer();
355
+
356
+ const promises = this.contentSources.map(
357
+ (contentSourceInstance): Promise<ContentSourceRawData> => {
358
+ const contentSourceId = getContentSourceIdForContentSource(contentSourceInstance);
359
+ if (contentSourceIds.includes(contentSourceId)) {
360
+ return this.loadContentSourceData({
361
+ contentSourceInstance: contentSourceInstance,
362
+ init: false
363
+ });
364
+ } else {
365
+ return Promise.resolve(_.omit(this.contentSourceDataById[contentSourceId], ['models', 'modelMap', 'documents', 'documentMap']));
366
+ }
367
+ }
368
+ );
369
+
370
+ const contentSourceRawDataArr = await Promise.all(promises);
371
+
372
+ this.contentSourceDataById = await this.processData({
373
+ stackbitConfig: this.stackbitConfig,
374
+ configModels: this.configModels,
375
+ presets: this.presets,
376
+ contentSourceRawDataArr: contentSourceRawDataArr
377
+ });
252
378
 
253
379
  this.contentUpdatesWatchTimer.startTimer();
254
380
  }
@@ -259,7 +385,7 @@ export class ContentStore {
259
385
  }: {
260
386
  contentSourceInstance: CSITypes.ContentSourceInterface;
261
387
  init: boolean;
262
- }): Promise<ContentSourceData> {
388
+ }): Promise<ContentSourceRawData> {
263
389
  const contentSourceId = getContentSourceIdForContentSource(contentSourceInstance);
264
390
  this.logger.debug('loadContentSourceData', { contentSourceId, init });
265
391
 
@@ -268,105 +394,40 @@ export class ContentStore {
268
394
  logger: this.logger,
269
395
  userLogger: this.userLogger,
270
396
  userCommandSpawner: this.userCommandSpawner,
271
- localDev: this.localDev
397
+ localDev: this.localDev,
398
+ webhookUrl: this.getWebhookUrl(contentSourceInstance.getContentSourceType(), contentSourceInstance.getProjectId()),
399
+ devAppRestartNeeded: this.devAppRestartNeeded
272
400
  });
273
401
  } else {
274
402
  contentSourceInstance.stopWatchingContentUpdates();
275
403
  await contentSourceInstance.reset();
276
404
  }
277
405
 
278
- // TODO: introduce optimization: don't fetch content source models,
279
- // documents, assets if only stackbitConfig was changed
280
-
281
406
  const csiModels = await contentSourceInstance.getModels();
282
- const locales = await contentSourceInstance?.getLocales();
283
- const defaultLocaleCode = locales?.find((locale) => locale.default)?.code;
284
-
285
- // for older versions of stackbit, it uses models to extend content source models
286
- let modelsNoImage: Exclude<Model, ImageModel>[] = [];
287
- let imageModel: ImageModel | undefined;
288
- if (this.rawStackbitConfig) {
289
- const result = await extendConfig({
290
- config: this.rawStackbitConfig,
291
- externalModels: csiModels
292
- });
293
- for (const error of result?.errors ?? []) {
294
- this.userLogger.warn(error.message);
295
- }
296
- const config = await this.handleConfigAssets(result.config);
297
- const modelsWithImageModel = config?.models ?? [];
298
- const imageModelIndex = modelsWithImageModel.findIndex((model) => isImageModel(model));
299
- if (imageModelIndex > -1) {
300
- imageModel = modelsWithImageModel[imageModelIndex] as ImageModel;
301
- modelsWithImageModel.splice(imageModelIndex, 1);
302
- }
303
- modelsNoImage = modelsWithImageModel as Exclude<Model, ImageModel>[];
304
-
305
- // TODO: load presets externally from config, and create additional map
306
- // that maps presetIds by model name instead of storing that map inside every model
307
-
308
- // Augment presets with srcType and srcProjectId if they don't exist
309
- this.presets = _.reduce(
310
- Object.keys(config?.presets ?? {}),
311
- (accum: Record<string, Preset>, presetId) => {
312
- const preset = config?.presets?.[presetId];
313
- _.set(accum, [presetId], {
314
- ...preset,
315
- srcType: preset?.srcType ?? contentSourceInstance.getContentSourceType(),
316
- srcProjectId: preset?.srcProjectId ?? contentSourceInstance.getProjectId()
317
- });
318
- return accum;
319
- },
320
- {}
321
- );
322
- }
407
+ const csiModelMap = _.keyBy(csiModels, 'name');
323
408
 
324
- if (this.rawStackbitConfig?.mapModels) {
325
- const srcType = contentSourceInstance.getContentSourceType();
326
- const srcProjectId = contentSourceInstance.getProjectId();
327
- const modelsWithSource = modelsNoImage.map((model) => ({
328
- srcType,
329
- srcProjectId,
330
- ...model
331
- }));
332
- const mappedModels = this.rawStackbitConfig.mapModels({
333
- models: modelsWithSource
334
- });
335
- modelsNoImage = mappedModels.map((model) => {
336
- const { srcType, srcProjectId, ...rest } = model;
337
- return rest;
338
- });
339
- }
409
+ const locales = await contentSourceInstance.getLocales();
410
+ const defaultLocaleCode = locales?.find((locale) => locale.default)?.code;
340
411
 
341
- const models: Model[] = imageModel ? [...modelsNoImage, imageModel] : modelsNoImage;
342
- const modelMap = _.keyBy(models, 'name');
343
- const csiModelMap = _.keyBy(csiModels, 'name');
344
412
  const csiDocuments = await contentSourceInstance.getDocuments({ modelMap: csiModelMap });
345
413
  const csiAssets = await contentSourceInstance.getAssets();
346
414
  const csiDocumentMap = _.keyBy(csiDocuments, 'id');
347
415
  const csiAssetMap = _.keyBy(csiAssets, 'id');
348
416
 
349
- const contentStoreDocuments = mapCSIDocumentsToStoreDocuments({
350
- csiDocuments,
351
- contentSourceInstance,
352
- modelMap,
353
- defaultLocaleCode
354
- });
355
417
  const contentStoreAssets = mapCSIAssetsToStoreAssets({
356
418
  csiAssets,
357
419
  contentSourceInstance,
358
420
  defaultLocaleCode
359
421
  });
360
- const documentMap = _.keyBy(contentStoreDocuments, 'srcObjectId');
361
422
  const assetMap = _.keyBy(contentStoreAssets, 'srcObjectId');
362
423
 
363
424
  this.logger.debug('loaded content source data', {
364
425
  contentSourceId,
365
- locales,
366
426
  defaultLocaleCode,
367
- modelCount: models.length,
368
- documentCount: contentStoreDocuments.length,
369
- assetCount: contentStoreAssets.length
427
+ localesCount: locales.length,
428
+ modelCount: csiModels.length,
429
+ documentCount: csiDocuments.length,
430
+ assetCount: csiAssets.length
370
431
  });
371
432
 
372
433
  contentSourceInstance.startWatchingContentUpdates({
@@ -386,28 +447,21 @@ export class ContentStore {
386
447
  },
387
448
  onSchemaChange: async () => {
388
449
  this.logger.debug('content source called onSchemaChange', { contentSourceId });
389
- this.contentSourceDataById[contentSourceId] = await this.loadContentSourceData({
390
- contentSourceInstance: contentSourceInstance,
391
- init: false
392
- });
393
- this.onSchemaChangeCallback();
450
+ await this.reloadContentSourcesByIdAndProcessData({ contentSourceIds: [contentSourceId] });
394
451
  }
395
452
  });
396
453
 
397
454
  return {
398
455
  id: contentSourceId,
399
- type: contentSourceInstance.getContentSourceType(),
400
- projectId: contentSourceInstance.getProjectId(),
456
+ srcType: contentSourceInstance.getContentSourceType(),
457
+ srcProjectId: contentSourceInstance.getProjectId(),
401
458
  instance: contentSourceInstance,
402
459
  locales: locales,
403
460
  defaultLocaleCode: defaultLocaleCode,
404
- models: models,
405
- modelMap: modelMap,
461
+ csiModels: csiModels,
406
462
  csiModelMap: csiModelMap,
407
463
  csiDocuments: csiDocuments,
408
464
  csiDocumentMap: csiDocumentMap,
409
- documents: contentStoreDocuments,
410
- documentMap: documentMap,
411
465
  csiAssets: csiAssets,
412
466
  csiAssetMap: csiAssetMap,
413
467
  assets: contentStoreAssets,
@@ -450,8 +504,8 @@ export class ContentStore {
450
504
  }
451
505
 
452
506
  result.deletedDocuments.push({
453
- srcType: contentSourceData.type,
454
- srcProjectId: contentSourceData.projectId,
507
+ srcType: contentSourceData.srcType,
508
+ srcProjectId: contentSourceData.srcProjectId,
455
509
  srcObjectId: docId
456
510
  });
457
511
  });
@@ -471,15 +525,43 @@ export class ContentStore {
471
525
  }
472
526
 
473
527
  result.deletedAssets.push({
474
- srcType: contentSourceData.type,
475
- srcProjectId: contentSourceData.projectId,
528
+ srcType: contentSourceData.srcType,
529
+ srcProjectId: contentSourceData.srcProjectId,
476
530
  srcObjectId: assetId
477
531
  });
478
532
  });
479
533
 
534
+ // map csi documents through stackbitConfig.mapDocuments
535
+ let mappedDocs = contentChangeEvent.documents;
536
+ if (this.stackbitConfig?.mapDocuments) {
537
+ const csiDocumentsWithSource = contentChangeEvent.documents.map(
538
+ (csiDocument): CSITypes.DocumentWithSource => ({
539
+ srcType: contentSourceData.srcType,
540
+ srcProjectId: contentSourceData.srcProjectId,
541
+ ...csiDocument
542
+ })
543
+ );
544
+
545
+ const modelsWithSource = contentSourceData.models.map(
546
+ (model): CSITypes.ModelWithSource => {
547
+ return {
548
+ srcType: contentSourceData.srcType,
549
+ srcProjectId: contentSourceData.srcProjectId,
550
+ ...model
551
+ };
552
+ }
553
+ );
554
+
555
+ mappedDocs =
556
+ this.stackbitConfig?.mapDocuments?.({
557
+ documents: _.cloneDeep(csiDocumentsWithSource),
558
+ models: _.cloneDeep(modelsWithSource)
559
+ }) ?? csiDocumentsWithSource;
560
+ }
561
+
480
562
  // map csi documents and assets to content store documents and assets
481
563
  const documents = mapCSIDocumentsToStoreDocuments({
482
- csiDocuments: contentChangeEvent.documents,
564
+ csiDocuments: mappedDocs,
483
565
  contentSourceInstance: contentSourceData.instance,
484
566
  modelMap: contentSourceData.modelMap,
485
567
  defaultLocaleCode: contentSourceData.defaultLocaleCode
@@ -492,7 +574,7 @@ export class ContentStore {
492
574
 
493
575
  // update contentSourceData with new or updated documents and assets
494
576
  Object.assign(contentSourceData.csiDocumentMap, _.keyBy(contentChangeEvent.documents, 'id'));
495
- Object.assign(contentSourceData.csiAssets, _.keyBy(contentChangeEvent.assets, 'id'));
577
+ Object.assign(contentSourceData.csiAssetMap, _.keyBy(contentChangeEvent.assets, 'id'));
496
578
  Object.assign(contentSourceData.documentMap, _.keyBy(documents, 'srcObjectId'));
497
579
  Object.assign(contentSourceData.assetMap, _.keyBy(assets, 'srcObjectId'));
498
580
 
@@ -510,8 +592,8 @@ export class ContentStore {
510
592
  contentSourceData.csiDocuments.splice(dataIndex, 1, csiDocument);
511
593
  }
512
594
  result.updatedDocuments.push({
513
- srcType: contentSourceData.type,
514
- srcProjectId: contentSourceData.projectId,
595
+ srcType: contentSourceData.srcType,
596
+ srcProjectId: contentSourceData.srcProjectId,
515
597
  srcObjectId: document.srcObjectId
516
598
  });
517
599
  }
@@ -530,8 +612,8 @@ export class ContentStore {
530
612
  contentSourceData.csiAssets.splice(index, 1, csiAsset);
531
613
  }
532
614
  result.updatedAssets.push({
533
- srcType: contentSourceData.type,
534
- srcProjectId: contentSourceData.projectId,
615
+ srcType: contentSourceData.srcType,
616
+ srcProjectId: contentSourceData.srcProjectId,
535
617
  srcObjectId: asset.srcObjectId
536
618
  });
537
619
  }
@@ -539,13 +621,95 @@ export class ContentStore {
539
621
  return result;
540
622
  }
541
623
 
542
- getModels(): Record<string, Record<string, Record<string, Model>>> {
624
+ private async processData({
625
+ stackbitConfig,
626
+ configModels,
627
+ presets,
628
+ contentSourceRawDataArr
629
+ }: {
630
+ stackbitConfig: Config | null;
631
+ configModels: Model[];
632
+ presets: Record<string, Preset>;
633
+ contentSourceRawDataArr: ContentSourceRawData[];
634
+ }): Promise<Record<string, ContentSourceData>> {
635
+ const modelsWithSource = contentSourceRawDataArr.reduce((accum: CSITypes.ModelWithSource[], csData) => {
636
+ const mergedModels = mergeConfigModelsWithExternalModels({
637
+ configModels: configModels,
638
+ externalModels: csData.csiModels
639
+ });
640
+ const modelsWithSource = mergedModels.map(
641
+ (model): CSITypes.ModelWithSource => {
642
+ return {
643
+ srcType: csData.srcType,
644
+ srcProjectId: csData.id,
645
+ ...model
646
+ };
647
+ }
648
+ );
649
+ return accum.concat(modelsWithSource);
650
+ }, []);
651
+
652
+ // TODO: Is there a better way than deep cloning objects before passing them to user methods?
653
+ // Not cloning mutable objects will break the internal state if user mutates the objects.
654
+ const mappedModels = stackbitConfig?.mapModels?.({ models: _.cloneDeep(modelsWithSource) }) ?? modelsWithSource;
655
+ const normalizedModels = normalizeModels({ models: mappedModels, logger: this.userLogger });
656
+ const validatedModels = validateModels({ models: normalizedModels, logger: this.userLogger });
657
+ const modelsWithPresetsIds = extendModelsWithPresetsIds({ models: validatedModels, presets });
658
+ const { models } = await this.handleConfigAssets({ models: modelsWithPresetsIds });
659
+
660
+ const csiDocumentsWithSource = contentSourceRawDataArr.reduce((accum: CSITypes.DocumentWithSource[], csData) => {
661
+ const csiDocumentsWithSource = csData.csiDocuments.map(
662
+ (csiDocument): CSITypes.DocumentWithSource => ({
663
+ srcType: csData.srcType,
664
+ srcProjectId: csData.id,
665
+ ...csiDocument
666
+ })
667
+ );
668
+ return accum.concat(csiDocumentsWithSource);
669
+ }, []);
670
+
671
+ // TODO: Is there a better way than deep cloning objects before passing them to user methods?
672
+ // Not cloning mutable objects will break the internal state if user mutates the objects.
673
+ const mappedDocs =
674
+ stackbitConfig?.mapDocuments?.({
675
+ documents: _.cloneDeep(csiDocumentsWithSource),
676
+ models: _.cloneDeep(models)
677
+ }) ?? csiDocumentsWithSource;
678
+
679
+ const modelMapByContentSource = groupModelsByContentSource({ models: models });
680
+ const documentMapByContentSource = groupDocumentsByContentSource({ documents: mappedDocs });
681
+
682
+ const contentSourceDataArr = contentSourceRawDataArr.map(
683
+ (csData): ContentSourceData => {
684
+ const modelMap = _.get(modelMapByContentSource, [csData.srcType, csData.id], {});
685
+ const mappedCSIDocuments = _.get(documentMapByContentSource, [csData.srcType, csData.id]);
686
+ const documents = mapCSIDocumentsToStoreDocuments({
687
+ csiDocuments: mappedCSIDocuments,
688
+ contentSourceInstance: csData.instance,
689
+ defaultLocaleCode: csData.defaultLocaleCode,
690
+ modelMap: modelMap
691
+ });
692
+ return {
693
+ ...csData,
694
+ models: Object.values(modelMap),
695
+ modelMap,
696
+ documents,
697
+ documentMap: _.keyBy(documents, 'srcObjectId')
698
+ };
699
+ }
700
+ );
701
+
702
+ return _.keyBy(contentSourceDataArr, 'id');
703
+ }
704
+
705
+ getModels(): Record<string, Record<string, Record<string, Model | ImageModel>>> {
543
706
  return _.reduce(
544
707
  this.contentSourceDataById,
545
- (result: Record<string, Record<string, Record<string, Model>>>, contentSourceData) => {
708
+ (result: Record<string, Record<string, Record<string, Model | ImageModel>>>, contentSourceData) => {
546
709
  const contentSourceType = contentSourceData.instance.getContentSourceType();
547
710
  const srcProjectId = contentSourceData.instance.getProjectId();
548
711
  _.set(result, [contentSourceType, srcProjectId], contentSourceData.modelMap);
712
+ _.set(result, [contentSourceType, srcProjectId, '__image_model'], IMAGE_MODEL);
549
713
  return result;
550
714
  },
551
715
  {}
@@ -593,8 +757,8 @@ export class ContentStore {
593
757
  return reducePromise(
594
758
  contentSourceDataArr,
595
759
  async (accum: ContentStoreTypes.HasAccessResult, contentSourceData) => {
596
- const srcType = contentSourceData.type;
597
- const srcProjectId = contentSourceData.projectId;
760
+ const srcType = contentSourceData.srcType;
761
+ const srcProjectId = contentSourceData.srcProjectId;
598
762
  const userContext = getUserContextForSrcType(srcType, user);
599
763
  let result = await contentSourceData.instance.hasAccess({ userContext });
600
764
  // backwards compatibility with older CSI version
@@ -1278,15 +1442,15 @@ export class ContentStore {
1278
1442
  const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
1279
1443
  locale = locale ?? contentSourceData.defaultLocaleCode;
1280
1444
  const { documents, assets } = getCSIDocumentsAndAssetsFromContentSourceDataByIds(contentSourceData, contentSourceObjects);
1281
- const userContext = getUserContextForSrcType(contentSourceData.type, user);
1445
+ const userContext = getUserContextForSrcType(contentSourceData.srcType, user);
1282
1446
  const internalValidationErrors = internalValidateContent(documents, assets, contentSourceData);
1283
1447
  const validationResult = await contentSourceData.instance.validateDocuments({ documents, assets, locale, userContext });
1284
1448
  errors = errors.concat(
1285
1449
  internalValidationErrors,
1286
1450
  validationResult.errors.map((validationError) => ({
1287
1451
  message: validationError.message,
1288
- srcType: contentSourceData.type,
1289
- srcProjectId: contentSourceData.projectId,
1452
+ srcType: contentSourceData.srcType,
1453
+ srcProjectId: contentSourceData.srcProjectId,
1290
1454
  srcObjectType: validationError.objectType,
1291
1455
  srcObjectId: validationError.objectId,
1292
1456
  fieldPath: validationError.fieldPath,
@@ -1336,7 +1500,7 @@ export class ContentStore {
1336
1500
  const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
1337
1501
 
1338
1502
  documents.push(...contentSourceData.documents);
1339
- _.set(schema, [contentSourceData.type, contentSourceData.projectId], contentSourceData.modelMap);
1503
+ _.set(schema, [contentSourceData.srcType, contentSourceData.srcProjectId], contentSourceData.modelMap);
1340
1504
  });
1341
1505
 
1342
1506
  return searchDocuments({
@@ -1352,7 +1516,7 @@ export class ContentStore {
1352
1516
  const objectsBySourceId = _.groupBy(objects, (object) => getContentSourceId(object.srcType, object.srcProjectId));
1353
1517
  for (const [contentSourceId, contentSourceObjects] of Object.entries(objectsBySourceId)) {
1354
1518
  const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
1355
- const userContext = getUserContextForSrcType(contentSourceData.type, user);
1519
+ const userContext = getUserContextForSrcType(contentSourceData.srcType, user);
1356
1520
  const { documents, assets } = getCSIDocumentsAndAssetsFromContentSourceDataByIds(contentSourceData, contentSourceObjects);
1357
1521
  await contentSourceData.instance.publishDocuments({ documents, assets, userContext });
1358
1522
  }
@@ -1365,6 +1529,19 @@ export class ContentStore {
1365
1529
  }
1366
1530
  return contentSourceData;
1367
1531
  }
1532
+
1533
+ onWebhook({srcType, srcProjectId, data, headers}: {srcType: string; srcProjectId: string; data: unknown; headers: Record<string, string>}) {
1534
+ const contentSourceId = getContentSourceId(srcType, srcProjectId);
1535
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
1536
+ return contentSourceData.instance.onWebhook?.({ data, headers });
1537
+ }
1538
+
1539
+ getWebhookUrl(contentSourceType: string, projectId: string) {
1540
+ if (!this.webhookUrl) {
1541
+ return undefined;
1542
+ }
1543
+ return `${this.webhookUrl}/${encodeURIComponent(contentSourceType)}/${encodeURIComponent(projectId)}`
1544
+ }
1368
1545
  }
1369
1546
 
1370
1547
  function mapStoreFieldsToOperationFields({
@@ -1440,8 +1617,8 @@ function validateDocumentFields(
1440
1617
  if (!objRef) {
1441
1618
  errors.push({
1442
1619
  fieldPath,
1443
- srcType: contentSourceData.type,
1444
- srcProjectId: contentSourceData.projectId,
1620
+ srcType: contentSourceData.srcType,
1621
+ srcProjectId: contentSourceData.srcProjectId,
1445
1622
  srcObjectType: documentField.refType,
1446
1623
  srcObjectId: document.id,
1447
1624
  message: `Can't find referenced ${documentField.refType}: ${documentField.refId}`