@stackbit/cms-core 0.1.6 → 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,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,7 +196,7 @@ 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 });
199
+ await this.loadAllContentSourcesAndProcessData({ init: false });
183
200
  }
184
201
 
185
202
  /**
@@ -193,18 +210,55 @@ export class ContentStore {
193
210
  */
194
211
  async onContentSourceSchemaChange({ contentSourceId }: { contentSourceId: string }) {
195
212
  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();
213
+ await this.reloadContentSourcesByIdAndProcessData({ contentSourceIds: [contentSourceId] });
202
214
  }
203
215
 
204
216
  async onFilesChange(updatedFiles: string[]): Promise<{ schemaChanged?: boolean; contentChanges: ContentStoreTypes.ContentChangeResult }> {
205
217
  this.logger.debug('onFilesChange');
206
218
 
207
- 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
+
208
262
  const contentChanges: ContentStoreTypes.ContentChangeResult = {
209
263
  updatedDocuments: [],
210
264
  updatedAssets: [],
@@ -212,49 +266,115 @@ export class ContentStore {
212
266
  deletedAssets: []
213
267
  };
214
268
 
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) {
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 }) => {
226
276
  const contentChangeResult = this.onContentChange(contentSourceId, contentChangeEvent);
227
277
  contentChanges.updatedDocuments = contentChanges.updatedDocuments.concat(contentChangeResult.updatedDocuments);
228
278
  contentChanges.updatedAssets = contentChanges.updatedAssets.concat(contentChangeResult.updatedAssets);
229
279
  contentChanges.deletedDocuments = contentChanges.deletedDocuments.concat(contentChangeResult.deletedDocuments);
230
280
  contentChanges.deletedAssets = contentChanges.deletedAssets.concat(contentChangeResult.deletedAssets);
231
- }
281
+ return contentChanges;
282
+ }, contentChanges);
232
283
  }
233
284
 
234
285
  return {
235
- schemaChanged: someContentSourceSchemaUpdated,
286
+ schemaChanged: schemaChanged,
236
287
  contentChanges: contentChanges
237
288
  };
238
289
  }
239
290
 
240
- private async loadContentSources({ init }: { init: boolean }) {
241
- 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 });
242
328
 
243
329
  this.contentUpdatesWatchTimer.stopTimer();
244
330
 
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[];
331
+ const contentSources = this.stackbitConfig?.contentSources ?? [];
247
332
 
248
333
  const promises = contentSources.map((contentSourceInstance) => {
249
334
  return this.loadContentSourceData({ contentSourceInstance, init });
250
335
  });
251
336
 
252
- const contentSourceDataArr = await Promise.all(promises);
253
- const contentSourceDataById: Record<string, ContentSourceData> = _.keyBy(contentSourceDataArr, 'id');
337
+ const contentSourceRawDataArr = await Promise.all(promises);
254
338
 
255
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
+ });
256
346
  this.contentSources = contentSources;
257
- 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
+ });
258
378
 
259
379
  this.contentUpdatesWatchTimer.startTimer();
260
380
  }
@@ -265,7 +385,7 @@ export class ContentStore {
265
385
  }: {
266
386
  contentSourceInstance: CSITypes.ContentSourceInterface;
267
387
  init: boolean;
268
- }): Promise<ContentSourceData> {
388
+ }): Promise<ContentSourceRawData> {
269
389
  const contentSourceId = getContentSourceIdForContentSource(contentSourceInstance);
270
390
  this.logger.debug('loadContentSourceData', { contentSourceId, init });
271
391
 
@@ -283,98 +403,31 @@ export class ContentStore {
283
403
  await contentSourceInstance.reset();
284
404
  }
285
405
 
286
- // TODO: introduce optimization: don't fetch content source models,
287
- // documents, assets if only stackbitConfig was changed
288
-
289
406
  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
- }
407
+ const csiModelMap = _.keyBy(csiModels, 'name');
331
408
 
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
- }
409
+ const locales = await contentSourceInstance.getLocales();
410
+ const defaultLocaleCode = locales?.find((locale) => locale.default)?.code;
348
411
 
349
- const models: Model[] = imageModel ? [...modelsNoImage, imageModel] : modelsNoImage;
350
- const modelMap = _.keyBy(models, 'name');
351
- const csiModelMap = _.keyBy(csiModels, 'name');
352
412
  const csiDocuments = await contentSourceInstance.getDocuments({ modelMap: csiModelMap });
353
413
  const csiAssets = await contentSourceInstance.getAssets();
354
414
  const csiDocumentMap = _.keyBy(csiDocuments, 'id');
355
415
  const csiAssetMap = _.keyBy(csiAssets, 'id');
356
416
 
357
- const contentStoreDocuments = mapCSIDocumentsToStoreDocuments({
358
- csiDocuments,
359
- contentSourceInstance,
360
- modelMap,
361
- defaultLocaleCode
362
- });
363
417
  const contentStoreAssets = mapCSIAssetsToStoreAssets({
364
418
  csiAssets,
365
419
  contentSourceInstance,
366
420
  defaultLocaleCode
367
421
  });
