@stackbit/cms-contentstack 0.1.1-staging.0

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.
Files changed (72) hide show
  1. package/README.md +1 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/contentstack-api-client.d.ts +63 -0
  4. package/dist/contentstack-api-client.d.ts.map +1 -0
  5. package/dist/contentstack-api-client.js +295 -0
  6. package/dist/contentstack-api-client.js.map +1 -0
  7. package/dist/contentstack-content-poller.d.ts +46 -0
  8. package/dist/contentstack-content-poller.d.ts.map +1 -0
  9. package/dist/contentstack-content-poller.js +111 -0
  10. package/dist/contentstack-content-poller.js.map +1 -0
  11. package/dist/contentstack-content-source.d.ts +138 -0
  12. package/dist/contentstack-content-source.d.ts.map +1 -0
  13. package/dist/contentstack-content-source.js +544 -0
  14. package/dist/contentstack-content-source.js.map +1 -0
  15. package/dist/contentstack-conversion-utils.d.ts +41 -0
  16. package/dist/contentstack-conversion-utils.d.ts.map +1 -0
  17. package/dist/contentstack-conversion-utils.js +504 -0
  18. package/dist/contentstack-conversion-utils.js.map +1 -0
  19. package/dist/contentstack-entries-converter.d.ts +39 -0
  20. package/dist/contentstack-entries-converter.d.ts.map +1 -0
  21. package/dist/contentstack-entries-converter.js +333 -0
  22. package/dist/contentstack-entries-converter.js.map +1 -0
  23. package/dist/contentstack-operation-converter.d.ts +42 -0
  24. package/dist/contentstack-operation-converter.d.ts.map +1 -0
  25. package/dist/contentstack-operation-converter.js +535 -0
  26. package/dist/contentstack-operation-converter.js.map +1 -0
  27. package/dist/contentstack-schema-converter.d.ts +26 -0
  28. package/dist/contentstack-schema-converter.d.ts.map +1 -0
  29. package/dist/contentstack-schema-converter.js +379 -0
  30. package/dist/contentstack-schema-converter.js.map +1 -0
  31. package/dist/contentstack-types.d.ts +429 -0
  32. package/dist/contentstack-types.d.ts.map +1 -0
  33. package/dist/contentstack-types.js +3 -0
  34. package/dist/contentstack-types.js.map +1 -0
  35. package/dist/contentstack-utils.d.ts +31 -0
  36. package/dist/contentstack-utils.d.ts.map +1 -0
  37. package/dist/contentstack-utils.js +144 -0
  38. package/dist/contentstack-utils.js.map +1 -0
  39. package/dist/entries-converter.d.ts +10 -0
  40. package/dist/entries-converter.d.ts.map +1 -0
  41. package/dist/entries-converter.js +245 -0
  42. package/dist/entries-converter.js.map +1 -0
  43. package/dist/file-download.d.ts +2 -0
  44. package/dist/file-download.d.ts.map +1 -0
  45. package/dist/file-download.js +33 -0
  46. package/dist/file-download.js.map +1 -0
  47. package/dist/index.d.ts +4 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +14 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/schema-converter.d.ts +3 -0
  52. package/dist/schema-converter.d.ts.map +1 -0
  53. package/dist/schema-converter.js +169 -0
  54. package/dist/schema-converter.js.map +1 -0
  55. package/dist/transformation-utils.d.ts +41 -0
  56. package/dist/transformation-utils.d.ts.map +1 -0
  57. package/dist/transformation-utils.js +730 -0
  58. package/dist/transformation-utils.js.map +1 -0
  59. package/dist/types.d.ts +120 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +3 -0
  62. package/dist/types.js.map +1 -0
  63. package/package.json +44 -0
  64. package/src/contentstack-api-client.ts +330 -0
  65. package/src/contentstack-content-poller.ts +157 -0
  66. package/src/contentstack-content-source.ts +687 -0
  67. package/src/contentstack-entries-converter.ts +438 -0
  68. package/src/contentstack-operation-converter.ts +703 -0
  69. package/src/contentstack-schema-converter.ts +486 -0
  70. package/src/contentstack-types.ts +527 -0
  71. package/src/contentstack-utils.ts +174 -0
  72. package/src/index.ts +3 -0
