import type { MaybePromise } from "./types.ts"; /** * @ignore * * Options for defining some sort of migration * * ```js * const options = { * up(value) {}, * down(value) {}, * } * ``` */ export interface MigrationOptions { up(value: T): MaybePromise; down(value: T): MaybePromise; } /** * @ignore * * A definition for a migration, it's basically a `MigrationOptions` with a unique name * * ```js * const migration = { * name: '042-some-migration', * up() {}, * down() {}, * } * ``` */ export interface MigrationDefinition { name: string; up(value: T): MaybePromise; down(value: T): MaybePromise; } /** * @ignore * * A record of a migration that has been performed * * ```js * const record = { name: '042-some-migration' } * ``` */ export interface MigrationRecord { name: string; } /** * @group Migrator * * Define a generic migration, this is a wrapper around creating a `MigrationOptions` * which within TypeScript means you can specify the `` once, rather than for each action. * * ```js * const migration = defineMigration({ * up () {}, * down () {}, * }) * ``` */ export function defineMigration( options: MigrationOptions, ): MigrationOptions { return { up: options.up, down: options.down, }; } export type MigrateDirection = "up" | "down"; /** * @group Migrator * * MigratorOptions lets your create your own migrator that performs migrations in different ways. * For instance you could create one that loads a JSON "migrations" file from the filesystem. */ export interface MigratorOptions { /** * Get or generate the all migration definitions * * ```js * function getDefinitions () { * return { name: 001-something.js', up() {}, down() {} } * } * ``` */ getDefinitions(): Promise[]>; /** * Query which migrations have already been performed * * ```js * function getRecords () { * return [{ name: '001-something.js' }] * } * ``` */ getRecords(): Promise; /** * Perform or reverse a migration and update any required state * * ```js * function execute(definition, direction) { * console.log('running', definition.name, direction) * if (direction === 'up') definition.up() * if (direction === 'down') definition.down() * } * ``` */ execute( def: MigrationDefinition, direction: "up" | "down", ): void | Promise; } /** * @group Migrator * * Migrator provides methods for running a specific type of migrations. * The idea is that different platforms/integrations can create a migrator that * works with a specific feature they want to add migrations around, e.g. a Postgres database. * * ```js * const migrator = new Migrator({ * async getRecords() {}, * async getDefinitions() {}, * async execute(definition, direction) {} * }) * ``` * * See [examples/node-fs-migrator](https://github.com/robb-j/gruber/tree/main/examples/node-fs-migrator) */ export class Migrator { options: MigratorOptions; constructor(options: MigratorOptions) { this.options = options; } /** * Run any pending "up" migrations * * > It would be cool to specify a number here so you could run just 1 but * > I haven't needed this so it hasn't been properly designed yet * * ```js * await migrator.up() * ``` */ async up() { for (const def of await this._getTodo("up")) { await this.options.execute(def, "up"); } } /** * Run any "down" migrations for migrations that have already been performed * * > It would be cool to specify a number here so you could run just 1 but * > I haven't needed this so it hasn't been properly designed yet * * ```js * await migrator.up() * ``` */ async down() { for (const def of await this._getTodo("down")) { await this.options.execute(def, "down"); } } /** * @internal * * Get a number of migrations that need to be performed in a specific direction */ async _getTodo(direction: MigrateDirection, count = -1) { const defs = await this.options.getDefinitions(); const records = await this.options.getRecords(); const ran = new Set(records.map((r) => r.name)); return (direction === "up" ? defs : Array.from(defs).reverse()) .filter((def) => ran.has(def.name) === (direction === "down")) .slice(0, count === -1 ? Infinity : count); } } /** * @group Migrator * * Attempt to load a migration from a file using `import`. * * It combines the `name` and `directory` to get a file path, attempts to `import`-it and convert the `default` export into a `MigrationDefinition`. You can also force the `` parameter onto the definition. * * It will throw errors if the file does not exist or if the default export doesn't look like a `MigrationOptions`. * * * ```js * const migration = await loadMigration( * '001-create-users.js', * new URL('./migrations/', import.meta.url) * ) * * migration.name // "001-create-users.js" * migration.up // function * migration.down // function * ``` */ export async function loadMigration( name: string, directory: string | URL, ): Promise> { const url = new URL(name, directory); const def = await import(url.toString()); const up = def.default.up ?? def.up ?? null; if (up && typeof up !== "function") { throw new Error(`migration "${name}" - up is not a function`); } const down = def.default.down ?? def.down ?? null; if (down && typeof down !== "function") { throw new Error(`migration "${name}" - down is not a function`); } return { name, up, down }; }