@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.
- package/README.md +1 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/contentstack-api-client.d.ts +63 -0
- package/dist/contentstack-api-client.d.ts.map +1 -0
- package/dist/contentstack-api-client.js +295 -0
- package/dist/contentstack-api-client.js.map +1 -0
- package/dist/contentstack-content-poller.d.ts +46 -0
- package/dist/contentstack-content-poller.d.ts.map +1 -0
- package/dist/contentstack-content-poller.js +111 -0
- package/dist/contentstack-content-poller.js.map +1 -0
- package/dist/contentstack-content-source.d.ts +138 -0
- package/dist/contentstack-content-source.d.ts.map +1 -0
- package/dist/contentstack-content-source.js +544 -0
- package/dist/contentstack-content-source.js.map +1 -0
- package/dist/contentstack-conversion-utils.d.ts +41 -0
- package/dist/contentstack-conversion-utils.d.ts.map +1 -0
- package/dist/contentstack-conversion-utils.js +504 -0
- package/dist/contentstack-conversion-utils.js.map +1 -0
- package/dist/contentstack-entries-converter.d.ts +39 -0
- package/dist/contentstack-entries-converter.d.ts.map +1 -0
- package/dist/contentstack-entries-converter.js +333 -0
- package/dist/contentstack-entries-converter.js.map +1 -0
- package/dist/contentstack-operation-converter.d.ts +42 -0
- package/dist/contentstack-operation-converter.d.ts.map +1 -0
- package/dist/contentstack-operation-converter.js +535 -0
- package/dist/contentstack-operation-converter.js.map +1 -0
- package/dist/contentstack-schema-converter.d.ts +26 -0
- package/dist/contentstack-schema-converter.d.ts.map +1 -0
- package/dist/contentstack-schema-converter.js +379 -0
- package/dist/contentstack-schema-converter.js.map +1 -0
- package/dist/contentstack-types.d.ts +429 -0
- package/dist/contentstack-types.d.ts.map +1 -0
- package/dist/contentstack-types.js +3 -0
- package/dist/contentstack-types.js.map +1 -0
- package/dist/contentstack-utils.d.ts +31 -0
- package/dist/contentstack-utils.d.ts.map +1 -0
- package/dist/contentstack-utils.js +144 -0
- package/dist/contentstack-utils.js.map +1 -0
- package/dist/entries-converter.d.ts +10 -0
- package/dist/entries-converter.d.ts.map +1 -0
- package/dist/entries-converter.js +245 -0
- package/dist/entries-converter.js.map +1 -0
- package/dist/file-download.d.ts +2 -0
- package/dist/file-download.d.ts.map +1 -0
- package/dist/file-download.js +33 -0
- package/dist/file-download.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/schema-converter.d.ts +3 -0
- package/dist/schema-converter.d.ts.map +1 -0
- package/dist/schema-converter.js +169 -0
- package/dist/schema-converter.js.map +1 -0
- package/dist/transformation-utils.d.ts +41 -0
- package/dist/transformation-utils.d.ts.map +1 -0
- package/dist/transformation-utils.js +730 -0
- package/dist/transformation-utils.js.map +1 -0
- package/dist/types.d.ts +120 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +44 -0
- package/src/contentstack-api-client.ts +330 -0
- package/src/contentstack-content-poller.ts +157 -0
- package/src/contentstack-content-source.ts +687 -0
- package/src/contentstack-entries-converter.ts +438 -0
- package/src/contentstack-operation-converter.ts +703 -0
- package/src/contentstack-schema-converter.ts +486 -0
- package/src/contentstack-types.ts +527 -0
- package/src/contentstack-utils.ts +174 -0
- 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
|
+
}
|