Domain names become an allocatable resource with one authority. A new core module (schema `domains`, own migration stream between core and the integrations) owns claims — a DNS node plus its whole subtree, mutually disjoint: operator shared-domain roots, member claims carved from them, and bring-your-own names proven by TXT verification — and placements, which bind a name inside a claim to a provider slug and resource ref. Verification moves to the claim and decouples from creation. A member proves control of a domain once; afterwards every name inside it places instantly, wildcard-CNAME friendly, with no further DNS work. The claim workflow activates the claim and stops — it no longer creates a site — so the sites list offers a one-click create once a domain verifies. /domains/ask answers from placements and is registered by core rather than the FedWiki adapter; its HTTP contract is unchanged. A configured `domains-ask-fallback-url` forwards names the registry does not know to a legacy answerer, the strangler seam wiki.cafe's migration needs; a name the registry knows but has archived is refused locally. FedWiki's create saga reserves the name before the farm call, carrying a workflow-minted site id so retries are idempotent, and compensates on failure. Sync places only names it owns, never stealing a member's; lifecycle transitions and the retention purge maintain servability. An unconditional boot pass seeds operator roots, releases orphaned placements, and adopts pre-existing sites — grandfathering member-owned external domains shortest-name-first, and skipping name policy, so a live single-letter site cannot lose its certificate. Members manage domains at /domains: claims with verification status, DNS records including an optional wildcard row, check-now, cancel, release. Name policy (reserved, blocked, premium, plus a single-letter guard) is operator data; refusals collapse to a plain "unavailable" so the console never becomes an oracle for who holds what. BREAKING (pre-release): `fedwiki.custom_domain_verifications` and `sites.is_custom_domain` are dropped, the flag now derived from the placement's claim kind; resource key `fedwiki_custom_domains` migrates to the platform-owned `external_domain_claims`; running verify-custom-domain workflows must be terminated before deploy.
142 lines
4.6 KiB
Go
142 lines
4.6 KiB
Go
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"},
|
|
}
|
|
}
|