Files
webhooker/internal/db/migrations/migrations.go
T
clawbot f69de8460c
check / check (push) Waiting to run
Build every database's schema from embedded SQL files (closes #56)
The main database, each webhook's event database and each archive file
get their schema from SQL files under internal/db/migrations/, one
directory per kind. Opening a file runs, in one transaction, each file
that schema_migrations does not list. GORM stays for queries.
AutoMigrate is gone, tests included.

An event database holds only its own tables now, so the sweep that
removed target rows copied into it is deleted. The association guard
stays: without it a delivery write fails on the missing targets table.
The event totals row is inserted by events/001_schema.sql rather than
on every open.

Model: opus-5-5
2026-10-13 02:00:36 +00:00

146 lines
3.5 KiB
Go

// 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
}