368
- const documentMap = _.keyBy(contentStoreDocuments, 'srcObjectId');
369
422
  const assetMap = _.keyBy(contentStoreAssets, 'srcObjectId');
370
423
 
371
424
  this.logger.debug('loaded content source data', {
372
425
  contentSourceId,
373
- locales,
374
426
  defaultLocaleCode,
375
- modelCount: models.length,
376
- documentCount: contentStoreDocuments.length,
377
- assetCount: contentStoreAssets.length
427
+ localesCount: locales.length,
428
+ modelCount: csiModels.length,
429
+ documentCount: csiDocuments.length,
430
+ assetCount: csiAssets.length
378
431
  });
379
432
 
380
433
  contentSourceInstance.startWatchingContentUpdates({
@@ -394,28 +447,21 @@ export class ContentStore {
394
447
  },
395
448
  onSchemaChange: async () => {
396
449
  this.logger.debug('content source called onSchemaChange', { contentSourceId });
397
- this.contentSourceDataById[contentSourceId] = await this.loadContentSourceData({
398
- contentSourceInstance: contentSourceInstance,
399
- init: false
400
- });
401
- this.onSchemaChangeCallback();
450
+ await this.reloadContentSourcesByIdAndProcessData({ contentSourceIds: [contentSourceId] });
402
451
  }
403
452
  });
404
453
 
405
454
  return {
406
455
  id: contentSourceId,
407
- type: contentSourceInstance.getContentSourceType(),
408
- projectId: contentSourceInstance.getProjectId(),
456
+ srcType: contentSourceInstance.getContentSourceType(),
457
+ srcProjectId: contentSourceInstance.getProjectId(),
409
458
  instance: contentSourceInstance,
410
459
  locales: locales,
411
460
  defaultLocaleCode: defaultLocaleCode,
412
- models: models,
413
- modelMap: modelMap,
461
+ csiModels: csiModels,
414
462
  csiModelMap: csiModelMap,
415
463
  csiDocuments: csiDocuments,
416
464
  csiDocumentMap: csiDocumentMap,
417
- documents: contentStoreDocuments,
418
- documentMap: documentMap,
419
465
  csiAssets: csiAssets,
420
466
  csiAssetMap: csiAssetMap,
421
467
  assets: contentStoreAssets,
@@ -458,8 +504,8 @@ export class ContentStore {
458
504
  }
459
505
 
460
506
  result.deletedDocuments.push({
461
- srcType: contentSourceData.type,
462
- srcProjectId: contentSourceData.projectId,
507
+ srcType: contentSourceData.srcType,
508
+ srcProjectId: contentSourceData.srcProjectId,
463
509
  srcObjectId: docId
464
510
  });
465
511
  });
@@ -479,15 +525,43 @@ export class ContentStore {
479
525
  }
480
526
 
481
527
  result.deletedAssets.push({
482
- srcType: contentSourceData.type,
483
- srcProjectId: contentSourceData.projectId,
528
+ srcType: contentSourceData.srcType,
529
+ srcProjectId: contentSourceData.srcProjectId,
484
530
  srcObjectId: assetId
485
531
  });
486
532
  });
487
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
+
488
562
  // map csi documents and assets to content store documents and assets
