@stackbit/cms-core 0.1.6 → 0.1.8

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,9 +26,20 @@ 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;
@@ -24,21 +49,23 @@ export interface ContentSourceOptions {
24
49
  userCommandSpawner?: UserCommandSpawner;
25
50
  onSchemaChangeCallback: () => void;
26
51
  onContentChangeCallback: (contentChanges: ContentStoreTypes.ContentChangeResult) => void;
27
- handleConfigAssets: (config: Config) => Promise<Config>;
52
+ handleConfigAssets: HandleConfigAssets;
28
53
  devAppRestartNeeded?: () => void;
29
54
  }
30
55
 
31
56
  interface ContentSourceData {
32
57
  id: string;
33
58
  instance: CSITypes.ContentSourceInterface;
34
- type: string;
35
- projectId: string;
59
+ srcType: string;
60
+ srcProjectId: string;
36
61
  locales?: CSITypes.Locale[];
37
62
  defaultLocaleCode?: string;
38
63
  /* Array of extended and validated Models */
39
64
  models: Model[];
40
65
  /* Map of extended and validated Models by model name */
41
66
  modelMap: Record<string, Model>;
67
+ /* Array of original Models (as provided by content source) */
68
+ csiModels: CSITypes.Model[];
42
69
  /* Map of original Models (as provided by content source) by model name */
43
70
  csiModelMap: Record<string, CSITypes.Model>;
44
71
  /* Array of original content source Documents */
@@ -59,6 +86,8 @@ interface ContentSourceData {
59
86
  assetMap: Record<string, ContentStoreTypes.Asset>;
60
87
  }
61
88
 
89
+ type ContentSourceRawData = Omit<ContentSourceData, 'models' | 'modelMap' | 'documents' | 'documentMap'>;
90
+
62
91
  export class ContentStore {
63
92
  private readonly logger: ContentStoreTypes.Logger;
64
93
  private readonly userLogger: ContentStoreTypes.Logger;
@@ -67,13 +96,15 @@ export class ContentStore {
67
96
  private readonly webhookUrl?: string;
68
97
  private readonly onSchemaChangeCallback: () => void;
69
98
  private readonly onContentChangeCallback: (contentChanges: ContentStoreTypes.ContentChangeResult) => void;
70
- private readonly handleConfigAssets: (config: Config) => Promise<Config>;
99
+ private readonly handleConfigAssets: HandleConfigAssets;
71
100
  private readonly devAppRestartNeeded?: () => void;
72
101
  private contentSources: CSITypes.ContentSourceInterface[] = [];
73
102
  private contentSourceDataById: Record<string, ContentSourceData> = {};
74
103
  private contentUpdatesWatchTimer: Timer;
75
- private rawStackbitConfig: RawConfigWithPaths | null = null;
76
- private presets?: Record<string, any>;
104
+ private stackbitConfig: Config | null = null;
105
+ private yamlModels: Model[] = [];
106
+ private configModels: Model[] = [];
107
+ private presets: PresetMap = {};
77
108
 
78
109
  constructor(options: ContentSourceOptions) {
79
110
  this.logger = options.logger.createLogger({ label: 'content-store' });
@@ -120,6 +151,10 @@ export class ContentStore {
120
151
 
121
152
  async init({ stackbitConfig }: { stackbitConfig: Config | null }): Promise<void> {
122
153
  this.logger.debug('init');
154
+ if (stackbitConfig) {
155
+ this.yamlModels = await this.loadYamlModels({ stackbitConfig });
156
+ this.presets = await this.loadPresets({ stackbitConfig });
157
+ }
123
158
  await this.setStackbitConfig({ stackbitConfig, init: true });
124
159
  }
125
160
 
@@ -129,37 +164,19 @@ export class ContentStore {
129
164
  }
130
165
 
131
166
  private async setStackbitConfig({ stackbitConfig, init }: { stackbitConfig: Config | null; init: boolean }) {
132
- // TODO: Use stackbitConfig instead of loading rawStackbitConfig here
133
- // by splitting config loader into independent phases:
134
- // 1. load and validate only the root config props
135
- // 2. load content-source models
136
- // 3. merge content-source models with config.models or via config.mapModels, sanitize and validate
137
- // 4. load presets, adjust presets to have srcType and srcProjectId
138
- if (!stackbitConfig) {
139
- this.rawStackbitConfig = null;
140
- } else {
141
- const rawConfigResult = await loadConfigFromDir({
142
- dirPath: stackbitConfig.dirPath,
143
- logger: this.logger
144
- });
145
- for (const error of rawConfigResult.errors) {
146
- this.userLogger.warn(error.message);
147
- }
148
- if (rawConfigResult.config) {
149
- this.rawStackbitConfig = {
150
- ...rawConfigResult.config,
151
- contentSources: stackbitConfig.contentSources
152
- };
153
- } else {
154
- this.rawStackbitConfig = null;
155
- }
156
- }
157
- await this.loadContentSources({ init });
167
+ this.stackbitConfig = stackbitConfig;
168
+ this.configModels = this.mergeConfigModels(this.stackbitConfig, this.yamlModels);
169
+
170
+ await this.loadAllContentSourcesAndProcessData({ init });
158
171
  }
159
172
 
160
173
  /**
161
174
  * This method is called when contentUpdatesWatchTimer receives timeout.
162
- * 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.
163
180
  */
164
181
  private handleTimerTimeout() {
165
182
  for (const contentSourceInstance of this.contentSources) {
@@ -179,32 +196,55 @@ export class ContentStore {
179
196
  }
180
197
 
181
198
  this.logger.debug('keepAlive => contentUpdatesWatchTimer is not running => load content source data');
182
- await this.loadContentSources({ init: false });
183
- }
184
-
185
- /**
186
- * This method is called when a content source notifies Stackbit of models
187
- * changes via webhook. When this happens, all content source data
188
- *
189
- * For example, Contentful notifies Stackbit of any content-type changes via
190
- * special webhook.
191
- *
192
- * @param contentSourceId
193
- */
194
- async onContentSourceSchemaChange({ contentSourceId }: { contentSourceId: string }) {
195
- this.logger.debug('onContentSourceSchemaChange', { contentSourceId });
196
- const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
197
- this.contentSourceDataById[contentSourceId] = await this.loadContentSourceData({
198
- contentSourceInstance: contentSourceData.instance,
199
- init: false
200
- });
201
- this.onSchemaChangeCallback();
199
+ await this.loadAllContentSourcesAndProcessData({ init: false });
202
200
  }
203
201
 
204
202
  async onFilesChange(updatedFiles: string[]): Promise<{ schemaChanged?: boolean; contentChanges: ContentStoreTypes.ContentChangeResult }> {
205
203
  this.logger.debug('onFilesChange');
206
204
 
207
- let someContentSourceSchemaUpdated = false;
205
+ let schemaChanged = false;
206
+
207
+ if (this.stackbitConfig) {
208
+ // Check if any of the yaml models files were changed. If yaml model files were changed,
209
+ // reload them and merge them with models defined in stackbit config.
210
+ const modelDirs = getYamlModelDirs(this.stackbitConfig);
211
+ const yamlModelsChanged = updatedFiles.find((updatedFile) => _.some(modelDirs, (modelDir) => updatedFile.startsWith(modelDir)));
212
+ if (yamlModelsChanged) {
213
+ this.logger.debug('identified change in stackbit model files');
214
+ schemaChanged = true;
215
+ this.yamlModels = await this.loadYamlModels({ stackbitConfig: this.stackbitConfig });
216
+ this.configModels = this.mergeConfigModels(this.stackbitConfig, this.yamlModels);
217
+ }
218
+
219
+ // Check if any of the preset files were changed. If presets were changed, reload them.
220
+ const presetDirs = getPresetDirs(this.stackbitConfig);
221
+ const presetsChanged = updatedFiles.find((updatedFile) => _.some(presetDirs, (presetDir) => updatedFile.startsWith(presetDir)));
222
+ if (presetsChanged) {
223
+ this.logger.debug('identified change in stackbit preset files');
224
+ schemaChanged = true;
225
+ this.presets = await this.loadPresets({ stackbitConfig: this.stackbitConfig });
226
+ }
227
+ }
228
+
229
+ const contentSourceIdsWithChangedSchema: string[] = [];
230
+ const contentChangeEvents: { contentSourceId: string; contentChangeEvent: CSITypes.ContentChangeEvent }[] = [];
231
+
232
+ for (const contentSourceInstance of this.contentSources) {
233
+ const contentSourceId = getContentSourceIdForContentSource(contentSourceInstance);
234
+ this.logger.debug(`call onFilesChange for contentSource: ${contentSourceId}`);
235
+ const onFilesChangeResult = (await contentSourceInstance.onFilesChange?.({ updatedFiles: updatedFiles })) ?? {};
236
+ this.logger.debug(`schemaChanged: ${onFilesChangeResult.schemaChanged}, has contentChangeEvent: ${!!onFilesChangeResult.contentChangeEvent}`);
237
+
238
+ // if schema is changed, there is no need to return contentChanges
239
+ // because schema changes reloads everything and implies content changes
240
+ if (onFilesChangeResult.schemaChanged) {
241
+ schemaChanged = true;
242
+ contentSourceIdsWithChangedSchema.push(contentSourceId);
243
+ } else if (onFilesChangeResult.contentChangeEvent) {
244
+ contentChangeEvents.push({ contentSourceId, contentChangeEvent: onFilesChangeResult.contentChangeEvent });
245
+ }
246
+ }
247
+
208
248
  const contentChanges: ContentStoreTypes.ContentChangeResult = {
209
249
  updatedDocuments: [],
210
250
  updatedAssets: [],
@@ -212,49 +252,117 @@ export class ContentStore {
212
252
  deletedAssets: []
213
253
  };
214
254
 
215
- for (const contentSourceInstance of this.contentSources) {
216
- const contentSourceId = getContentSourceIdForContentSource(contentSourceInstance);
217
- this.logger.debug(`call onFilesChange for contentSource: ${contentSourceId}`);
218
- const { schemaChanged, contentChangeEvent } = (await contentSourceInstance.onFilesChange?.({ updatedFiles: updatedFiles })) ?? {};
219
- this.logger.debug(`schemaChanged: ${schemaChanged}, has contentChangeEvent: ${!!contentChangeEvent}`);
220
- // if schema is changed, there is no need to return contentChanges
221
- // because schema changes reloads everything and implies content changes
222
- if (schemaChanged) {
223
- someContentSourceSchemaUpdated = true;
224
- this.contentSourceDataById[contentSourceId] = await this.loadContentSourceData({ contentSourceInstance, init: false });
225
- } else if (contentChangeEvent) {
255
+ // If the schema was changed, there is no need to accumulate or notify about content changes.
256
+ // The processData will update the store with the latest data. And once the Studio receives
257
+ // the schemaChanged notification it will reload all the models and the documents with their latest state.
258
+ if (schemaChanged) {
259
+ await this.reloadContentSourcesByIdAndProcessData({ contentSourceIds: contentSourceIdsWithChangedSchema });
260
+ } else {
261
+ contentChangeEvents.reduce((contentChanges, { contentSourceId, contentChangeEvent }) => {
226
262
  const contentChangeResult = this.onContentChange(contentSourceId, contentChangeEvent);
227
263
  contentChanges.updatedDocuments = contentChanges.updatedDocuments.concat(contentChangeResult.updatedDocuments);
228
264
  contentChanges.updatedAssets = contentChanges.updatedAssets.concat(contentChangeResult.updatedAssets);
229
265
  contentChanges.deletedDocuments = contentChanges.deletedDocuments.concat(contentChangeResult.deletedDocuments);
230
266
  contentChanges.deletedAssets = contentChanges.deletedAssets.concat(contentChangeResult.deletedAssets);
231
- }
267
+ return contentChanges;
268
+ }, contentChanges);
232
269
  }
233
270
 
271
+ // TODO: maybe instead of returning object with results
272
+ // replace with this.onSchemaChangeCallback() and this.onContentChangeCallback(contentChanges) for consistency of data flow?
234
273
  return {
235
- schemaChanged: someContentSourceSchemaUpdated,
274
+ schemaChanged: schemaChanged,
236
275
  contentChanges: contentChanges
237
276
  };
238
277
  }
239
278
 
240
- private async loadContentSources({ init }: { init: boolean }) {
241
- this.logger.debug('loadContentSources', { init });
279
+ private async loadYamlModels({ stackbitConfig }: { stackbitConfig: Config }): Promise<Model[]> {
280
+ const yamlModelsResult = await loadYamlModelsFromFiles(stackbitConfig);
281
+ for (const error of yamlModelsResult.errors) {
282
+ this.userLogger.warn(error.message);
283
+ }
284
+ return yamlModelsResult.models;
285
+ }
286
+
287
+ private mergeConfigModels(stackbitConfig: Config | null, modelsFromFiles: Model[]) {
288
+ const configModelsResult = mergeConfigModelsWithModelsFromFiles(stackbitConfig?.models ?? [], modelsFromFiles);
289
+ for (const error of configModelsResult.errors) {
290
+ this.userLogger.warn(error.message);
291
+ }
292
+ return configModelsResult.models;
293
+ }
294
+
295
+ private async loadPresets({ stackbitConfig }: { stackbitConfig: Config }): Promise<Record<string, Preset>> {
296
+ const contentSources = stackbitConfig?.contentSources ?? [];
297
+ const singleContentSource = contentSources.length === 1 ? contentSources[0] : null;
298
+ const presetResult = await loadPresets({
299
+ config: stackbitConfig,
300
+ ...(singleContentSource
301
+ ? {
302
+ fallbackSrcType: singleContentSource.getContentSourceType(),
303
+ fallbackSrcProjectId: singleContentSource.getProjectId()
304
+ }
305
+ : null)
306
+ });
307
+ for (const error of presetResult.errors) {
308
+ this.userLogger.warn(error.message);
309
+ }
310
+ const { presets } = await this.handleConfigAssets({ presets: presetResult.presets });
311
+ return presets;
312
+ }
313
+
314
+ private async loadAllContentSourcesAndProcessData({ init }: { init: boolean }) {
315
+ this.logger.debug('loadAllContentSourcesAndProcessData', { init });
242
316
 
243
317
  this.contentUpdatesWatchTimer.stopTimer();
244
318
 
245
- // TODO: move CSITypes to separate package so Config will be able to use them
246
- const contentSources: CSITypes.ContentSourceInterface[] = (this.rawStackbitConfig?.contentSources ?? []) as CSITypes.ContentSourceInterface[];
319
+ const contentSources = this.stackbitConfig?.contentSources ?? [];
247
320
 
248
321
  const promises = contentSources.map((contentSourceInstance) => {
249
322
  return this.loadContentSourceData({ contentSourceInstance, init });
250
323
  });
251
324
 
252
- const contentSourceDataArr = await Promise.all(promises);
253
- const contentSourceDataById: Record<string, ContentSourceData> = _.keyBy(contentSourceDataArr, 'id');
325
+ const contentSourceRawDataArr = await Promise.all(promises);
254
326
 
255
327
  // update all content sources at once to prevent race conditions
328
+ this.contentSourceDataById = await this.processData({
329
+ stackbitConfig: this.stackbitConfig,
330
+ configModels: this.configModels,
331
+ presets: this.presets,
332
+ contentSourceRawDataArr: contentSourceRawDataArr
333
+ });
256
334
  this.contentSources = contentSources;
257
- this.contentSourceDataById = contentSourceDataById;
335
+
336
+ this.contentUpdatesWatchTimer.startTimer();
337
+ }
338
+
339
+ private async reloadContentSourcesByIdAndProcessData({ contentSourceIds }: { contentSourceIds: string[] }) {
340
+ this.logger.debug('reloadContentSourcesByIdAndProcessData', { contentSourceIds });
341
+
342
+ this.contentUpdatesWatchTimer.stopTimer();
343
+
344
+ const promises = this.contentSources.map(
345
+ (contentSourceInstance): Promise<ContentSourceRawData> => {
346
+ const contentSourceId = getContentSourceIdForContentSource(contentSourceInstance);
347
+ if (contentSourceIds.includes(contentSourceId)) {
348
+ return this.loadContentSourceData({
349
+ contentSourceInstance: contentSourceInstance,
350
+ init: false
351
+ });
352
+ } else {
353
+ return Promise.resolve(_.omit(this.contentSourceDataById[contentSourceId], ['models', 'modelMap', 'documents', 'documentMap']));
354
+ }
355
+ }
356
+ );
357
+
358
+ const contentSourceRawDataArr = await Promise.all(promises);
359
+
360
+ this.contentSourceDataById = await this.processData({
361
+ stackbitConfig: this.stackbitConfig,
362
+ configModels: this.configModels,
363
+ presets: this.presets,
364
+ contentSourceRawDataArr: contentSourceRawDataArr
365
+ });
258
366
 
259
367
  this.contentUpdatesWatchTimer.startTimer();
260
368
  }
@@ -265,7 +373,7 @@ export class ContentStore {
265
373
  }: {
266
374
  contentSourceInstance: CSITypes.ContentSourceInterface;
267
375
  init: boolean;
268
- }): Promise<ContentSourceData> {
376
+ }): Promise<ContentSourceRawData> {
269
377
  const contentSourceId = getContentSourceIdForContentSource(contentSourceInstance);
270
378
  this.logger.debug('loadContentSourceData', { contentSourceId, init });
271
379
 
@@ -283,98 +391,31 @@ export class ContentStore {
283
391
  await contentSourceInstance.reset();
284
392
  }
285
393
 
286
- // TODO: introduce optimization: don't fetch content source models,
287
- // documents, assets if only stackbitConfig was changed
288
-
289
394
  const csiModels = await contentSourceInstance.getModels();
290
- const locales = await contentSourceInstance?.getLocales();
291
- const defaultLocaleCode = locales?.find((locale) => locale.default)?.code;
292
-
293
- // for older versions of stackbit, it uses models to extend content source models
294
- let modelsNoImage: Exclude<Model, ImageModel>[] = [];
295
- let imageModel: ImageModel | undefined;
296
- if (this.rawStackbitConfig) {
297
- const result = await extendConfig({
298
- config: this.rawStackbitConfig,
299
- externalModels: csiModels
300
- });
301
- for (const error of result?.errors ?? []) {
302
- this.userLogger.warn(error.message);
303
- }
304
- const config = await this.handleConfigAssets(result.config);
305
- const modelsWithImageModel = config?.models ?? [];
306
- const imageModelIndex = modelsWithImageModel.findIndex((model) => isImageModel(model));
307
- if (imageModelIndex > -1) {
308
- imageModel = modelsWithImageModel[imageModelIndex] as ImageModel;
309
- modelsWithImageModel.splice(imageModelIndex, 1);
310
- }
311
- modelsNoImage = modelsWithImageModel as Exclude<Model, ImageModel>[];
312
-
313
- // TODO: load presets externally from config, and create additional map
314
- // that maps presetIds by model name instead of storing that map inside every model
315
-
316
- // Augment presets with srcType and srcProjectId if they don't exist
317
- this.presets = _.reduce(
318
- Object.keys(config?.presets ?? {}),
319
- (accum: Record<string, Preset>, presetId) => {
320
- const preset = config?.presets?.[presetId];
321
- _.set(accum, [presetId], {
322
- ...preset,
323
- srcType: preset?.srcType ?? contentSourceInstance.getContentSourceType(),
324
- srcProjectId: preset?.srcProjectId ?? contentSourceInstance.getProjectId()
325
- });
326
- return accum;
327
- },
328
- {}
329
- );
330
- }
395
+ const csiModelMap = _.keyBy(csiModels, 'name');
331
396
 
332
- if (this.rawStackbitConfig?.mapModels) {
333
- const srcType = contentSourceInstance.getContentSourceType();
334
- const srcProjectId = contentSourceInstance.getProjectId();
335
- const modelsWithSource = modelsNoImage.map((model) => ({
336
- srcType,
337
- srcProjectId,
338
- ...model
339
- }));
340
- const mappedModels = this.rawStackbitConfig.mapModels({
341
- models: modelsWithSource
342
- });
343
- modelsNoImage = mappedModels.map((model) => {
344
- const { srcType, srcProjectId, ...rest } = model;
345
- return rest;
346
- });
347
- }
397
+ const locales = await contentSourceInstance.getLocales();
398
+ const defaultLocaleCode = locales?.find((locale) => locale.default)?.code;
348
399
 
349
- const models: Model[] = imageModel ? [...modelsNoImage, imageModel] : modelsNoImage;
350
- const modelMap = _.keyBy(models, 'name');
351
- const csiModelMap = _.keyBy(csiModels, 'name');
352
400
  const csiDocuments = await contentSourceInstance.getDocuments({ modelMap: csiModelMap });
353
401
  const csiAssets = await contentSourceInstance.getAssets();
354
402
  const csiDocumentMap = _.keyBy(csiDocuments, 'id');
355
403
  const csiAssetMap = _.keyBy(csiAssets, 'id');
356
404
 
357
- const contentStoreDocuments = mapCSIDocumentsToStoreDocuments({
358
- csiDocuments,
359
- contentSourceInstance,
360
- modelMap,
361
- defaultLocaleCode
362
- });
363
405
  const contentStoreAssets = mapCSIAssetsToStoreAssets({
364
406
  csiAssets,
365
407
  contentSourceInstance,
366
408
  defaultLocaleCode
367
409
  });
368
- const documentMap = _.keyBy(contentStoreDocuments, 'srcObjectId');
369
410
  const assetMap = _.keyBy(contentStoreAssets, 'srcObjectId');
370
411
 
371
412
  this.logger.debug('loaded content source data', {
372
413
  contentSourceId,
373
- locales,
374
414
  defaultLocaleCode,
375
- modelCount: models.length,
376
- documentCount: contentStoreDocuments.length,
377
- assetCount: contentStoreAssets.length
415
+ localesCount: locales.length,
416
+ modelCount: csiModels.length,
417
+ documentCount: csiDocuments.length,
418
+ assetCount: csiAssets.length
378
419
  });
379
420
 
380
421
  contentSourceInstance.startWatchingContentUpdates({
@@ -394,28 +435,22 @@ export class ContentStore {
394
435
  },
395
436
  onSchemaChange: async () => {
396
437
  this.logger.debug('content source called onSchemaChange', { contentSourceId });
397
- this.contentSourceDataById[contentSourceId] = await this.loadContentSourceData({
398
- contentSourceInstance: contentSourceInstance,
399
- init: false
400
- });
438
+ await this.reloadContentSourcesByIdAndProcessData({ contentSourceIds: [contentSourceId] });
401
439
  this.onSchemaChangeCallback();
402
440
  }
403
441
  });
404
442
 
405
443
  return {
406
444
  id: contentSourceId,
407
- type: contentSourceInstance.getContentSourceType(),
408
- projectId: contentSourceInstance.getProjectId(),
445
+ srcType: contentSourceInstance.getContentSourceType(),
446
+ srcProjectId: contentSourceInstance.getProjectId(),
409
447
  instance: contentSourceInstance,
410
448
  locales: locales,
411
449
  defaultLocaleCode: defaultLocaleCode,
412
- models: models,
413
- modelMap: modelMap,
450
+ csiModels: csiModels,
414
451
  csiModelMap: csiModelMap,
415
452
  csiDocuments: csiDocuments,
416
453
  csiDocumentMap: csiDocumentMap,
417
- documents: contentStoreDocuments,
418
- documentMap: documentMap,
419
454
  csiAssets: csiAssets,
420
455
  csiAssetMap: csiAssetMap,
421
456
  assets: contentStoreAssets,
@@ -458,8 +493,8 @@ export class ContentStore {
458
493
  }
459
494
 
460
495
  result.deletedDocuments.push({
461
- srcType: contentSourceData.type,
462
- srcProjectId: contentSourceData.projectId,
496
+ srcType: contentSourceData.srcType,
497
+ srcProjectId: contentSourceData.srcProjectId,
463
498
  srcObjectId: docId
464
499
  });
465
500
  });
@@ -479,15 +514,43 @@ export class ContentStore {
479
514
  }
480
515
 
481
516
  result.deletedAssets.push({
482
- srcType: contentSourceData.type,
483
- srcProjectId: contentSourceData.projectId,
517
+ srcType: contentSourceData.srcType,
518
+ srcProjectId: contentSourceData.srcProjectId,
484
519
  srcObjectId: assetId
485
520
  });
486
521
  });
487
522
 
523
+ // map csi documents through stackbitConfig.mapDocuments
524
+ let mappedDocs = contentChangeEvent.documents;
525
+ if (this.stackbitConfig?.mapDocuments) {
526
+ const csiDocumentsWithSource = contentChangeEvent.documents.map(
527
+ (csiDocument): CSITypes.DocumentWithSource => ({
528
+ srcType: contentSourceData.srcType,
529
+ srcProjectId: contentSourceData.srcProjectId,
530
+ ...csiDocument
531
+ })
532
+ );
533
+
534
+ const modelsWithSource = contentSourceData.models.map(
535
+ (model): CSITypes.ModelWithSource => {
536
+ return {
537
+ srcType: contentSourceData.srcType,
538
+ srcProjectId: contentSourceData.srcProjectId,
539
+ ...model
540
+ };
541
+ }
542
+ );
543
+
544
+ mappedDocs =
545
+ this.stackbitConfig?.mapDocuments?.({
546
+ documents: _.cloneDeep(csiDocumentsWithSource),
547
+ models: _.cloneDeep(modelsWithSource)
548
+ }) ?? csiDocumentsWithSource;
549
+ }
550
+
488
551
  // map csi documents and assets to content store documents and assets
489
552
  const documents = mapCSIDocumentsToStoreDocuments({
490
- csiDocuments: contentChangeEvent.documents,
553
+ csiDocuments: mappedDocs,
491
554
  contentSourceInstance: contentSourceData.instance,
492
555
  modelMap: contentSourceData.modelMap,
493
556
  defaultLocaleCode: contentSourceData.defaultLocaleCode
@@ -500,7 +563,7 @@ export class ContentStore {
500
563
 
501
564
  // update contentSourceData with new or updated documents and assets
502
565
  Object.assign(contentSourceData.csiDocumentMap, _.keyBy(contentChangeEvent.documents, 'id'));
503
- Object.assign(contentSourceData.csiAssets, _.keyBy(contentChangeEvent.assets, 'id'));
566
+ Object.assign(contentSourceData.csiAssetMap, _.keyBy(contentChangeEvent.assets, 'id'));
504
567
  Object.assign(contentSourceData.documentMap, _.keyBy(documents, 'srcObjectId'));
505
568
  Object.assign(contentSourceData.assetMap, _.keyBy(assets, 'srcObjectId'));
506
569
 
@@ -518,8 +581,8 @@ export class ContentStore {
518
581
  contentSourceData.csiDocuments.splice(dataIndex, 1, csiDocument);
519
582
  }
520
583
  result.updatedDocuments.push({
521
- srcType: contentSourceData.type,
522
- srcProjectId: contentSourceData.projectId,
584
+ srcType: contentSourceData.srcType,
585
+ srcProjectId: contentSourceData.srcProjectId,
523
586
  srcObjectId: document.srcObjectId
524
587
  });
525
588
  }
@@ -538,8 +601,8 @@ export class ContentStore {
538
601
  contentSourceData.csiAssets.splice(index, 1, csiAsset);
539
602
  }
540
603
  result.updatedAssets.push({
541
- srcType: contentSourceData.type,
542
- srcProjectId: contentSourceData.projectId,
604
+ srcType: contentSourceData.srcType,
605
+ srcProjectId: contentSourceData.srcProjectId,
543
606
  srcObjectId: asset.srcObjectId
544
607
  });
545
608
  }
@@ -547,13 +610,95 @@ export class ContentStore {
547
610
  return result;
548
611
  }
549
612
 
550
- getModels(): Record<string, Record<string, Record<string, Model>>> {
613
+ private async processData({
614
+ stackbitConfig,
615
+ configModels,
616
+ presets,
617
+ contentSourceRawDataArr
618
+ }: {
619
+ stackbitConfig: Config | null;
620
+ configModels: Model[];
621
+ presets: Record<string, Preset>;
622
+ contentSourceRawDataArr: ContentSourceRawData[];
623
+ }): Promise<Record<string, ContentSourceData>> {
624
+ const modelsWithSource = contentSourceRawDataArr.reduce((accum: CSITypes.ModelWithSource[], csData) => {
625
+ const mergedModels = mergeConfigModelsWithExternalModels({
626
+ configModels: configModels,
627
+ externalModels: csData.csiModels
628
+ });
629
+ const modelsWithSource = mergedModels.map(
630
+ (model): CSITypes.ModelWithSource => {
631
+ return {
632
+ srcType: csData.srcType,
633
+ srcProjectId: csData.id,
634
+ ...model
635
+ };
636
+ }
637
+ );
638
+ return accum.concat(modelsWithSource);
639
+ }, []);
640
+
641
+ // TODO: Is there a better way than deep cloning objects before passing them to user methods?
642
+ // Not cloning mutable objects will break the internal state if user mutates the objects.
643
+ const mappedModels = stackbitConfig?.mapModels?.({ models: _.cloneDeep(modelsWithSource) }) ?? modelsWithSource;
644
+ const normalizedModels = normalizeModels({ models: mappedModels, logger: this.userLogger });
645
+ const validatedModels = validateModels({ models: normalizedModels, logger: this.userLogger });
646
+ const modelsWithPresetsIds = extendModelsWithPresetsIds({ models: validatedModels, presets });
647
+ const { models } = await this.handleConfigAssets({ models: modelsWithPresetsIds });
648
+
649
+ const csiDocumentsWithSource = contentSourceRawDataArr.reduce((accum: CSITypes.DocumentWithSource[], csData) => {
650
+ const csiDocumentsWithSource = csData.csiDocuments.map(
651
+ (csiDocument): CSITypes.DocumentWithSource => ({
652
+ srcType: csData.srcType,
653
+ srcProjectId: csData.id,
654
+ ...csiDocument
655
+ })
656
+ );
657
+ return accum.concat(csiDocumentsWithSource);
658
+ }, []);
659
+
660
+ // TODO: Is there a better way than deep cloning objects before passing them to user methods?
661
+ // Not cloning mutable objects will break the internal state if user mutates the objects.
662
+ const mappedDocs =
663
+ stackbitConfig?.mapDocuments?.({
664
+ documents: _.cloneDeep(csiDocumentsWithSource),
665
+ models: _.cloneDeep(models)
666
+ }) ?? csiDocumentsWithSource;
667
+
668
+ const modelMapByContentSource = groupModelsByContentSource({ models: models });
669
+ const documentMapByContentSource = groupDocumentsByContentSource({ documents: mappedDocs });
670
+
671
+ const contentSourceDataArr = contentSourceRawDataArr.map(
672
+ (csData): ContentSourceData => {
673
+ const modelMap = _.get(modelMapByContentSource, [csData.srcType, csData.id], {});
674
+ const mappedCSIDocuments = _.get(documentMapByContentSource, [csData.srcType, csData.id]);
675
+ const documents = mapCSIDocumentsToStoreDocuments({
676
+ csiDocuments: mappedCSIDocuments,
677
+ contentSourceInstance: csData.instance,
678
+ defaultLocaleCode: csData.defaultLocaleCode,
679
+ modelMap: modelMap
680
+ });
681
+ return {
682
+ ...csData,
683
+ models: Object.values(modelMap),
684
+ modelMap,
685
+ documents,
686
+ documentMap: _.keyBy(documents, 'srcObjectId')
687
+ };
688
+ }
689
+ );
690
+
691
+ return _.keyBy(contentSourceDataArr, 'id');
692
+ }
693
+
694
+ getModels(): Record<string, Record<string, Record<string, Model | ImageModel>>> {
551
695
  return _.reduce(
552
696
  this.contentSourceDataById,
553
- (result: Record<string, Record<string, Record<string, Model>>>, contentSourceData) => {
697
+ (result: Record<string, Record<string, Record<string, Model | ImageModel>>>, contentSourceData) => {
554
698
  const contentSourceType = contentSourceData.instance.getContentSourceType();
555
699
  const srcProjectId = contentSourceData.instance.getProjectId();
556
700
  _.set(result, [contentSourceType, srcProjectId], contentSourceData.modelMap);
701
+ _.set(result, [contentSourceType, srcProjectId, '__image_model'], IMAGE_MODEL);
557
702
  return result;
558
703
  },
559
704
  {}
@@ -601,8 +746,8 @@ export class ContentStore {
601
746
  return reducePromise(
602
747
  contentSourceDataArr,
603
748
  async (accum: ContentStoreTypes.HasAccessResult, contentSourceData) => {
604
- const srcType = contentSourceData.type;
605
- const srcProjectId = contentSourceData.projectId;
749
+ const srcType = contentSourceData.srcType;
750
+ const srcProjectId = contentSourceData.srcProjectId;
606
751
  const userContext = getUserContextForSrcType(srcType, user);
607
752
  let result = await contentSourceData.instance.hasAccess({ userContext });
608
753
  // backwards compatibility with older CSI version
@@ -1286,15 +1431,15 @@ export class ContentStore {
1286
1431
  const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
1287
1432
  locale = locale ?? contentSourceData.defaultLocaleCode;
1288
1433
  const { documents, assets } = getCSIDocumentsAndAssetsFromContentSourceDataByIds(contentSourceData, contentSourceObjects);
1289
- const userContext = getUserContextForSrcType(contentSourceData.type, user);
1434
+ const userContext = getUserContextForSrcType(contentSourceData.srcType, user);
1290
1435
  const internalValidationErrors = internalValidateContent(documents, assets, contentSourceData);
1291
1436
  const validationResult = await contentSourceData.instance.validateDocuments({ documents, assets, locale, userContext });
1292
1437
  errors = errors.concat(
1293
1438
  internalValidationErrors,
1294
1439
  validationResult.errors.map((validationError) => ({
1295
1440
  message: validationError.message,
1296
- srcType: contentSourceData.type,
1297
- srcProjectId: contentSourceData.projectId,
1441
+ srcType: contentSourceData.srcType,
1442
+ srcProjectId: contentSourceData.srcProjectId,
1298
1443
  srcObjectType: validationError.objectType,
1299
1444
  srcObjectId: validationError.objectId,
1300
1445
  fieldPath: validationError.fieldPath,
@@ -1344,7 +1489,7 @@ export class ContentStore {
1344
1489
  const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
1345
1490
 
1346
1491
  documents.push(...contentSourceData.documents);
1347
- _.set(schema, [contentSourceData.type, contentSourceData.projectId], contentSourceData.modelMap);
1492
+ _.set(schema, [contentSourceData.srcType, contentSourceData.srcProjectId], contentSourceData.modelMap);
1348
1493
  });
1349
1494
 
1350
1495
  return searchDocuments({
@@ -1360,7 +1505,7 @@ export class ContentStore {
1360
1505
  const objectsBySourceId = _.groupBy(objects, (object) => getContentSourceId(object.srcType, object.srcProjectId));
1361
1506
  for (const [contentSourceId, contentSourceObjects] of Object.entries(objectsBySourceId)) {
1362
1507
  const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
1363
- const userContext = getUserContextForSrcType(contentSourceData.type, user);
1508
+ const userContext = getUserContextForSrcType(contentSourceData.srcType, user);
1364
1509
  const { documents, assets } = getCSIDocumentsAndAssetsFromContentSourceDataByIds(contentSourceData, contentSourceObjects);
1365
1510
  await contentSourceData.instance.publishDocuments({ documents, assets, userContext });
1366
1511
  }
@@ -1461,8 +1606,8 @@ function validateDocumentFields(
1461
1606
  if (!objRef) {
1462
1607
  errors.push({
1463
1608
  fieldPath,
1464
- srcType: contentSourceData.type,
1465
- srcProjectId: contentSourceData.projectId,
1609
+ srcType: contentSourceData.srcType,
1610
+ srcProjectId: contentSourceData.srcProjectId,
1466
1611
  srcObjectType: documentField.refType,
1467
1612
  srcObjectId: document.id,
1468
1613
  message: `Can't find referenced ${documentField.refType}: ${documentField.refId}`