package core import ( "fmt" "strings" "time" "github.com/AlecAivazis/survey/v2" "github.com/fatih/color" "github.com/pocketbase/dbx" "github.com/spf13/cast" ) var AppMigrations MigrationsList var SystemMigrations MigrationsList const DefaultMigrationsTable = "_migrations" // MigrationsRunner defines a simple struct for managing the execution of db migrations. type MigrationsRunner struct { app App tableName string migrationsList MigrationsList inited bool } // NewMigrationsRunner creates and initializes a new db migrations MigrationsRunner instance. func NewMigrationsRunner(app App, migrationsList MigrationsList) *MigrationsRunner { return &MigrationsRunner{ app: app, migrationsList: migrationsList, tableName: DefaultMigrationsTable, } } // Run interactively executes the current runner with the provided args. // // The following commands are supported: // - up - applies all migrations // - down [n] - reverts the last n (default 1) applied migrations // - history-sync - syncs the migrations table with the runner's migrations list func (r *MigrationsRunner) Run(args ...string) error { if err := r.initMigrationsTable(); err != nil { return err } cmd := "up" if len(args) > 0 { cmd = args[0] } switch cmd { case "up": applied, err := r.Up() if err != nil { return err } if len(applied) == 0 { color.Green("No new migrations to apply.") } else { for _, file := range applied { color.Green("Applied %s", file) } } return nil case "down": toRevertCount := 1 if len(args) > 1 { toRevertCount = cast.ToInt(args[1]) if toRevertCount < 0 { // revert all applied migrations toRevertCount = len(r.migrationsList.Items()) } } names, err := r.lastAppliedMigrations(toRevertCount) if err != nil { return err } confirm := false prompt := &survey.Confirm{ Message: fmt.Sprintf( "\n%v\nDo you really want to revert the last %d applied migration(s)?", strings.Join(names, "\n"), toRevertCount, ), } survey.AskOne(prompt, &confirm) if !confirm { fmt.Println("The command has been cancelled") return nil } reverted, err := r.Down(toRevertCount) if err != nil { return err } if len(reverted) == 0 { color.Green("No migrations to revert.") } else { for _, file := range reverted { color.Green("Reverted %s", file) } } return nil case "history-sync": if err := r.RemoveMissingAppliedMigrations(); err != nil { return err } color.Green("The %s table was synced with the available migrations.", r.tableName) return nil default: return fmt.Errorf("Unsupported command: %q\n", cmd) } } // Up executes all unapplied migrations for the provided runner. // // On success returns list with the applied migrations file names. func (r *MigrationsRunner) Up() ([]string, error) { if err := r.initMigrationsTable(); err != nil { return nil, err } applied := []string{} err := r.app.AuxRunInTransaction(func(txApp App) error { return txApp.RunInTransaction(func(txApp App) error { for _, m := range r.migrationsList.Items() { // applied migrations check if r.isMigrationApplied(txApp, m.File) { if m.ReapplyCondition == nil { continue // no need to reapply } shouldReapply, err := m.ReapplyCondition(txApp, r, m.File) if err != nil { return err } if !shouldReapply { continue } // clear previous history stored entry // (it will be recreated after successful execution) r.saveRevertedMigration(txApp, m.File) } // ignore empty Up action if m.Up != nil { if err := m.Up(txApp); err != nil { return fmt.Errorf("Failed to apply migration %s: %w", m.File, err) } } if err := r.saveAppliedMigration(txApp, m.File); err != nil { return fmt.Errorf("Failed to save applied migration info for %s: %w", m.File, err) } applied = append(applied, m.File) } return nil }) }) if err != nil { return nil, err } return applied, nil } // Down reverts the last `toRevertCount` applied migrations // (in the order they were applied). // // On success returns list with the reverted migrations file names. func (r *MigrationsRunner) Down(toRevertCount int) ([]string, error) { if err := r.initMigrationsTable(); err != nil { return nil, err } reverted := make([]string, 0, toRevertCount) names, appliedErr := r.lastAppliedMigrations(toRevertCount) if appliedErr != nil { return nil, appliedErr } err := r.app.AuxRunInTransaction(func(txApp App) error { return txApp.RunInTransaction(func(txApp App) error { for _, name := range names { for _, m := range r.migrationsList.Items() { if m.File != name { continue } // revert limit reached if toRevertCount-len(reverted) <= 0 { return nil } // ignore empty Down action if m.Down != nil { if err := m.Down(txApp); err != nil { return fmt.Errorf("Failed to revert migration %s: %w", m.File, err) } } if err := r.saveRevertedMigration(txApp, m.File); err != nil { return fmt.Errorf("Failed to save reverted migration info for %s: %w", m.File, err) } reverted = append(reverted, m.File) } } return nil }) }) if err != nil { return nil, err } return reverted, nil } // RemoveMissingAppliedMigrations removes the db entries of all applied migrations // that are not listed in the runner's migrations list. func (r *MigrationsRunner) RemoveMissingAppliedMigrations() error { loadedMigrations := r.migrationsList.Items() names := make([]any, len(loadedMigrations)) for i, migration := range loadedMigrations { names[i] = migration.File } _, err := r.app.DB().Delete(r.tableName, dbx.Not(dbx.HashExp{ "file": names, })).Execute() return err } func (r *MigrationsRunner) initMigrationsTable() error { if r.inited { return nil // already inited } rawQuery := fmt.Sprintf( "CREATE TABLE IF NOT EXISTS {{%s}} (file VARCHAR(255) PRIMARY KEY NOT NULL, applied INTEGER NOT NULL)", r.tableName, ) _, err := r.app.DB().NewQuery(rawQuery).Execute() if err == nil { r.inited = true } return err } func (r *MigrationsRunner) isMigrationApplied(txApp App, file string) bool { var exists bool err := txApp.DB().Select("count(*)"). From(r.tableName). Where(dbx.HashExp{"file": file}). Limit(1). Row(&exists) return err == nil && exists } func (r *MigrationsRunner) saveAppliedMigration(txApp App, file string) error { _, err := txApp.DB().Insert(r.tableName, dbx.Params{ "file": file, "applied": time.Now().UnixMicro(), }).Execute() return err } func (r *MigrationsRunner) saveRevertedMigration(txApp App, file string) error { _, err := txApp.DB().Delete(r.tableName, dbx.HashExp{"file": file}).Execute() return err } func (r *MigrationsRunner) lastAppliedMigrations(limit int) ([]string, error) { var files = make([]string, 0, limit) loadedMigrations := r.migrationsList.Items() names := make([]any, len(loadedMigrations)) for i, migration := range loadedMigrations { names[i] = migration.File } err := r.app.DB().Select("file"). From(r.tableName). Where(dbx.Not(dbx.HashExp{"applied": nil})). AndWhere(dbx.HashExp{"file": names}). // unify microseconds and seconds applied time for backward compatibility OrderBy("substr(applied||'0000000000000000', 0, 17) DESC"). AndOrderBy("file DESC"). Limit(int64(limit)). Column(&files) if err != nil { return nil, err } return files, nil }