// Package migrations holds the schema of each kind of SQLite file // webhooker writes, as numbered SQL files embedded in the binary, and // applies it when a file is opened. // // Each kind of file has its own directory. In each, 000_migration.sql // creates only the schema_migrations table, and 001_schema.sql holds // the whole schema. Before 1.0 a schema change is an edit to // 001_schema.sql, never a new file. package migrations import ( "embed" "fmt" "io/fs" "path" "strconv" "strings" "gorm.io/gorm" ) // The directories, one for each kind of database file. const ( // Main is the main database, webhooker.db. Main = "main" // Events is a webhook's event database, events-{uuid}.db. Events = "events" // Archive is a database target's archive file. Archive = "archive" ) //go:embed main/*.sql events/*.sql archive/*.sql var files embed.FS // Apply runs each SQL file in dir, which is Main, Events or Archive, // that the database has not had yet, in the order of the number its // name starts with, and records that number in schema_migrations. // 000_migration.sql creates that table and records itself, so it runs // first, and only when the table is missing. // // It all runs in one transaction, so a failure leaves the file as it // was. func Apply(db *gorm.DB, dir string) error { return db.Transaction(func(tx *gorm.DB) error { var tables int // Row().Scan, not (*gorm.DB).Scan: see internal/gormlog. err := tx.Raw( "SELECT count(*) FROM sqlite_master "+ "WHERE type = 'table' AND name = ?", "schema_migrations", ).Row().Scan(&tables) if err != nil { return fmt.Errorf("looking for schema_migrations: %w", err) } if tables == 0 { err = run(tx, path.Join(dir, "000_migration.sql")) if err != nil { return err } } // fs.Glob lists the files in name order, which is number order. names, err := fs.Glob(files, path.Join(dir, "*.sql")) if err != nil { return fmt.Errorf("listing migrations: %w", err) } for _, name := range names { err = applyOnce(tx, name) if err != nil { return err } } return nil }) } // applyOnce runs the migration file name and records its number, // unless schema_migrations already has that number. func applyOnce(tx *gorm.DB, name string) error { number, _, _ := strings.Cut(path.Base(name), "_") version, err := strconv.Atoi(number) if err != nil { return fmt.Errorf("numbering migration %s: %w", name, err) } var applied int err = tx.Raw( "SELECT count(*) FROM schema_migrations WHERE version = ?", version, ).Row().Scan(&applied) if err != nil { return fmt.Errorf("checking migration %s: %w", name, err) } if applied > 0 { return nil } err = run(tx, name) if err != nil { return err } err = tx.Exec( "INSERT INTO schema_migrations (version) VALUES (?)", version, ).Error if err != nil { return fmt.Errorf("recording migration %s: %w", name, err) } return nil } // run executes every statement in the migration file name. func run(tx *gorm.DB, name string) error { script, err := files.ReadFile(name) if err != nil { return fmt.Errorf("reading migration %s: %w", name, err) } err = tx.Exec(string(script)).Error if err != nil { return fmt.Errorf("applying migration %s: %w", name, err) } return nil }