package store import ( "context" "crypto/sha256" "database/sql" "embed" "encoding/hex" "errors" "fmt" "io/fs" "path" "sort" "strconv" "strings" ) //go:embed migrations/*.sql var migrationsFS embed.FS // migration is one numbered file from migrations/. type migration struct { version int name string body string checksum string } // migrate applies every migration the database has not seen yet. // // SQLite runs DDL inside transactions, so each migration either lands whole or // not at all — there is no dirty state for an operator to repair by hand, which // is the main thing an external migration tool buys elsewhere. What is worth // keeping is the checksum: it catches an already-applied migration file being // edited afterwards, which otherwise produces two divergent schemas that both // claim to be at the same version. func (d *DB) migrate(ctx context.Context) error { migrations, err := loadMigrations() if err != nil { return err } if err := d.Tx(ctx, func(tx *sql.Tx) error { _, err := tx.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER NOT NULL PRIMARY KEY, checksum TEXT NOT NULL, applied_at INTEGER NOT NULL )`) return err }); err != nil { return fmt.Errorf("create schema_version: %w", err) } applied := map[int]string{} rows, err := d.r.QueryContext(ctx, `SELECT version, checksum FROM schema_version`) if err != nil { return fmt.Errorf("read schema_version: %w", err) } defer rows.Close() for rows.Next() { var v int var sum string if err := rows.Scan(&v, &sum); err != nil { return err } applied[v] = sum } if err := rows.Err(); err != nil { return err } rows.Close() for _, m := range migrations { if sum, ok := applied[m.version]; ok { if sum != m.checksum { return fmt.Errorf( "migration %s was modified after it was applied (recorded %s, now %s); "+ "add a new migration instead of editing an applied one", m.name, sum, m.checksum) } continue } if d.log != nil { d.log.Info("applying migration", "version", m.version, "name", m.name) } if err := d.Tx(ctx, func(tx *sql.Tx) error { if _, err := tx.ExecContext(ctx, m.body); err != nil { return err } _, err := tx.ExecContext(ctx, `INSERT INTO schema_version (version, checksum, applied_at) VALUES (?, ?, ?)`, m.version, m.checksum, unixNow()) return err }); err != nil { return fmt.Errorf("migration %s: %w", m.name, err) } } // A database from a newer build is not something this binary can serve // safely: it may be missing columns the newer code added. for v := range applied { if !hasVersion(migrations, v) { return fmt.Errorf( "database is at schema version %d, which this build does not know about; "+ "it was probably written by a newer pages-server", v) } } return nil } func hasVersion(ms []migration, v int) bool { for _, m := range ms { if m.version == v { return true } } return false } // loadMigrations reads the embedded files, ordered by version. func loadMigrations() ([]migration, error) { entries, err := fs.ReadDir(migrationsFS, "migrations") if err != nil { return nil, err } var out []migration seen := map[int]string{} for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { continue } version, err := parseVersion(e.Name()) if err != nil { return nil, err } if prev, dup := seen[version]; dup { return nil, fmt.Errorf("migrations %s and %s share version %d", prev, e.Name(), version) } seen[version] = e.Name() body, err := migrationsFS.ReadFile(path.Join("migrations", e.Name())) if err != nil { return nil, err } sum := sha256.Sum256(body) out = append(out, migration{ version: version, name: e.Name(), body: string(body), checksum: hex.EncodeToString(sum[:]), }) } if len(out) == 0 { return nil, errors.New("no migrations embedded") } sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version }) return out, nil } // parseVersion reads the leading number of "0001_init.sql". func parseVersion(name string) (int, error) { base, _, ok := strings.Cut(name, "_") if !ok { return 0, fmt.Errorf("migration %q: want NNNN_name.sql", name) } v, err := strconv.Atoi(base) if err != nil || v <= 0 { return 0, fmt.Errorf("migration %q: want a positive leading version number", name) } return v, nil } // SchemaVersion reports the highest applied migration version. func (d *DB) SchemaVersion(ctx context.Context) (int, error) { var v sql.NullInt64 err := d.r.QueryRowContext(ctx, `SELECT max(version) FROM schema_version`).Scan(&v) if err != nil { return 0, err } return int(v.Int64), nil }