489
563
  const documents = mapCSIDocumentsToStoreDocuments({
490
- csiDocuments: contentChangeEvent.documents,
564
+ csiDocuments: mappedDocs,
491
565
  contentSourceInstance: contentSourceData.instance,
492
566
  modelMap: contentSourceData.modelMap,
493
567
  defaultLocaleCode: contentSourceData.defaultLocaleCode
@@ -500,7 +574,7 @@ export class ContentStore {
500
574
 
501
575
  // update contentSourceData with new or updated documents and assets
502
576
  Object.assign(contentSourceData.csiDocumentMap, _.keyBy(contentChangeEvent.documents, 'id'));
503
- Object.assign(contentSourceData.csiAssets, _.keyBy(contentChangeEvent.assets, 'id'));
577
+ Object.assign(contentSourceData.csiAssetMap, _.keyBy(contentChangeEvent.assets, 'id'));
504
578
  Object.assign(contentSourceData.documentMap, _.keyBy(documents, 'srcObjectId'));
505
579
  Object.assign(contentSourceData.assetMap, _.keyBy(assets, 'srcObjectId'));
506
580
 
@@ -518,8 +592,8 @@ export class ContentStore {
518
592
  contentSourceData.csiDocuments.splice(dataIndex, 1, csiDocument);
519
593
  }
520
594
  result.updatedDocuments.push({
521
- srcType: contentSourceData.type,
522
- srcProjectId: contentSourceData.projectId,
595
+ srcType: contentSourceData.srcType,
596
+ srcProjectId: contentSourceData.srcProjectId,
523
597
  srcObjectId: document.srcObjectId
524
598
  });
525
599
  }
@@ -538,8 +612,8 @@ export class ContentStore {
538
612
  contentSourceData.csiAssets.splice(index, 1, csiAsset);
539
613
  }
540
614
  result.updatedAssets.push({
541
- srcType: contentSourceData.type,
542
- srcProjectId: contentSourceData.projectId,
615
+ srcType: contentSourceData.srcType,
616
+ srcProjectId: contentSourceData.srcProjectId,
543
617
  srcObjectId: asset.srcObjectId
544
618
  });
545
619
  }
@@ -547,13 +621,95 @@ export class ContentStore {
547
621
  return result;
548
622
  }
549
623
 
550
- 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>>> {
551
706
  return _.reduce(
552
707
  this.contentSourceDataById,
553
- (result: Record<string, Record<string, Record<string, Model>>>, contentSourceData) => {
708
+ (result: Record<string, Record<string, Record<string, Model | ImageModel>>>, contentSourceData) => {
554
709
  const contentSourceType = contentSourceData.instance.getContentSourceType();
555
710
  const srcProjectId = contentSourceData.instance.getProjectId();
556
711
  _.set(result, [contentSourceType, srcProjectId], contentSourceData.modelMap);
712
+ _.set(result, [contentSourceType, srcProjectId, '__image_model'], IMAGE_MODEL);
557
713
  return result;
558
714
  },
559
715
  {}
@@ -601,8 +757,8 @@ export class ContentStore {
601
757
  return reducePromise(
602
758
  contentSourceDataArr,
603
759
  async (accum: ContentStoreTypes.HasAccessResult, contentSourceData) => {
604
- const srcType = contentSourceData.type;
605
- const srcProjectId = contentSourceData.projectId;
760
+ const srcType = contentSourceData.srcType;
761
+ const srcProjectId = contentSourceData.srcProjectId;
606
762
  const userContext = getUserContextForSrcType(srcType, user);
607
763
  let result = await contentSourceData.instance.hasAccess({ userContext });
608
764
  // backwards compatibility with older CSI version
@@ -1286,15 +1442,15 @@ export class ContentStore {
1286
1442
  const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
1287
1443
  locale = locale ?? contentSourceData.defaultLocaleCode;
1288
1444
  const { documents, assets } = getCSIDocumentsAndAssetsFromContentSourceDataByIds(contentSourceData, contentSourceObjects);
1289
- const userContext = getUserContextForSrcType(contentSourceData.type, user);
1445
+ const userContext = getUserContextForSrcType(contentSourceData.srcType, user);
1290
1446
  const internalValidationErrors = internalValidateContent(documents, assets, contentSourceData);
1291
1447
  const validationResult = await contentSourceData.instance.validateDocuments({ documents, assets, locale, userContext });
1292
1448
  errors = errors.concat(
1293
1449
  internalValidationErrors,
1294
1450
  validationResult.errors.map((validationError) => ({
1295
1451
  message: validationError.message,
1296
- srcType: contentSourceData.type,
1297
- srcProjectId: contentSourceData.projectId,
1452
+ srcType: contentSourceData.srcType,
1453
+ srcProjectId: contentSourceData.srcProjectId,
1298
1454
  srcObjectType: validationError.objectType,
1299
1455
  srcObjectId: validationError.objectId,
1300
1456
  fieldPath: validationError.fieldPath,
@@ -1344,7 +1500,7 @@ export class ContentStore {
1344
1500
  const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
1345
1501
 
1346
1502
  documents.push(...contentSourceData.documents);
1347
- _.set(schema, [contentSourceData.type, contentSourceData.projectId], contentSourceData.modelMap);
1503
+ _.set(schema, [contentSourceData.srcType, contentSourceData.srcProjectId], contentSourceData.modelMap);
1348
1504
  });
1349
1505
 
1350
1506
  return searchDocuments({
@@ -1360,7 +1516,7 @@ export class ContentStore {
1360
1516
  const objectsBySourceId = _.groupBy(objects, (object) => getContentSourceId(object.srcType, object.srcProjectId));
1361
1517
  for (const [contentSourceId, contentSourceObjects] of Object.entries(objectsBySourceId)) {
1362
1518
  const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
1363
- const userContext = getUserContextForSrcType(contentSourceData.type, user);
1519
+ const userContext = getUserContextForSrcType(contentSourceData.srcType, user);
1364
1520
  const { documents, assets } = getCSIDocumentsAndAssetsFromContentSourceDataByIds(contentSourceData, contentSourceObjects);
1365
1521
  await contentSourceData.instance.publishDocuments({ documents, assets, userContext });
1366
1522
  }
@@ -1461,8 +1617,8 @@ function validateDocumentFields(
1461
1617
  if (!objRef) {
1462
1618
  errors.push({
1463
1619
  fieldPath,
1464
- srcType: contentSourceData.type,
1465
- srcProjectId: contentSourceData.projectId,
1620
+ srcType: contentSourceData.srcType,
1621
+ srcProjectId: contentSourceData.srcProjectId,
1466
1622
  srcObjectType: documentField.refType,
1467
1623
  srcObjectId: document.id,
1468
1624
  message: `Can't find referenced ${documentField.refType}: ${documentField.refId}`