Skip to content

Instantly share code, notes, and snippets.

@stacey-gammon
Last active May 7, 2020 14:22
Show Gist options
  • Save stacey-gammon/ad6e60637404e779bdaba8502e54ac03 to your computer and use it in GitHub Desktop.
Save stacey-gammon/ad6e60637404e779bdaba8502e54ac03 to your computer and use it in GitHub Desktop.

Persistable state plugin

Corner cases

  1. Registrar wants to add a migration to every registry item. Example: Author of Embeddable api wants to migrate base EmbeddableInput.
  2. Enhancer wants to add a migration to every registry item. Author of EnhancedEmbeddableDrilldowns wants to migrate EnhancedEmbeddableInput.
  3. Registrator wants to add a migration to it's specific registry item. Example: Author of VisState wants to migrate VisualizeEmbeddableInput

Questions

  1. We need migrations to hang around forever. Do we need to support calling injectReferences and extractReferences on legacy state, or can we assume these functions will always get the latest migrated state?

Option 1: Every migrate function returns state in the latest form.

There is only a single PersistableStateDefinition per id, so the registrator needs to ensure they are manually migrating PersistableState incrementally, through every version.

interface PersistableStatePlugin {
  setup: () => {
    register: (persistableStateDefinition: {
      id: string,
      migrate: (state: PersistableState, version: string) => PersistableState;
    }
  }
}

Every registrator could implement this differently, but here is a pretty clean example of how it could be done.

const greetingMigrator: PersistableStateMigrationFn (state: unknown, version: string): State {
  let versionStep: string = version;
  let state710?: GreetingState710 = version === '7.1.0' ? state as GreetingState710 : undefined;
  let state730?: GreetingState730 = version === '7.3.0' ? state as GreetingState730 : undefined;
  let state750?: GreetingState750 = version === '7.5.0' ? state as GreetingState750 : undefined;
  let stateLatest?: GreetingStateLatest = version > '7.5.0' ? state as GreetingStateLatest : undefined;

  if (state710 !=== undefined) {
   state730 = greetingMigrator710to730(state as GreetingState710);
  }

  if (state730 !=== undefined) {
   state750 = greetingMigrator730to750(state730);
  }

  if (state750 !=== undefined) {
   stateLatest = greetingMigrator750toLatest(state750);
  }

  // Do we have to migrate these enhancements at each step above? Or can we operate only on the latest version?
  stateLatest.enhancements = stateLatest.enhancements.forEach(key => 
    // What do we pass in for "key" here? How do we ensure uniqueness and avoid subtle bugs if an enhancer
    // fails to register a PersistableStateDefinition for `key`, and it accidentally matches some other
    // PersistableStateDefinition?
    persistableStatePlugin.get(key).migrate(
      stateLatest.enhancements[key],
      // What do we pass in for "version" here?
      stateLatest.enhancements[key].version)
    );

  return stateLatest;
}

persistableStatePlugin.register({ id: 'greeter', migrate: greetingMigrator });

// Because of "unknown", this isn't type safe.
persistableStatePlugin.get('greeter').migrate({ foo: 'hi' }, '7.0');
persistableStatePlugin.get('greeter').migrate({ bar: 'hi' }, '7.0');

// However, debateable, this is inherently not typesafe because you probably will
// never know the version or state shape at compile time. It'll look more like:
persistableStatePlugin.get('greeter').migrate(
  savedObject.attributes.greeter.state,
  savedObject.attributes.greeter.version
);

Option 2: PersistableStateDefinitions are registered per version.

PersistableStatePlugin handles the recursive state migrations until there are no more.

interface PersistableStatePlugin {
  setup: () => {
    register: <State, MigratedState>(persistableStateDefinition: {
      id: string;
      version: string;
      migrate: (state: State) => { migratedState: MigratedState, version: string };
    }
  }

  start: () => ({
    afterLoad(id: string, state: State, version: string) => {
      const { migratedState, version as latestVersion } = migrateState(id, state, version);
      const stateDefinition = this.getPersistableStateDefinitionForVersion(id, version);
      return 
    },

    migrateState(id: string, state: State, version: string) => {
      let stateDefinition = this.getPersistableStateDefinitionForVersion(id, version);
      const { migratedState, version } = stateDefinition.migrate(state);
      
      // Continue to look for migrations until there are no more migrations registered.
      while (version != stateDefinition.version) {
        stateDefinition = this.getPersistableStateDefinitionForVersion(id, version);
        const { migratedState, version } = stateDefinition.migrate(state);
      }

      return { migratedState, version };
    }
  })

  getPersistableStateDefinitionForVersion(id: string, version: string) {
      const definitions = this.persistableStateDefinitions.get(id);
      let foundDefinition?: PersistableStateDefinition;
      definitions.forEach(definition => {
        if (definition.version < version) {
          // Potential migrator found. If definitions are registered for 7.0, 7.1 and 7.3, but
          // the caller is looking for the definition to use for 7.2, 7.1 is the appropriate
          // definition. 7.2 wouldn't be registered if nothing changed since 7.1.
          if (!foundDefinition || definition.version > foundDefinition.version ) {
            foundDefinition = definition;
          }
        }
      });
    if (!foundDefinition) {
      throw new Error(`No persistable state handler for id ${id} and version ${version}`);
    }
    return foundDefinition;
  }
}
persistableStatePlugin.register<GreetingState710, GreetingState730>({
  id: 'greeter',
  version: '7.1.0',
  migrate: (state: GreetingState710) => { migratedState: greetingMigrator710to730(state), version: '7.3.0' }
});

persistableStatePlugin.register<GreetingState730, GreetingState750>({
  id: 'greeter',
  version: '7.3.0',
  migrate: (state: GreetingState730) => { migratedState: greetingMigrator730to750(state), version: '7.5.0' }
});

persistableStatePlugin.register<GreetingState750, GreetingStateLatest>({
  id: 'greeter',
  version: '7.5.0',
  migrate: (state: GreetingState750) => { migratedState: greetingMigrator750toLatest(state), version: getLatestKibanaVersion() }
});

persistableStatePlugin.register<GreetingStateLatest>({
  id: 'greeter',
  version: getLatestKibanaVersion(),
  // The "identity" migrator. Just returns the same state - we've reached the latest version.
  migrate: (state: GreetingStateLatest) => { migratedState: state, version: getLatestKibanaVersion() }
});

I think the above may solve some of the problems with keeping state in sync. For instance, if you put it all in one function, it requires a lot of coordination on the part of the author of the migrator.

You can see in the code snippet in the next section that there is a question of

What happens if VisualizeEmbeddableInput tries to access something off an older version of the base EmbeddableInput?

But there are still issues with this version because what is VisualizeEmbeddable registers a migration for 7.2 but Embeddable only has migrations registered for 7.1 and 7.3?

Going over the corner cases with a specific example.

interface EmbeddableInput710 {
  title: string;
  id: string;
}

interface EmbeddableInput730 {
  defaultTitle: string;
  id: string;
}

interface EmbeddableInput750 {
  defaultTitle: string;
  enhancements: Record<string, unknown>;
  id: string;
}

function embeddableMigrator710to730(state: EmbeddableInput710) {
  return {
    ...state
    defaultPanelTitle: state.title,
    title: undefined,
  }
}

const embeddableMigrator: PersistableStateMigrationFn (state: { type: string; input: unknown }, version: string): State {
  let versionStep: string = version;
  let input710?: EmbeddableInput710 = version === '7.1.0' ? state.input as EmbeddableInput710 : undefined;
  let input730?: EmbeddableInput730 = version === '7.3.0' ? state.input as EmbeddableInput730 : undefined;
  let input750?: EmbeddableInput750 = version === '7.5.0' ? state.input as EmbeddableInput750 : undefined;
  let inputLatest?: EmbeddableInputLatest = version > '7.5.0' ? state.input as EmbeddableInputLatest : undefined;

  if (input710 !=== undefined) {
   input730 = embeddableMigrator710to730(input710 as EmbeddableInput710);
  }

  if (input730 !=== undefined) {
   input750 = embeddableMigrator730to750(input730);
  }

  if (input750 !=== undefined) {
   inputLatest = embeddableMigrator750toLatest(input750);
  }

  // Do we have to migrate these enhancements at each step above? Or can we operate only on the latest version?
  inputLatest.enhancements = inputLatest.enhancements.forEach(key => 
    // What do we pass in for "key" here? How do we ensure uniqueness and avoid subtle bugs if an enhancer
    // fails to register a PersistableStateDefinition for `key`, and it accidentally matches some other
    // PersistableStateDefinition?
    persistableStatePlugin.get(key).migrate(
      inputLatest.enhancements[key],
      version)
    );

  // When and how do we migrate the specific state (like VisualizeEmbeddableInput) instead of the generic state?
  // How do we protect against clashes (e.g. what if `defaultTitle` already existed in `VisualizeEmbeddableInput`?).
  // What happens if `VisualizeEmbeddableInput` tries to access something off an older version of the base
  // `EmbeddableInput`?
  // Also, how do we help registrators know what `state` type to use? Should we pass type in or just input? If
  // just input, what if an Embeddable wants to migrate from one type to another? 
  return persistableStatePlugin.get(state.type).migrate({ type, input: inputLatest }, version), 
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment