Files
2026-08-15 07:13:00 +00:00

183 lines
4.7 KiB
Go

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
}