316 lines · 8.7 KB · TypeScript
RawDownload
import { parse } from "semver";import { type AnySchema, type NameVariants, schema } from "../schema/create";import {  applyNameVariants,  type NameVariantsConfig,} from "../schema/name-variants-builder";import type { LibraryConfig, RelationMode } from "../shared/config";import type { Provider } from "../shared/providers";import { deepEqual } from "../utils/deep-equal";import { generateMigrationFromSchema as defaultGenerateMigrationFromSchema } from "./auto-from-schema";import type { MigrationOperation } from "./shared"; type Awaitable<T> = T | Promise<T>; interface MigrationContext {  auto: () => Promise<MigrationOperation[]>;} export type CustomMigrationFn = (  context: MigrationContext) => Awaitable<MigrationOperation[]>; export interface MigrateOptions {  /**   * Manage how migrations are generated.   *   * - `from-schema` (default): compare fumadb schemas   * - `from-database`: introspect & compare the database with schema   */  mode?: "from-schema" | "from-database";   /**   * Update internal settings, it's true by default.   * We don't recommend to disable it other than testing purposes.   */  updateSettings?: boolean;   /**   * Enable unsafe operations when auto-generating migration.   */  unsafe?: boolean;} export interface MigrationResult {  operations: MigrationOperation[];  getSQL?: () => string;  execute: () => Promise<void>;} export interface Migrator {  /**   * Get current version, undefined if not initialized   */  getVersion: () => Promise<string | undefined>;  getNameVariants: () => Promise<NameVariantsConfig | undefined>;   next: () => Promise<AnySchema | undefined>;  previous: () => Promise<AnySchema | undefined>;  up: (options?: MigrateOptions) => Promise<MigrationResult>;  down: (options?: MigrateOptions) => Promise<MigrationResult>;  migrateTo: (    version: string,    options?: MigrateOptions  ) => Promise<MigrationResult>;  migrateToLatest: (options?: MigrateOptions) => Promise<MigrationResult>;} export interface MigrationEngineOptions {  libConfig: LibraryConfig;  userConfig: {    provider: Provider;    relationMode?: RelationMode;  };   executor: (operations: MigrationOperation[]) => Promise<void>;   generateMigrationFromSchema?: typeof defaultGenerateMigrationFromSchema;   generateMigrationFromDatabase?: (options: {    target: AnySchema;    dropUnusedColumns: boolean;  }) => Awaitable<MigrationOperation[]>;   settings: {    getVersion: () => Promise<string | undefined>;     /**     * get name variants for every table and column in schema.     *     * this is necessary for migrating database when name variants are changed by consumer,     * consumer's code doesn't have history like library's schema do, we need to detect it from previous settings.     */    getNameVariants(): Promise<Record<string, NameVariants> | undefined>;     updateSettingsInMigration: (      schema: AnySchema    ) => Awaitable<MigrationOperation[]>;  };   sql?: {    toSql: (operations: MigrationOperation[]) => string;  };   transformers?: MigrationTransformer[];} export interface MigrationTransformer {  /**   * Run after auto-generating migration operations   */  afterAuto?: (    operations: MigrationOperation[],    context: {      options: MigrateOptions;      prev: AnySchema;      next: AnySchema;    }  ) => MigrationOperation[];   /**   * Run on all migration operations   */  afterAll?: (    operations: MigrationOperation[],    context: {      prev: AnySchema;      next: AnySchema;    }  ) => MigrationOperation[];} export function createMigrator({  settings,  generateMigrationFromDatabase,  generateMigrationFromSchema = defaultGenerateMigrationFromSchema,  libConfig: { schemas, initialVersion = "0.0.0" },  userConfig,  executor,  sql: sqlConfig,  transformers = [],}: MigrationEngineOptions): Migrator {  const indexedSchemas = new Map<string, AnySchema>();   indexedSchemas.set(    initialVersion,    schema({      version: initialVersion,      tables: {},    })  );   for (const schema of schemas) {    if (indexedSchemas.has(schema.version))      throw new Error(`Duplicated version: ${schema.version}`);     indexedSchemas.set(schema.version, schema);  }   function getSchemaByVersion(version: string) {    const schema = indexedSchemas.get(version);    if (!schema) throw new Error(`Invalid version ${version}`);    return schema;  }   async function getCurrentSchema() {    const version = (await settings.getVersion()) ?? initialVersion;    const nameVariants = await settings.getNameVariants();    let schema = getSchemaByVersion(version);     if (nameVariants) schema = applyNameVariants(schema, nameVariants);     return schema;  }   function getSchemasOfVariant(    variant: readonly (string | number)[]  ): AnySchema[] {    return schemas.filter((schema) =>      deepEqual(parse(schema.version)!.prerelease, variant)    );  }   const instance: Migrator = {    getVersion() {      return settings.getVersion();    },    getNameVariants() {      return settings.getNameVariants();    },    async next() {      const version = (await settings.getVersion()) ?? initialVersion;      const list = getSchemasOfVariant(parse(version)!.prerelease);      const index = list.findIndex((schema) => schema.version === version);       return list[index + 1];    },    async previous() {      const version = await settings.getVersion();      if (!version) return;       const list = getSchemasOfVariant(parse(version)!.prerelease);      const index = list.findIndex((schema) => schema.version === version);      return list[index - 1];    },    async up(options = {}) {      const next = await this.next();      if (!next) throw new Error("Already up to date.");       return this.migrateTo(next.version, options);    },    async down(options = {}) {      const prev = await this.previous();      if (!prev) throw new Error("No previous schema to migrate to.");       return this.migrateTo(prev.version, options);    },    async migrateTo(version, options = {}) {      const {        updateSettings: updateVersion = true,        unsafe = false,        mode = "from-schema",      } = options;      const targetSchema = getSchemaByVersion(version);      const currentSchema = await getCurrentSchema();       let run:        | ((context: MigrationContext) => Awaitable<MigrationOperation[]>)        | undefined;       // same variant      const prevVariant = parse(targetSchema.version)!.prerelease;      const variant = parse(currentSchema.version)!.prerelease;       if (deepEqual(prevVariant, variant)) {        const list = getSchemasOfVariant(variant);        const targetIdx = list.indexOf(targetSchema);         switch (currentSchema.version) {          case list[targetIdx - 1]?.version:            run = targetSchema.up;            break;          case list[targetIdx + 1]?.version:            run = targetSchema.down;            break;        }      }       run ??= (context) => context.auto();       const context: MigrationContext = {        async auto() {          let generated: MigrationOperation[];           if (mode === "from-schema") {            generated = generateMigrationFromSchema(              currentSchema,              targetSchema,              userConfig            );          } else {            if (!generateMigrationFromDatabase)              throw new Error(`${mode} is not supported for this adapter.`);             generated = await generateMigrationFromDatabase({              target: targetSchema,              dropUnusedColumns: unsafe,            });          }           for (const transformer of transformers) {            if (!transformer.afterAuto) continue;             generated = transformer.afterAuto(generated, {              prev: currentSchema,              next: targetSchema,              options,            });          }           return generated;        },      };       let operations = await run(context);       if (updateVersion) {        operations.push(          ...(await settings.updateSettingsInMigration(targetSchema))        );      }       for (const transformer of transformers) {        if (!transformer.afterAll) continue;        operations = transformer.afterAll(operations, {          prev: currentSchema,          next: targetSchema,        });      }       return {        operations,        getSQL: sqlConfig ? () => sqlConfig.toSql(operations) : undefined,        execute: () => executor(operations),      };    },    async migrateToLatest(options) {      const version = (await settings.getVersion()) ?? initialVersion;      const last = getSchemasOfVariant(parse(version)!.prerelease).at(-1);      if (!last) throw new Error(`Cannot find other schemas`);       return this.migrateTo(last.version, options);    },  };   return instance;}