import Boom from '@hapi/boom' ; import { set } from '@elastic/safer-lodash-set' ; import _ from 'lodash' ; import Semver from 'semver' ; import { Logger } from '../../../logging' ; import { SavedObjectUnsanitizedDoc } from '../../serialization' ; import { SavedObjectsMigrationVersion } from '../../types' ; import { MigrationLogger } from './migration_logger' ; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry' ; import { SavedObjectMigrationFn } from '../types' ; export type TransformFn = ( doc : SavedObjectUnsanitizedDoc ) => SavedObjectUnsanitizedDoc ; interface DocumentMigratorOptions { opensearchDashboardsVersion : string ; typeRegistry : ISavedObjectTypeRegistry ; log : Logger ; } interface ActiveMigrations { [ type ] : { latestVersion : string ; transforms : Array < { version : string ; transform : TransformFn ; } > ; } ; } /**
 * Manages migration of individual documents.
 */ export interface VersionedTransformer { migrationVersion : SavedObjectsMigrationVersion ; migrate : TransformFn ; } /**
 * A concrete implementation of the VersionedTransformer interface.
 */ export class DocumentMigrator implements VersionedTransformer { private migrations : ActiveMigrations ; private transformDoc : TransformFn ; param { DocumentMigratorOptions } opts prop prop prop memberof constructor ( { typeRegistry , opensearchDashboardsVersion , log } ) { validateMigrationDefinition ( typeRegistry ) ; this . migrations = buildActiveMigrations ( typeRegistry , log ) ; this . transformDoc = buildDocumentTransform ( { opensearchDashboardsVersion , migrations : this . migrations , } ) ; } readonly type { SavedObjectsMigrationVersion } memberof public get migrationVersion ( ) : SavedObjectsMigrationVersion { return _ . mapValues ( this . migrations , ( { latestVersion } ) => latestVersion ) ; } param { SavedObjectUnsanitizedDoc } doc returns { SavedObjectUnsanitizedDoc } memberof public migrate = ( doc ) : SavedObjectUnsanitizedDoc => { const clonedDoc = _ . cloneDeep ( doc ) ; return this . transformDoc ( clonedDoc ) ; } ; } /**
 * Basic validation that the migraiton definition matches our expectations. We can't
 * rely on TypeScript here, as the caller may be JavaScript / ClojureScript / any compile-to-js
 * language. So, this is just to provide a little developer-friendly error messaging. Joi was
 * giving weird errors, so we're just doing manual validation.
 */ function validateMigrationDefinition ( registry ) { function assertObject ( obj , prefix ) { if ( ! obj || typeof obj !== 'object' ) { throw new Error ( `${ prefix } Got ${ obj }.` ) ; } } function assertValidSemver ( version , type ) { if ( ! Semver . valid ( version ) ) { throw new Error ( `Invalid migration for type ${ type }. Expected all properties to be semvers, but got ${ version }.` ) ; } } function assertValidTransform ( fn , version , type ) { if ( typeof fn !== 'function' ) { throw new Error ( `Invalid migration ${ type }.${ version }: expected a function, but got ${ fn }.` ) ; } } registry . getAllTypes ( ) . forEach ( ( type ) => { if ( type . migrations ) { assertObject ( type . migrations , `Migration for type ${ type . name } should be an object like { '2.0.0': (doc) => doc }.` ) ; Object . entries ( type . migrations ) . forEach ( ( [ version , fn ] ) => { assertValidSemver ( version , type . name ) ; assertValidTransform ( fn , version , type . name ) ; } ) ; } } ) ; } /**
 * Converts migrations from a format that is convenient for callers to a format that
 * is convenient for our internal usage:
 * From: { type: { version: fn } }
 * To:   { type: { latestVersion: string, transforms: [{ version: string, transform: fn }] } }
 */ function buildActiveMigrations ( typeRegistry , log ) { return typeRegistry . getAllTypes ( ) . filter ( ( type ) => type . migrations && Object . keys ( type . migrations ) . length > 0 ) . reduce ( ( migrations , type ) => { const transforms = Object . entries ( type . migrations ! ) . map ( ( [ version , transform ] ) => ( { version , transform : wrapWithTry ( version , type . name , transform , log ) , } ) ) . sort ( ( a , b ) => Semver . compare ( a . version , b . version ) ) ; return { ... migrations , [ type . name ] : { latestVersion : _ . last ( transforms ) ! . version , transforms , } , } ; } , { } as ActiveMigrations ) ; } /**
 * Creates a function which migrates and validates any document that is passed to it.
 */ function buildDocumentTransform ( { migrations , } ) { return function transformAndValidate ( doc ) { const result = doc . migrationVersion ? applyMigrations ( doc , migrations ) : markAsUpToDate ( doc , migrations ) ; if ( _ . isEmpty ( result . migrationVersion ) ) { delete result . migrationVersion ; } return result ; } ; } function applyMigrations ( doc , migrations ) { while ( true ) { const prop = nextUnmigratedProp ( doc , migrations ) ; if ( ! prop ) { return doc ; } doc = migrateProp ( doc , prop , migrations ) ; } } /**
 * Gets the doc's props, handling the special case of "type".
 */ function props ( doc ) { return Object . keys ( doc ) . concat ( doc . type ) ; } /**
 * Looks up the prop version in a saved object document or in our latest migrations.
 */ function propVersion ( doc , prop ) { return ( ( ( doc as any ) [ prop ] && ( doc as any ) [ prop ] . latestVersion ) || ( doc . migrationVersion && ( doc as any ) . migrationVersion [ prop ] ) ) ; } /**
 * Sets the doc's migrationVersion to be the most recent version
 */ function markAsUpToDate ( doc , migrations ) { return { ... doc , migrationVersion : props ( doc ) . reduce ( ( acc , prop ) => { const version = propVersion ( migrations , prop ) ; return version ? set ( acc , prop , version ) : acc ; } , { } ) , } ; } /**
 * If a specific transform function fails, this tacks on a bit of information
 * about the document and transform that caused the failure.
 */ function wrapWithTry ( version , type , migrationFn , log ) { return function tryTransformDoc ( doc ) { try { const context = { log : new MigrationLogger ( log ) } ; const result = migrationFn ( doc , context ) ; if ( ! result || ! result . type ) { throw new Error ( `Invalid saved object returned from migration ${ type }:${ version }.` ) ; } return result ; } catch ( error ) { const failedTransform = `${ type }:${ version }` ; const failedDoc = JSON . stringify ( doc ) ; log . warn ( `Failed to transform document ${ doc }. Transform: ${ failedTransform }\nDoc: ${ failedDoc }` ) ; throw error ; } } ; } /**
 * Finds the first unmigrated property in the specified document.
 */ function nextUnmigratedProp ( doc , migrations ) { return props ( doc ) . find ( ( p ) => { const latestVersion = propVersion ( migrations , p ) ; const docVersion = propVersion ( doc , p ) ; if ( latestVersion === docVersion ) { return false ; } if ( docVersion && ( ! latestVersion || Semver . gt ( docVersion , latestVersion ) ) ) { throw Boom . badData ( `Document "${ doc . id }" has property "${ p }" which belongs to a more recent` + ` version of OpenSearch Dashboards [${ docVersion }]. The last known version is [${ latestVersion }]` , doc ) ; } return true ; } ) ; } /**
 * Applies any relevent migrations to the document for the specified property.
 */ function migrateProp ( doc , prop , migrations ) { const originalType = doc . type ; let migrationVersion = _ . clone ( doc . migrationVersion ) || { } ; const typeChanged = ( ) => ! doc . hasOwnProperty ( prop ) || doc . type !== originalType ; for ( const { version , transform } of applicableTransforms ( migrations , doc , prop ) ) { doc = transform ( doc ) ; migrationVersion = updateMigrationVersion ( doc , migrationVersion , prop , version ) ; doc . migrationVersion = _ . clone ( migrationVersion ) ; if ( typeChanged ( ) ) { break ; } } return doc ; } /**
 * Retrieves any prop transforms that have not been applied to doc.
 */ function applicableTransforms ( migrations , doc , prop ) { const minVersion = propVersion ( doc , prop ) ; const { transforms } = migrations [ prop ] ; return minVersion ? transforms . filter ( ( { version } ) => Semver . gt ( version , minVersion ) ) : transforms ; } /**
 * Updates the document's migrationVersion, ensuring that the calling transform
 * has not mutated migrationVersion in an unsupported way.
 */ function updateMigrationVersion ( doc , migrationVersion , prop , version ) { assertNoDowngrades ( doc , migrationVersion , prop , version ) ; const docVersion = propVersion ( doc , prop ) || '0.0.0' ; const maxVersion = Semver . gt ( docVersion , version ) ? docVersion : version ; return { ... ( doc . migrationVersion || migrationVersion ) , [ prop ] : maxVersion } ; } /**
 * Transforms that remove or downgrade migrationVersion properties are not allowed,
 * as this could get us into an infinite loop. So, we explicitly check for that here.
 */ function assertNoDowngrades ( doc , migrationVersion , prop , version ) { const docVersion = doc . migrationVersion ; if ( ! docVersion ) { return ; } const downgrade = Object . keys ( migrationVersion ) . find ( ( k ) => ! docVersion . hasOwnProperty ( k ) || Semver . lt ( docVersion [ k ] , migrationVersion [ k ] ) ) ; if ( downgrade ) { throw new Error ( `Migration "${ prop } v ${ version }" attempted to ` + `downgrade "migrationVersion.${ downgrade }" from ${ migrationVersion [ downgrade ] } ` + `to ${ docVersion [ downgrade ] }.` ) ; } }