mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-09-21 03:04:23 +00:00
174 lines
4.9 KiB
Go
174 lines
4.9 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
//go:embed migrations/*.sql
|
|
var migrationFiles embed.FS
|
|
|
|
// execQuerier is the slice of pgxpool.Pool / pgx.Conn migrations need; it lets
|
|
// integration tests drive applyMigration over a plain connection.
|
|
type execQuerier interface {
|
|
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
|
}
|
|
|
|
var (
|
|
lineCommentRe = regexp.MustCompile(`(?m)--[^\n]*`)
|
|
concurrentIndexRe = regexp.MustCompile(`(?is)\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\s+(?:IF\s+NOT\s+EXISTS\s+)?("?[\w.]+"?)`)
|
|
)
|
|
|
|
// concurrentIndexName returns the index a CREATE INDEX CONCURRENTLY migration builds.
|
|
func concurrentIndexName(sql string) (string, bool) {
|
|
m := concurrentIndexRe.FindStringSubmatch(lineCommentRe.ReplaceAllString(sql, ""))
|
|
if m == nil {
|
|
return "", false
|
|
}
|
|
return strings.Trim(m[1], `"`), true
|
|
}
|
|
|
|
// isDuplicateRelation reports SQLSTATE 42P07 (relation already exists).
|
|
func isDuplicateRelation(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
return errors.As(err, &pgErr) && pgErr.Code == "42P07"
|
|
}
|
|
|
|
// applyMigration runs one file outside a transaction. An interrupted CREATE INDEX
|
|
// CONCURRENTLY leaves an invalid index that trips 42P07 on retry; drop it and rebuild once.
|
|
func applyMigration(ctx context.Context, db execQuerier, sql string) error {
|
|
_, err := db.Exec(ctx, sql)
|
|
if err == nil || !isDuplicateRelation(err) {
|
|
return err
|
|
}
|
|
name, ok := concurrentIndexName(sql)
|
|
if !ok {
|
|
return err
|
|
}
|
|
ident := pgx.Identifier(strings.Split(name, ".")).Sanitize()
|
|
var valid bool
|
|
if scanErr := db.QueryRow(ctx,
|
|
"SELECT indisvalid FROM pg_index WHERE indexrelid = to_regclass($1)", ident,
|
|
).Scan(&valid); scanErr != nil {
|
|
if errors.Is(scanErr, pgx.ErrNoRows) {
|
|
return err
|
|
}
|
|
return fmt.Errorf("%w (checking index %s: %v)", err, name, scanErr)
|
|
}
|
|
if valid {
|
|
fmt.Printf("index %s already built, recording migration\n", name)
|
|
return nil
|
|
}
|
|
if _, dropErr := db.Exec(ctx, "DROP INDEX CONCURRENTLY IF EXISTS "+ident); dropErr != nil {
|
|
return fmt.Errorf("dropping invalid index %s: %w", name, dropErr)
|
|
}
|
|
fmt.Printf("dropped invalid index %s, rebuilding\n", name)
|
|
_, err = db.Exec(ctx, sql)
|
|
return err
|
|
}
|
|
|
|
func RunMigrations(ctx context.Context, pool *pgxpool.Pool) error {
|
|
_, err := pool.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
filename TEXT PRIMARY KEY,
|
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create schema_migrations: %w", err)
|
|
}
|
|
|
|
// After creating schema_migrations table, check if we need to bootstrap
|
|
var count int
|
|
err = pool.QueryRow(ctx, "SELECT COUNT(*) FROM schema_migrations").Scan(&count)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check migrations count: %w", err)
|
|
}
|
|
if count == 0 {
|
|
// Check if db is already initialized by looking for a known table
|
|
var exists bool
|
|
err = pool.QueryRow(ctx, `
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_name = 'packets'
|
|
)
|
|
`).Scan(&exists)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check existing schema: %w", err)
|
|
}
|
|
if exists {
|
|
// Mark 001 as already applied
|
|
if _, err := pool.Exec(
|
|
ctx,
|
|
"INSERT INTO schema_migrations (filename) VALUES ($1)",
|
|
"001_initial_schema.sql",
|
|
); err != nil {
|
|
return fmt.Errorf("failed to bootstrap migrations: %w", err)
|
|
}
|
|
fmt.Println("bootstrapped existing schema as 001_initial_schema.sql")
|
|
}
|
|
}
|
|
|
|
entries, err := fs.ReadDir(migrationFiles, "migrations")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read migrations: %w", err)
|
|
}
|
|
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return entries[i].Name() < entries[j].Name()
|
|
})
|
|
|
|
for _, entry := range entries {
|
|
if !strings.HasSuffix(entry.Name(), ".sql") {
|
|
continue
|
|
}
|
|
var already bool
|
|
err := pool.QueryRow(
|
|
ctx,
|
|
"SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE filename = $1)",
|
|
entry.Name(),
|
|
).Scan(&already)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check migration %s: %w", entry.Name(), err)
|
|
}
|
|
if already {
|
|
continue
|
|
}
|
|
|
|
sql, err := migrationFiles.ReadFile("migrations/" + entry.Name())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read migration %s: %w", entry.Name(), err)
|
|
}
|
|
|
|
if err := applyMigration(ctx, pool, string(sql)); err != nil {
|
|
return fmt.Errorf("failed to apply migration %s: %w", entry.Name(), err)
|
|
}
|
|
|
|
if _, err := pool.Exec(
|
|
ctx,
|
|
"INSERT INTO schema_migrations (filename) VALUES ($1)",
|
|
entry.Name(),
|
|
); err != nil {
|
|
return fmt.Errorf("failed to record migration %s: %w", entry.Name(), err)
|
|
}
|
|
|
|
fmt.Printf("applied migration: %s\n", entry.Name())
|
|
}
|
|
|
|
return nil
|
|
}
|