183 lines
4.7 KiB
Go
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
|
|
}
|