firemorph/src/index.ts
2021-03-21 10:26:35 +01:00

118 lines
3.5 KiB
TypeScript

import crypto from 'crypto'
import path from 'path'
import admin from 'firebase-admin'
import semver from 'semver'
import glob from 'glob'
import chalk from 'chalk'
const App = admin.initializeApp()
const DB = admin.firestore()
const Timestamp = admin.firestore.Timestamp
const MigrationCollection = DB.collection('migrations')
export type MigrationFN = (db: FirebaseFirestore.Firestore, firestore: typeof admin.firestore) => Promise<void>
export type MigrationFile = {
version: string
name: string
fn: MigrationFN
}
enum MigrationResultStatus {
Successful = 'successful',
Failed = 'failed',
}
type MigrationResult = {
executed: FirebaseFirestore.Timestamp
version: string
status: MigrationResultStatus
}
export type Options = {
directory: string
delimiter: string
ignoreRemote: boolean
}
const defaults: Options = {
directory: './migrations',
delimiter: '__',
ignoreRemote: false,
}
const extension = /\..*$/
async function gather(options: Options): Promise<MigrationFile[]> {
const files = glob
.sync(path.join(options.directory, '*.js'))
.filter((f) => f.includes(options.delimiter))
.map((f) => path.resolve(f))
const versions: string[] = []
const contents = await Promise.all(
files.map(async (f) => {
const [rawVersion, name] = path.basename(f).split(options.delimiter)
const version = semver.coerce(rawVersion)
if (!version) throw new Error(`Invalid version: "${rawVersion}".`)
if (versions.includes(version.version))
throw new Error(`Cannot have multiple files for version: ${version.version}`)
versions.push(version.version)
const migration = await import(f)
if (typeof migration.migration !== 'function') throw new Error(`No migrate function found in: ${f}`)
return {
version,
name: name.replace(extension, ''),
fn: migration.migration as MigrationFN,
}
})
)
const sorted = contents.sort((a, b) => (semver.gt(a.version, b.version) ? 1 : -1))
return sorted.map(({ version, ...rest }) => ({
...rest,
version: version.version,
}))
}
function getIdFromMigration(migration: MigrationFile): string {
return crypto.createHash('sha256').update(migration.version).digest('hex')
}
function printMigration(migration: MigrationFile, msg: string) {
console.log(chalk.underline(`Migration ${chalk.bold(migration.version)}:`), msg)
}
async function runMigrations(migrations: MigrationFile[], options: Options) {
for (const migration of migrations) {
const id = getIdFromMigration(migration)
const remoteDoc = await MigrationCollection.doc(id).get()
const remote = remoteDoc.data() as MigrationResult | undefined
if (!options.ignoreRemote && remote && remote.status === MigrationResultStatus.Successful) {
printMigration(migration, '🔧 Already run.')
continue
}
const result: MigrationResult = {
version: migration.version,
executed: Timestamp.now(),
status: MigrationResultStatus.Successful,
}
try {
await migration.fn(DB, admin.firestore)
await remoteDoc.ref.set(result)
printMigration(migration, chalk.green(`✅ Success`))
} catch (e) {
await remoteDoc.ref.set({ ...result, status: MigrationResultStatus.Failed })
printMigration(migration, chalk.red(`❌ Error while running.`))
console.error(e)
break
}
}
}
export async function migrate(options?: Partial<Options>) {
const merged: Options = Object.assign(defaults, options)
const migrations = await gather(merged)
await runMigrations(migrations, merged)
}