// 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 ( "context" "database/sql" "embed" "fmt" "io/fs" "path" "strconv" "strings" ) // 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(ctx context.Context, db *sql.DB, dir string) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("starting migrations: %w", err) } // Does nothing once Commit has succeeded. defer func() { _ = tx.Rollback() }() var tables int err = tx.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master "+ "WHERE type = 'table' AND name = 'schema_migrations'", ).Scan(&tables) if err != nil { return fmt.Errorf("looking for schema_migrations: %w", err) } if tables == 0 { err = run(ctx, 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(ctx, tx, name) if err != nil { return err } } err = tx.Commit() if err != nil { return fmt.Errorf("committing migrations: %w", err) } return nil } // applyOnce runs the migration file name and records its number, // unless schema_migrations already has that number. func applyOnce(ctx context.Context, tx *sql.Tx, 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.QueryRowContext(ctx, "SELECT count(*) FROM schema_migrations WHERE version = ?", version, ).Scan(&applied) if err != nil { return fmt.Errorf("checking migration %s: %w", name, err) } if applied > 0 { return nil } err = run(ctx, tx, name) if err != nil { return err } _, err = tx.ExecContext(ctx, "INSERT INTO schema_migrations (version) VALUES (?)", version, ) 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(ctx context.Context, tx *sql.Tx, name string) error { script, err := files.ReadFile(name) if err != nil { return fmt.Errorf("reading migration %s: %w", name, err) } _, err = tx.ExecContext(ctx, string(script)) if err != nil { return fmt.Errorf("applying migration %s: %w", name, err) } return nil }