mirror of
https://github.com/cupcakearmy/firemorph.git
synced 2025-09-06 06:00:39 +00:00
initial commit
This commit is contained in:
20
src/cli.ts
Normal file
20
src/cli.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from 'commander'
|
||||
import spec from '../package.json'
|
||||
import { migrate } from './'
|
||||
|
||||
const program = new Command()
|
||||
program.version(spec.version).name(spec.name)
|
||||
|
||||
program
|
||||
.command('migrate')
|
||||
.description('run migrations')
|
||||
// .option('--dry-run', 'run simulation without committing changes')
|
||||
.option('-m, --migrations <glob>', 'migration files', './migrations/*.js')
|
||||
.option('--force', 'ignore remote state and rerun migrations')
|
||||
.action(async (args) => {
|
||||
await migrate({ directory: args.migrations, ignoreRemote: args.force })
|
||||
})
|
||||
|
||||
program.parse(process.argv)
|
117
src/index.ts
Normal file
117
src/index.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
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)
|
||||
}
|
Reference in New Issue
Block a user