Files
member-console/internal/db/migrations.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

145 lines
4.7 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package db
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var embedMigrations embed.FS
// MigrationSource pairs a source name with its embedded migration filesystem.
// Sources are registered in dependency order; see internal/migrate/sources.go
// for the ordering contract (core, then core-module streams, then
// integrations in any order).
type MigrationSource struct {
Name string // Source name (e.g., "core", "domains", "fedwiki", "stripe")
Migrations fs.FS // Embedded FS containing migration SQL files
Dir string // Directory path within the FS (e.g., "migrations")
}
// defaultTableName is goose's own default ledger table name. Every entry
// point restores it on exit so it doesn't leak into whichever code runs
// next in the same process.
const defaultTableName = "goose_db_version"
// ledgerTableName returns the per-stream goose version table name, e.g.
// "goose_db_version_core".
func ledgerTableName(sourceName string) string {
return defaultTableName + "_" + sourceName
}
// resetGooseGlobals restores goose's package-level FS/table-name state.
// goose.SetBaseFS and goose.SetTableName mutate process-global state, so
// every function that touches them must reset it before returning.
func resetGooseGlobals() {
goose.SetBaseFS(nil)
goose.SetTableName(defaultTableName)
}
// RunMigrations applies all pending migrations for every source, in order.
// Each source is migrated against its own goose ledger table
// (goose_db_version_<name>), so a source's version numbers are independent
// of its position in the slice and of any other source's migration count.
// The order is tiered: core first (every other schema FKs into core, never
// the reverse), then the core-module streams (domains — integration
// migrations grant on its objects), then the integration streams, which are
// mutually order-independent.
func RunMigrations(database *sql.DB, sources []MigrationSource) error {
defer resetGooseGlobals()
if err := goose.SetDialect("postgres"); err != nil {
return err
}
for _, src := range sources {
goose.SetBaseFS(src.Migrations)
goose.SetTableName(ledgerTableName(src.Name))
if err := goose.Up(database, src.Dir); err != nil {
return fmt.Errorf("running %s migrations: %w", src.Name, err)
}
}
return nil
}
// RollbackMigration rolls back one migration step. It walks sources in
// reverse order and rolls back one step of the first stream (i.e., the
// most-recently-populated one in source order) that has any applied
// migrations — integrations are unwound before the core-module streams,
// which are unwound before core. A stream whose ledger
// table doesn't exist yet is treated as having zero applied migrations
// rather than an error.
func RollbackMigration(database *sql.DB, sources []MigrationSource) error {
defer resetGooseGlobals()
if err := goose.SetDialect("postgres"); err != nil {
return err
}
for i := len(sources) - 1; i >= 0; i-- {
src := sources[i]
goose.SetBaseFS(src.Migrations)
goose.SetTableName(ledgerTableName(src.Name))
// EnsureDBVersion creates the ledger table if it doesn't exist yet
// and reports version 0 in that case, so a not-yet-migrated stream
// is handled gracefully rather than erroring.
version, err := goose.EnsureDBVersion(database)
if err != nil {
return fmt.Errorf("checking %s ledger version: %w", src.Name, err)
}
if version == 0 {
continue
}
if err := goose.Down(database, src.Dir); err != nil {
return fmt.Errorf("rolling back %s migration: %w", src.Name, err)
}
return nil
}
return fmt.Errorf("no migration stream has any applied migrations to roll back")
}
// MigrationStatus prints the goose status of every source's ledger, one
// stream at a time.
func MigrationStatus(database *sql.DB, sources []MigrationSource) error {
defer resetGooseGlobals()
if err := goose.SetDialect("postgres"); err != nil {
return err
}
for _, src := range sources {
fmt.Printf("== %s ==\n", src.Name)
goose.SetBaseFS(src.Migrations)
goose.SetTableName(ledgerTableName(src.Name))
if err := goose.Status(database, src.Dir); err != nil {
return fmt.Errorf("getting %s migration status: %w", src.Name, err)
}
}
return nil
}
// BaseSources returns the core migration source (the db package's own
// embedded migrations, forming the "core" schema namespace). This is the
// foundation — the domains core-module source and the integration sources
// are appended, in that order, by internal/migrate.Sources.
func BaseSources() []MigrationSource {
return []MigrationSource{
{Name: "core", Migrations: embedMigrations, Dir: "migrations"},
}
}