import * as CSITypes from '@stackbit/types'; /** * AnyContentSourceInterface is the union of the previous ContentSourceInterface * versions. * * When changing the ContentSourceInterface in a way that it may break previous * content source modules, create a new type by omitting the changed methods and * redefine them with their new signatures. Then add the new type to the union. */ export type AnyContentSourceInterface = ContentSourceInterface_v0_1_0 | ContentSourceInterface_v0_2_0; export type ContentSourceInterface_v0_2_0 = CSITypes.ContentSourceInterface; export type ContentSourceInterface_v0_1_0 = Omit< CSITypes.ContentSourceInterface, | 'getVersion' | 'init' | 'destroy' | 'getSchema' | 'onFilesChange' | 'startWatchingContentUpdates' | 'stopWatchingContentUpdates' | 'hasAccess' | 'getDocuments' | 'createDocument' | 'updateDocument' > & { init(options: { logger: CSITypes.Logger; userLogger: CSITypes.Logger; userCommandSpawner?: CSITypes.UserCommandSpawner; localDev: boolean; webhookUrl?: string; devAppRestartNeeded?: () => void; }): Promise; getModels(): Promise; getLocales(): Promise; onFilesChange?(options: { updatedFiles: string[] }): Promise<{ schemaChanged?: boolean; contentChangeEvent?: CSITypes.ContentChangeEvent }>; startWatchingContentUpdates(options: { getModelMap: () => CSITypes.ModelMap; getDocument: ({ documentId }: { documentId: string }) => CSITypes.Document | undefined; getAsset: ({ assetId }: { assetId: string }) => CSITypes.Asset | undefined; onContentChange: (contentChangeEvent: CSITypes.ContentChangeEvent) => Promise; onSchemaChange: () => void; }): void; stopWatchingContentUpdates(): void; hasAccess(options: { userContext?: CSITypes.User }): boolean | Promise<{ hasConnection: boolean; hasPermissions: boolean }>; getDocuments(options: { modelMap: CSITypes.ModelMap }): Promise; createDocument(options: { updateOperationFields: Record; model: CSITypes.Model; modelMap: CSITypes.ModelMap; locale?: string; defaultLocaleDocumentId?: string; userContext?: CSITypes.User; }): Promise; updateDocument(options: { document: CSITypes.Document; operations: CSITypes.UpdateOperation[]; modelMap: CSITypes.ModelMap; userContext?: CSITypes.User; }): Promise; }; /** * BackCompatContentSourceInterface redefines the ContentSourceInterface such * that when its methods are called, it can correctly invoke the previous * content source module versions. * * The parameters of its methods must be intersections of the parameters in the * previous versions. For example, if a method in an older version received * 'options.x', and a method in the newer version receives 'options.y', the * matching method should receive both options '{ x } & { y }' to ensure that * both the older and the newer content sources will receive what they need. * * The method return values must match the most recent content source versions. */ export type BackCompatContentSourceInterface = Omit< CSITypes.ContentSourceInterface, 'getVersion' | 'onFilesChange' | 'startWatchingContentUpdates' | 'hasAccess' | 'getDocuments' | 'createDocument' | 'updateDocument' > & { getVersion(): GetVersionReturn; onFilesChange(options: BCOnFilesChangeOptions): OnFilesChangeReturn; startWatchingContentUpdates?(options: BCStartWatchingOptions): StartWatchingReturn; hasAccess(options: BCHasAccessOptions): HasAccessReturn; getDocuments(options: BCGetDocumentsOptions): GetDocumentsReturn; createDocument(options: BCCreateDocumentOptions): CreateDocumentReturn; updateDocument(options: BCUpdateDocumentOptions): UpdateDocumentReturn; }; export function backwardCompatibleContentSource(contentSource: AnyContentSourceInterface): BackCompatContentSourceInterface { return new Proxy(contentSource, { get(target: AnyContentSourceInterface, prop: keyof CSITypes.ContentSourceInterface): any { switch (prop) { case 'getVersion': return getVersion.bind(undefined, target); case 'destroy': return destroy.bind(undefined, target); case 'onFilesChange': return onFilesChange.bind(undefined, target); case 'startWatchingContentUpdates': return startWatchingContentUpdates.bind(undefined, target); case 'getSchema': return getSchema.bind(undefined, target); case 'hasAccess': return hasAccess.bind(undefined, target); case 'getDocuments': return getDocuments.bind(undefined, target); case 'createDocument': return createDocument.bind(undefined, target); case 'updateDocument': return updateDocument.bind(undefined, target); default: return target[prop]; } } }) as BackCompatContentSourceInterface; } type ReturnTypeOfMethod = ReturnType>; type GetVersionReturn = Promise<{ interfaceVersion: string; contentSourceVersion: string }>; export async function getVersion(contentSource: AnyContentSourceInterface): GetVersionReturn { if ('getVersion' in contentSource) { return contentSource.getVersion(); } return { interfaceVersion: '0.1.0', contentSourceVersion: '' }; } type DestroyReturn = ReturnTypeOfMethod<'destroy'>; export async function destroy(contentSource: AnyContentSourceInterface): DestroyReturn { if ('destroy' in contentSource) { return contentSource.destroy(); } } type BCOnFilesChangeOptions = { updatedFiles: string[] }; type OnFilesChangeReturn = ReturnTypeOfMethod<'onFilesChange'>; /** * Converts the old onFilesChange API to the new one * OLD: onFilesChange?(options: { updatedFiles: string[]; }): * Promise<{ schemaChanged?: boolean; contentChangeEvent?: ContentChangeEvent }> * NEW: onFilesChange?(options: { updatedFiles: string[]; }): * Promise<{ invalidateSchema?: boolean; contentChanges?: ContentChanges }> */ export async function onFilesChange(contentSource: AnyContentSourceInterface, options: BCOnFilesChangeOptions): OnFilesChangeReturn { const value = await contentSource.onFilesChange?.(options); if (!value) { return {}; } // if there are properties from the new API return the value as is if ('invalidateSchema' in value || 'contentChanges' in value) { return value; } // if there are properties from the old API return convert to the new API value const result: Awaited = {}; if ('schemaChanged' in value) { result.invalidateSchema = value.schemaChanged; } if ('contentChangeEvent' in value) { result.contentChanges = value.contentChangeEvent; } return result; } type BCStartWatchingOptions = { getModelMap: () => CSITypes.ModelMap; getDocument: ({ documentId }: { documentId: string }) => CSITypes.Document | undefined; getAsset: ({ assetId }: { assetId: string }) => CSITypes.Asset | undefined; onContentChange: (contentChangeEvent: CSITypes.ContentChangeEvent) => Promise; onSchemaChange: () => void; }; type StartWatchingReturn = ReturnTypeOfMethod<'startWatchingContentUpdates'>; /** * Converts between the old startWatchingContentUpdates API and the new one. * OLD: startWatchingContentUpdates(options: startWatchingContentUpdatesOptionsOld): void; * NEW: startWatchingContentUpdates?(): void; */ export function startWatchingContentUpdates(contentSource: AnyContentSourceInterface, options: BCStartWatchingOptions): StartWatchingReturn { contentSource.startWatchingContentUpdates?.(options); } /** * Converts the old getModels() and getLocales() API methods to the new getSchema() API method. * OLD: * getModels(): Promise; * getLocales(): Promise * NEW: * getSchema(): Promise> */ export async function getSchema(contentSource: AnyContentSourceInterface): Promise { if ('getSchema' in contentSource) { return contentSource.getSchema(); } const models = await contentSource.getModels(); const locales = await contentSource.getLocales(); return { models, locales, context: null }; } type HasAccessReturn = ReturnTypeOfMethod<'hasAccess'>; type BCHasAccessOptions = { userContext?: CSITypes.User }; /** * Converts the old hasAccess API to the new one * OLD: hasAccess(options: { userContext?: CSITypes.User }): Promise * NEW: hasAccess(options: { userContext?: CSITypes.User }): Promise<{ hasConnection: boolean; hasPermissions: boolean }> */ export async function hasAccess(contentSource: AnyContentSourceInterface, options: BCHasAccessOptions): HasAccessReturn { const result = await contentSource.hasAccess(options); if (typeof result === 'boolean') { return { hasConnection: result, hasPermissions: result }; } return result; } type BCGetDocumentsOptions = { modelMap: CSITypes.ModelMap; syncContext?: unknown }; type GetDocumentsReturn = ReturnTypeOfMethod<'getDocuments'>; /** * Converts the old getDocuments API to the new one * OLD: getDocuments(options: { modelMap: ModelMap }): Promise * NEW: getDocuments(): Promise */ export async function getDocuments(contentSource: AnyContentSourceInterface, options: BCGetDocumentsOptions): GetDocumentsReturn { return contentSource.getDocuments(options); } type BCCreateDocumentOptions = { updateOperationFields: Record; model: CSITypes.Model; modelMap: CSITypes.ModelMap; locale?: string; defaultLocaleDocumentId?: string; userContext?: CSITypes.User; }; type CreateDocumentReturn = ReturnTypeOfMethod<'createDocument'>; /** * Converts the old createDocument API to the new one * OLD: createDocument(options: Options & { modelMap: ModelMap }): Promise> * NEW: createDocument(options: Options): Promise<{ documentId: string }> */ export async function createDocument(contentSource: AnyContentSourceInterface, options: BCCreateDocumentOptions): CreateDocumentReturn { const result = await contentSource.createDocument(options); if ('id' in result) { return { documentId: result.id }; } return result; } type UpdateDocumentReturn = ReturnTypeOfMethod<'updateDocument'>; type BCUpdateDocumentOptions = { document: CSITypes.Document; operations: CSITypes.UpdateOperation[]; modelMap: CSITypes.ModelMap; userContext?: CSITypes.User; }; /** * Converts the old updateDocument API to the new one * OLD: updateDocument(options: Options & { modelMap: ModelMap }): Promise; * NEW: updateDocument(options: Options): Promise */ export async function updateDocument(contentSource: AnyContentSourceInterface, options: BCUpdateDocumentOptions): UpdateDocumentReturn { await contentSource.updateDocument(options); }