@@ -0,0 +1,687 @@
1
+ import { unlink, writeFile } from 'fs/promises';
2
+ import path from 'path';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ import _ from 'lodash';
5
+ import * as StackbitTypes from '@stackbit/types';
6
+ import { getVersion as getStackbitInterfaceVersion } from '@stackbit/types';
7
+ import { ContentStackClient } from './contentstack-api-client';
8
+ import { convertLocales, convertModels, ModelContext, ModelWithContext } from './contentstack-schema-converter';
9
+ import {
10
+ DocumentContext,
11
+ AssetContext,
12
+ convertEntry,
13
+ convertEntries,
14
+ convertAsset,
15
+ convertAssets,
16
+ DocumentWithContext
17
+ } from './contentstack-entries-converter';
18
+ import { createEntryFromOperationFields, updateEntryFromUpdateOperations } from './contentstack-operation-converter';
19
+ import { getContentstackDashboardUrl, resolvePublishEnvironment, downloadFile, createWebhookIfNeeded, userMissingInMap } from './contentstack-utils';
20
+ import { ContentPoller, getLastUpdatedEntityDate, SyncContext, SyncResult } from './contentstack-content-poller';
21
+ import type { User, Asset, Entry, Environment, WebhookPayload } from './contentstack-types';
22
+
23
+ type UserWithContext = StackbitTypes.User<UserContext>;
24
+ type UserContext = unknown;
25
+ type SchemaContext = unknown;
26
+
27
+ export interface ContentStackSourceOptions {
28
+ apiKey: string;
29
+ managementToken: string;
30
+ branch?: string;
31
+ /**
32
+ * User specific authtoken. You should use an authtoken of a "service" user
33
+ * of your organization. If you don't pass the authtoken, some features
34
+ * may not work in Stackbit properly.
35
+ *
36
+ * To get the authtoken, use the following command:
37
+ * ```
38
+ * curl -X POST \
39
+ * -H "Content-Type: application/json" \
40
+ * -d '{"user":{"email":"simon@stackbit.com","password":"i7rdEiZUt!6tMEUkeFTo"}}' \
41
+ * https://api.contentstack.io/v3/user-session \
42
+ * | grep -ioE '"authtoken":"([^\"])+"'
43
+ * ```
44
+ */
45
+ authtoken?: string;
46
+ /**
47
+ * The master locale of the Stack. If `authtoken` is provided, then there is
48
+ * no need to pass `masterLocale` as it will be fetched from the stack API.
49
+ */
50
+ masterLocale?: string;
51
+ /**
52
+ * Environment for publishing entries. If your Contentstack has multiple
53
+ * environments this property need to be specified.
54
+ */
55
+ publishEnvironmentName?: string;
56
+ /**
57
+ * Do not fetch documents and assets when content source starts if there are
58
+ * cached documents and assets. The newer document and asset versions will
59
+ * be fetched by polling content. This flag is applicable only when running
60
+ * `stackbit dev` locally without webhooks, i.e., --csi-webhook-url is not
61
+ * provided.
62
+ */
63
+ skipFetchOnStartIfCache?: boolean;
64
+ }
65
+
66
+ export type CacheWithContext = StackbitTypes.Cache<SchemaContext, DocumentContext, AssetContext, ModelContext>;
67
+
68
+ export class ContentstackContentSource
69
+ implements StackbitTypes.ContentSourceInterface<UserContext, SchemaContext, DocumentContext, AssetContext, ModelContext>
70
+ {
71
+ private readonly apiKey: string;
72
+ private readonly managementToken: string;
73
+ private readonly branch?: string;
74
+ private readonly authtoken?: string;
75
+ private masterLocale?: string;
76
+ private contentStackClient!: ContentStackClient;
77
+ private logger!: StackbitTypes.Logger;
78
+ private userLogger!: StackbitTypes.Logger;
79
+ private cache!: CacheWithContext;
80
+ private useContentPoller: boolean = false;
81
+ private contentPoller: ContentPoller | null = null;
82
+ private syncContext!: SyncContext;
83
+ private skipFetchOnStartIfCache: boolean = false;
84
+ private updateCacheOnFirstPoll: boolean = false;
85
+ private publishEnvironmentName?: string;
86
+ private publishEnvironment!: Environment;
87
+ private usersById: Record<string, User> = {};
88
+
89
+ constructor({ apiKey, managementToken, branch, authtoken, masterLocale, publishEnvironmentName, skipFetchOnStartIfCache }: ContentStackSourceOptions) {
90
+ this.apiKey = apiKey;
91
+ this.managementToken = managementToken;
92
+ this.branch = branch;
93
+ this.authtoken = authtoken;
94
+ this.masterLocale = masterLocale;
95
+ this.publishEnvironmentName = publishEnvironmentName;
96
+ if (skipFetchOnStartIfCache) {
97
+ this.skipFetchOnStartIfCache = true;
98
+ this.updateCacheOnFirstPoll = true;
99
+ }
100
+ }
101
+
102
+ async getVersion(): Promise<StackbitTypes.Version> {
103
+ return getStackbitInterfaceVersion({ packageJsonPath: path.join(__dirname, '../package.json') });
104
+ }
105
+
106
+ getContentSourceType(): string {
107
+ return 'contentstack';
108
+ }
109
+
110
+ getProjectId(): string {
111
+ return this.apiKey;
112
+ }
113
+
114
+ getProjectEnvironment(): string {
115
+ return this.branch ?? 'main';
116
+ }
117
+
118
+ getProjectManageUrl(): string {
119
+ return getContentstackDashboardUrl({ apiKey: this.apiKey, branch: this.branch });
120
+ }
121
+
122
+ async init({
123
+ localDev,
124
+ logger,
125
+ userLogger,
126
+ webhookUrl,
127
+ cache
128
+ }: StackbitTypes.InitOptions<SchemaContext, DocumentContext, AssetContext, ModelContext>): Promise<void> {
129
+ this.logger = logger.createLogger({ label: 'contentstack' });
130
+ this.userLogger = userLogger.createLogger({ label: 'contentstack' });
131
+ this.cache = cache;
132
+
133
+ this.logger.debug('init');
134
+
135
+ this.contentStackClient = new ContentStackClient({
136
+ apiKey: this.apiKey,
137
+ managementToken: this.managementToken,
138
+ branch: this.branch,
139
+ logger: this.logger
140
+ });
141
+
142
+ // If running locally, use ContentPoller instead of webhook unless explicitly debugging webhooks
143
+ if (localDev && !webhookUrl) {
144
+ this.logger.info(`no webhookUrl provided, using content poller`);
145
+ this.useContentPoller = true;
146
+ } else {
147
+ // if not a local dev, or webhook was provided, do not skip fetch on start
148
+ this.skipFetchOnStartIfCache = false;
149
+ this.updateCacheOnFirstPoll = false;
150
+ }
151
+
152
+ await this.reset();
153
+
154
+ await createWebhookIfNeeded({
155
+ webhookUrl,
156
+ publishEnvironmentName: this.publishEnvironment.name,
157
+ branch: this.branch,
158
+ isLocalDev: localDev,
159
+ contentStackClient: this.contentStackClient,
160
+ logger: this.logger
161
+ });
162
+
163
+ this.syncContext = ((await this.cache.get('syncContext')) as SyncContext) ?? {};
164
+ }
165
+
166
+ async reset(): Promise<void> {
167
+ const environments = await this.contentStackClient.getEnvironments();
168
+ this.publishEnvironment = resolvePublishEnvironment({
169
+ environments,
170
+ publishEnvironmentName: this.publishEnvironmentName,
171
+ logger: this.logger
172
+ });
173
+
174
+ if (this.authtoken) {
175
+ const stack = await this.contentStackClient.getStack(this.authtoken);
176
+ this.masterLocale = stack.master_locale;
177
+ await this.fetchUsers();
178
+ }
179
+
180
+ // The contentPoller updates its syncContext property internally when
181
+ // it gets an updated content. But, the actual cached data is not
182
+ // updated. Therefore, when content source module is restarted, we need
183
+ // to reset the contentPoller's internal syncContext, so it will be
184
+ // able to fetch all the updated content from its cached state.
185
+ if (this.contentPoller) {
186
+ this.contentPoller.setSyncContext(this.syncContext);
187
+ }
188
+ }
189
+
190
+ async destroy(): Promise<void> {}
191
+
192
+ startWatchingContentUpdates() {
193
+ this.logger.debug('startWatchingContentUpdates');
194
+ if (!this.useContentPoller) {
195
+ return;
196
+ }
197
+ if (this.contentPoller) {
198
+ this.stopWatchingContentUpdates();
199
+ }
200
+ const { models } = this.cache.getSchema();
201
+ const modelNames = models.filter((model) => ['data', 'page'].includes(model.type)).map((model) => model.name);
202
+ this.contentPoller = new ContentPoller({
203
+ modelNames,
204
+ contentStackClient: this.contentStackClient,
205
+ syncContext: this.syncContext,
206
+ logger: this.logger,
207
+ notificationCallback: async (syncResult) => {
208
+ if (syncResult.contentTypes.length || syncResult.globalFields.length) {
209
+ this.logger.debug('schema was changed, invalidate schema');
210
+ this.cache.invalidateSchema();
211
+ } else {
212
+ const result = await this.convertSyncResult(syncResult);
213
+ await this.cache.updateContent(result);
214
+ }
215
+ }
216
+ });
217
+ this.contentPoller.start();
218
+ }
219
+
220
+ stopWatchingContentUpdates() {
221
+ this.logger.debug('stopWatchingContentUpdates');
222
+ if (this.contentPoller) {
223
+ this.contentPoller.stop();
224
+ this.contentPoller = null;
225
+ }
226
+ }
227
+
228
+ async convertSyncResult(syncResult: SyncResult) {
229
+ if (this.updateCacheOnFirstPoll) {
230
+ if (syncResult.entries.length || syncResult.assets.length) {
231
+ if (syncResult.entries.length) {
232
+ const cachedEntries = ((await this.cache.get('entries')) as Entry[]) ?? [];
233
+ this.syncContext.lastUpdatedEntryDate = getLastUpdatedEntityDate(syncResult.entries);
234
+ const entries = _.unionBy(syncResult.entries, cachedEntries, 'uid');
235
+ await this.cache.set('entries', entries);
236
+ }
237
+ if (syncResult.assets.length) {
238
+ const cachedAssets = ((await this.cache.get('assets')) as Asset[]) ?? [];
239
+ this.syncContext.lastUpdatedAssetDate = getLastUpdatedEntityDate(syncResult.assets);
240
+ const assets = _.unionBy(syncResult.assets, cachedAssets, 'uid');
241
+ await this.cache.set('assets', assets);
242
+ }
243
+ await this.cache.set('syncContext', this.syncContext);
244
+ this.updateCacheOnFirstPoll = false;
245
+ }
246
+ }
247
+
248
+ if (this.authtoken) {
249
+ const hasMissingUser = [...syncResult.entries, ...syncResult.assets].some((entity) => userMissingInMap(this.usersById, entity));
250
+ if (hasMissingUser) {
251
+ await this.fetchUsers();
252
+ }
253
+ }
254
+
255
+ const documents = convertEntries({
256
+ entries: syncResult.entries,
257
+ apiKey: this.apiKey,
258
+ getModelByName: this.cache.getModelByName,
259
+ usersById: this.usersById,
260
+ publishEnvironment: this.publishEnvironment,
261
+ logger: this.logger
262
+ });
263
+ const assets = convertAssets({
264
+ assets: syncResult.assets,
265
+ apiKey: this.apiKey,
266
+ usersById: this.usersById,
267
+ publishEnvironment: this.publishEnvironment
268
+ });
269
+
270
+ return {
271
+ documents,
272
+ assets,
273
+ deletedDocumentIds: syncResult.deletedEntries.map((entry) => entry.uid),
274
+ deletedAssetIds: syncResult.deletedAssets.map((asset) => asset.uid)
275
+ };
276
+ }
277
+
278
+ async fetchUsers() {
279
+ if (this.authtoken) {
280
+ const users = await this.contentStackClient.getUsers(this.authtoken);
281
+ this.usersById = _.keyBy(users, 'uid');
282
+ }
283
+ }
284
+
285
+ async getSchema(): Promise<StackbitTypes.Schema<SchemaContext, ModelContext>> {
286
+ this.logger.debug('getSchema');
287
+ const [globalFields, contentTypes, contentStackLocales] = await Promise.all([
288
+ this.contentStackClient.getGlobalFields(),
289
+ this.contentStackClient.getContentTypes(),
290
+ this.contentStackClient.getLocales()
291
+ ]);
292
+
293
+ const models = convertModels({ contentStackModels: [...globalFields, ...contentTypes] });
294
+ const locales = convertLocales({ contentStackLocales, masterLocale: this.masterLocale, logger: this.logger });
295
+
296
+ // Check if one of the content types was changed from the last time the
297
+ // content types were fetched, in which case remove all cached content.
298
+ const lastUpdatedContentTypeDate = getLastUpdatedEntityDate(contentTypes);
299
+ const lastUpdatedGlobalFieldDate = getLastUpdatedEntityDate(globalFields);
300
+ if (
301
+ this.syncContext.lastUpdatedContentTypeDate !== lastUpdatedContentTypeDate ||
302
+ this.syncContext.lastUpdatedGlobalFieldDate !== lastUpdatedGlobalFieldDate
303
+ ) {
304
+ this.logger.debug(
305
+ `last updated content type date '${lastUpdatedContentTypeDate}' is different from cached ` +
306
+ `syncContext.lastUpdatedContentTypeDate '${this.syncContext.lastUpdatedContentTypeDate}', ` +
307
+ `or last updated global field date '${lastUpdatedGlobalFieldDate}' is different from cached ` +
308
+ `syncContext.lastUpdatedGlobalFieldDate '${this.syncContext.lastUpdatedGlobalFieldDate}', ` +
309
+ `clearing cache`
310
+ );
311
+ await this.cache.remove('entries');
312
+ await this.cache.remove('assets');
313
+ this.skipFetchOnStartIfCache = false;
314
+ this.updateCacheOnFirstPoll = false;
315
+ this.syncContext = { lastUpdatedContentTypeDate, lastUpdatedGlobalFieldDate };
316
+ await this.cache.set('syncContext', this.syncContext);
317
+ }
318
+
319
+ this.logger.debug(`got ${contentTypes.length} content types, ${globalFields.length} global fields, ${locales.length} locales`);
320
+ return {
321
+ models,
322
+ locales,
323
+ context: null
324
+ };
325
+ }
326
+
327
+ async getDocuments(): Promise<StackbitTypes.Document<DocumentContext>[]> {
328
+ this.logger.debug('getDocuments');
329
+ const { models } = this.cache.getSchema();
330
+ const modelNames = models.filter((model) => ['data', 'page'].includes(model.type)).map((model) => model.name);
331
+
332
+ let entries: Entry[];
333
+ const cachedEntries = ((await this.cache.get('entries')) as Entry[]) ?? [];
334
+ try {
335
+ if (this.syncContext.lastUpdatedEntryDate && cachedEntries) {
336
+ entries = cachedEntries;
337
+ if (!this.skipFetchOnStartIfCache) {
338
+ this.logger.debug(
339
+ `got ${cachedEntries.length} entries from cache, fetching entries updated after ${this.syncContext.lastUpdatedEntryDate}`
340
+ );
341
+ const updatedEntries = await this.contentStackClient.getAllEntriesUpdatedAfter(modelNames, this.syncContext.lastUpdatedEntryDate);
342
+ this.logger.debug(`got ${updatedEntries.length} updated/created entries after ${this.syncContext.lastUpdatedEntryDate}`);
343
+ if (updatedEntries.length) {
344
+ this.syncContext.lastUpdatedEntryDate = getLastUpdatedEntityDate(updatedEntries);
345
+ entries = _.unionBy(updatedEntries, cachedEntries, 'uid');
346
+ await this.cache.set('entries', entries);
347
+ }
348
+ }
349
+ } else {
350
+ entries = await this.contentStackClient.getAllEntries(modelNames);
351
+ await this.cache.set('entries', entries);
352
+ this.syncContext.lastUpdatedEntryDate = getLastUpdatedEntityDate(entries);
353
+ }
354
+ } catch (error: any) {
355
+ // Stackbit won't be able to work properly even if one of the entries was not fetched.
356
+ // All api methods use Contentstack's API client that handles errors and retries automatically.
357
+ this.logger.error(`Failed fetching documents from Contentstack, error: ${error.message}`);
358
+ throw new Error(`Failed fetching documents from Contentstack, error: ${error.message}`);
359
+ }
360
+ await this.cache.set('syncContext', this.syncContext);
361
+
362
+ // TODO: localization: fetch entries per locale, and merge them into localized fields of a single document
363
+ const documents = convertEntries({
364
+ entries,
365
+ apiKey: this.apiKey,
366
+ getModelByName: this.cache.getModelByName,
367
+ usersById: this.usersById,
368
+ publishEnvironment: this.publishEnvironment,
369
+ logger: this.logger
370
+ });
371
+ this.logger.debug(`got ${documents.length} documents`);
372
+ return documents;
373
+ }
374
+
375
+ async getAssets(): Promise<StackbitTypes.Asset<AssetContext>[]> {
376
+ this.logger.debug('getAssets');
377
+
378
+ let assets: Asset[];
379
+ const cachedAssets = ((await this.cache.get('assets')) as Asset[]) ?? [];
380
+ try {
381
+ if (this.syncContext.lastUpdatedAssetDate && cachedAssets) {
382
+ assets = cachedAssets;
383
+ if (!this.skipFetchOnStartIfCache) {
384
+ this.logger.debug(`got ${cachedAssets.length} assets from cache, fetching assets updated after ${this.syncContext.lastUpdatedAssetDate}`);
385
+ const updatedAssets = await this.contentStackClient.getAssetsUpdatedAfter(this.syncContext.lastUpdatedAssetDate);
386
+ this.logger.debug(`got ${updatedAssets.length} updated/created assets after ${this.syncContext.lastUpdatedAssetDate}`);
387
+ if (updatedAssets.length) {
388
+ this.syncContext.lastUpdatedAssetDate = getLastUpdatedEntityDate(updatedAssets);
389
+ assets = _.unionBy(updatedAssets, cachedAssets, 'uid');
390
+ await this.cache.set('assets', assets);
391
+ }
392
+ }
393
+ } else {
394
+ assets = await this.contentStackClient.getAssets();
395
+ await this.cache.set('assets', assets);
396
+ this.syncContext.lastUpdatedAssetDate = getLastUpdatedEntityDate(assets);
397
+ }
398
+ } catch (error: any) {
399
+ // Stackbit won't be able to work properly even if one of the entries or the assets was not fetched.
400
+ // All api methods use Contentstack's API client that handles errors and retries automatically.
401
+ this.logger.error(`Failed fetching assets from Contentstack, error: ${error.message}`);
402
+ throw new Error(`Failed fetching assets from Contentstack, error: ${error.message}`);
403
+ }
404
+ await this.cache.set('syncContext', this.syncContext);
405
+
406
+ const stackbitAssets = convertAssets({
407
+ assets: assets,
408
+ apiKey: this.apiKey,
409
+ usersById: this.usersById,
410
+ publishEnvironment: this.publishEnvironment
411
+ });
412
+ this.logger.debug(`got ${stackbitAssets.length} documents`);
413
+ return stackbitAssets;
414
+ }
415
+
416
+ async hasAccess(options: { userContext?: UserWithContext }): Promise<{
417
+ hasConnection: boolean;
418
+ hasPermissions: boolean;
419
+ }> {
420
+ // TODO: add ContentStack OAuth and validate user's access to the current stack/branch
421
+ return {
422
+ hasConnection: true,
423
+ hasPermissions: true
424
+ };
425
+ }
426
+
427
+ async createDocument(options: {
428
+ updateOperationFields: Record<string, StackbitTypes.UpdateOperationField>;
429
+ model: ModelWithContext;
430
+ locale?: string;
431
+ defaultLocaleDocumentId?: string;
432
+ userContext?: UserWithContext;
433
+ }): Promise<{ documentId: string }> {
434
+ this.logger.debug('createDocument');
435
+ const entry = createEntryFromOperationFields({
436
+ updateOperationFields: options.updateOperationFields,
437
+ model: options.model,
438
+ getDocumentById: this.cache.getDocumentById,
439
+ getModelByName: this.cache.getModelByName,
440
+ logger: this.logger
441
+ });
442
+ const newEntry = await this.contentStackClient.createEntry(options.model.name, entry);
443
+ return { documentId: newEntry.uid };
444
+ }
445
+
446
+ async updateDocument(options: {
447
+ document: DocumentWithContext;
448
+ operations: StackbitTypes.UpdateOperation[];
449
+ userContext?: UserWithContext;
450
+ }): Promise<void> {
451
+ this.logger.debug('updateDocument');
452
+ const { document, operations } = options;
453
+ const entry = await this.contentStackClient.getEntry(document.modelName, document.id);
454
+ const updatedEntry = updateEntryFromUpdateOperations({
455
+ entry,
456
+ updateOperations: operations,
457
+ getDocumentById: this.cache.getDocumentById,
458
+ getModelByName: this.cache.getModelByName,
459
+ logger: this.logger
460
+ });
461
+ await this.contentStackClient.updateEntry(updatedEntry);
462
+ }
463
+
464
+ async deleteDocument(options: { document: StackbitTypes.Document<unknown>; userContext?: UserWithContext }): Promise<void> {
465
+ this.logger.debug('deleteDocument');
466
+ await this.contentStackClient.deleteEntry(options.document.modelName, options.document.id);
467
+ }
468
+
469
+ async uploadAsset(options: {
470
+ url?: string | undefined;
471
+ base64?: string | undefined;
472
+ fileName: string;
473
+ mimeType: string;
474
+ locale?: string | undefined;
475
+ userContext?: UserWithContext;
476
+ }): Promise<StackbitTypes.Asset<AssetContext>> {
477
+ this.logger.debug('uploadAsset');
478
+ const tempName = `${uuidv4()}-${options.fileName}`;
479
+ let asset: Asset;
480
+ try {
481
+ if (options.base64) {
482
+ // TODO: write to .stackbit/cache folder
483
+ await writeFile(tempName, Buffer.from(options.base64, 'base64'));
484
+ } else {
485
+ await downloadFile(options.url!, tempName);
486
+ }
487
+ asset = await this.contentStackClient.uploadImage(tempName, options.fileName);
488
+ const locales = this.cache.getSchema().locales ?? [];
489
+ const defaultLocale = locales.find((locale) => locale.default)?.code;
490
+ await asset.publish({
491
+ publishDetails: {
492
+ environments: [this.publishEnvironment.name],
493
+ locales: [defaultLocale!]
494
+ },
495
+ locale: defaultLocale
496
+ });
497
+ } finally {
498
+ await unlink(tempName);
499
+ }
500
+ this.logger.debug(`uploaded asset, new asset id: ${asset.uid}`);
501
+
502
+ return convertAsset({
503
+ asset,
504
+ apiKey: this.apiKey,
505
+ usersById: this.usersById,
506
+ publishEnvironment: this.publishEnvironment
507
+ });
508
+ }
509
+
510
+ async validateDocuments(options: {
511
+ documents: StackbitTypes.Document<unknown>[];
512
+ assets: StackbitTypes.Asset<unknown>[];
513
+ locale?: string | undefined;
514
+ userContext?: UserWithContext;
515
+ }): Promise<{ errors: StackbitTypes.ValidationError[] }> {
516
+ const validations: StackbitTypes.ValidationError[] = [];
517
+ return { errors: validations };
518
+ }
519
+
520
+ async publishDocuments(options: {
521
+ documents: StackbitTypes.Document<DocumentContext>[];
522
+ assets: StackbitTypes.Asset<AssetContext>[];
523
+ userContext?: UserWithContext;
524
+ }): Promise<void> {
525
+ this.logger.debug('publishDocuments');
526
+ await this.contentStackClient.publishDocuments({
527
+ documents: options.documents,
528
+ publishEnvironmentName: this.publishEnvironment.name
529
+ });
530
+ }
531
+
532
+ async onWebhook(data: { data: WebhookPayload; headers: Record<string, string> }): Promise<void> {
533
+ const webhookData = data.data;
534
+ const updates: StackbitTypes.ContentChanges<DocumentContext, AssetContext> = {
535
+ assets: [],
536
+ documents: [],
537
+ deletedDocumentIds: [],
538
+ deletedAssetIds: []
539
+ };
540
+
541
+ // check that the webhook is related to the current branch, if not ignore
542
+ if (this.branch && this.branch !== webhookData.data.branch.uid) {
543
+ return;
544
+ }
545
+
546
+ this.logger.debug(`got webhook for ${webhookData.event} ${webhookData.module}`);
547
+
548
+ if (webhookData.module === 'entry') {
549
+ switch (webhookData.event) {
550
+ case 'create':
551
+ case 'update': {
552
+ if (userMissingInMap(this.usersById, webhookData.data.entry as Entry)) {
553
+ await this.fetchUsers();
554
+ }
555
+ const document = convertEntry({
556
+ entry: {
557
+ ...webhookData.data.entry,
558
+ content_type_uid: webhookData.data.content_type.uid,
559
+ publish_details: [],
560
+ _branch: webhookData.data.branch.uid
561
+ } as unknown as Entry,
562
+ status: webhookData.event === 'create' ? 'added' : 'modified',
563
+ apiKey: this.apiKey,
564
+ getModelByName: this.cache.getModelByName,
565
+ usersById: this.usersById,
566
+ publishEnvironment: this.publishEnvironment,
567
+ logger: this.logger
568
+ });
569
+ updates.documents!.push(document);
570
+ break;
571
+ }
572
+ case 'publish': {
573
+ if (webhookData.data.environment.uid !== this.publishEnvironment.uid) {
574
+ this.logger.debug(
575
+ `entry was published in an environment '${webhookData.data.environment.name}' ` +
576
+ `different from the publish environment '${this.publishEnvironment.name}', ignoring the webhook'`
577
+ );
578
+ break;
579
+ }
580
+ if (userMissingInMap(this.usersById, webhookData.data.entry as Entry)) {
581
+ await this.fetchUsers();
582
+ }
583
+ const document = convertEntry({
584
+ entry: {
585
+ ...webhookData.data.entry,
586
+ content_type_uid: webhookData.data.content_type.uid,
587
+ publish_details: [],
588
+ _branch: webhookData.data.branch.uid
589
+ } as unknown as Entry,
590
+ status: 'published',
591
+ apiKey: this.apiKey,
592
+ getModelByName: this.cache.getModelByName,
593
+ usersById: this.usersById,
594
+ publishEnvironment: this.publishEnvironment,
595
+ logger: this.logger
596
+ });
597
+ updates.documents!.push(document);
598
+ break;
599
+ }
600
+ case 'unpublish':
601
+ if (webhookData.data.environment.uid !== this.publishEnvironment.uid) {
602
+ this.logger.debug(
603
+ `entry was published in an environment '${webhookData.data.environment.name}' ` +
604
+ `different from the publish environment '${this.publishEnvironment.name}', ignoring the webhook'`
605
+ );
606
+ break;
607
+ }
608
+ updates.deletedDocumentIds!.push(webhookData.data.entry.uid);
609
+ break;
610
+ case 'delete':
611
+ updates.deletedDocumentIds!.push(webhookData.data.entry.uid);
612
+ break;
613
+ }
614
+ } else if (webhookData.module === 'asset') {
615
+ switch (webhookData.event) {
616
+ case 'create':
617
+ case 'update': {
618
+ if (userMissingInMap(this.usersById, webhookData.data.asset as Asset)) {
619
+ await this.fetchUsers();
620
+ }
621
+ const asset = convertAsset({
622
+ asset: {
623
+ ...webhookData.data.asset,
624
+ publish_details: [],
625
+ _branch: webhookData.data.branch.uid
626
+ } as unknown as Asset,
627
+ apiKey: this.apiKey,
628
+ usersById: this.usersById,
629
+ publishEnvironment: this.publishEnvironment
630
+ });
631
+ updates.assets!.push(asset);
632
+ break;
633
+ }
634
+ case 'publish': {
635
+ if (webhookData.data.environment.uid !== this.publishEnvironment.uid) {
636
+ this.logger.debug(
637
+ `asset was published in an environment '${webhookData.data.environment.name}' ` +
638
+ `different from the publish environment '${this.publishEnvironment.name}', ignoring the webhook'`
639
+ );
640
+ return;
641
+ }
642
+ if (userMissingInMap(this.usersById, webhookData.data.asset as Asset)) {
643
+ await this.fetchUsers();
644
+ }
645
+ const asset = convertAsset({
646
+ asset: {
647
+ ...webhookData.data.asset,
648
+ publish_details: [],
649
+ _branch: webhookData.data.branch.uid
650
+ } as unknown as Asset,
651
+ apiKey: this.apiKey,
652
+ usersById: this.usersById,
653
+ publishEnvironment: this.publishEnvironment
654
+ });
655
+ updates.assets!.push(asset);
656
+ break;
657
+ }
658
+ case 'unpublish':
659
+ if (webhookData.data.environment.uid !== this.publishEnvironment.uid) {
660
+ this.logger.debug(
661
+ `asset was published in an environment '${webhookData.data.environment.name}' ` +
662
+ `different from the publish environment '${this.publishEnvironment.name}', ignoring the webhook'`
663
+ );
664
+ return;
665
+ }
666
+ updates.deletedAssetIds?.push(webhookData.data.asset.uid);
667
+ break;
668
+ case 'delete':
669
+ updates.deletedAssetIds?.push(webhookData.data.asset.uid);
670
+ break;
671
+ }
672
+ } else if (webhookData.module === 'content_type' || webhookData.module === 'global_field') {
673
+ this.logger.debug(`webhook for '${webhookData.module}' invalidate schema`);
674
+ this.cache.invalidateSchema();
675
+ return;
676
+ }
677
+
678
+ this.logger.debug(
679
+ `update content from webhook, ` +
680
+ `documents: ${updates.documents?.length}, ` +
681
+ `assets: ${updates.assets?.length}, ` +
682
+ `deleted documents: ${updates.deletedDocumentIds?.length}, ` +
683
+ `deleted assets: ${updates.deletedAssetIds?.length}`
684
+ );
685
+ this.cache.updateContent(updates);
686
+ }
687
+ }