feat: Minstrel can read its irreplaceable data out as JSON Lines and load it back into an empty instance (#6770)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m29s
release / android (push) Successful in 4m41s
release / Build signed APK (push) Successful in 4m52s
release / integration (push) Successful in 5m3s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped

internal/backup names every table a backup holds, in parent-first order,
and every table it leaves out with the reason: what the server computes
from the music files, provider caches it fetches again, sign-ins and
one-time links, and logs of past runs. The library's identity rows travel
because every like, play and playlist entry hangs off a track id, and the
sync change feed travels because the apps' first sync reads it.

Sealed credentials are left out of the file, and so are the markers that
say tag and art enrichment are done, so a restored server asks for the
credentials again and redoes that work rather than believing it has it.

Snapshot reads one REPEATABLE READ, read-only transaction, each table in
primary-key order, keys sorted. Restore refuses an instance with accounts
or tracks and a backup from a newer schema, replaces seeded settings,
inserts by column intersection in batches, skips generated columns, and
carries serial sequences on past the restored ids.

Guards: every schema table is backed up or excluded; every backed-up
table comes after what it references and references nothing excluded;
every omitted column exists; a restore gives back the backup byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 13:43:16 -05:00
co-authored by Claude Opus 5.5
parent a8a2d20307
commit a67897a6cd
5 changed files with 791 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
package backup
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Contents is a backup's data: the schema version that wrote it, and each
// table's rows as JSON objects with their keys sorted, in primary-key order.
// An unchanged row is the same bytes in every backup.
type Contents struct {
Schema int64
Tables map[string][][]byte
}
// Snapshot reads every table in Tables as of one moment.
func Snapshot(ctx context.Context, pool *pgxpool.Pool) (Contents, error) {
c := Contents{Tables: make(map[string][][]byte, len(Tables))}
tx, err := pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
if err != nil {
return c, fmt.Errorf("backup: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if c.Schema, err = schemaVersion(ctx, tx); err != nil {
return c, err
}
for _, t := range Tables {
lines, err := readTable(ctx, tx, t)
if err != nil {
return c, err
}
c.Tables[t.Name] = lines
}
return c, nil
}
func readTable(ctx context.Context, tx pgx.Tx, t Table) ([][]byte, error) {
pk, err := primaryKey(ctx, tx, t.Name)
if err != nil {
return nil, err
}
order := make([]string, len(pk))
for i, col := range pk {
order[i] = "t." + pgx.Identifier{col}.Sanitize()
}
omit := t.Omit
if omit == nil {
omit = []string{}
}
rows, err := tx.Query(ctx, fmt.Sprintf("SELECT to_jsonb(t) - $1::text[] FROM %s t ORDER BY %s",
pgx.Identifier{t.Name}.Sanitize(), strings.Join(order, ", ")), omit)
if err != nil {
return nil, fmt.Errorf("backup: read %s: %w", t.Name, err)
}
lines, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) ([]byte, error) {
var raw []byte
if err := row.Scan(&raw); err != nil {
return nil, err
}
return canonical(raw)
})
if err != nil {
return nil, fmt.Errorf("backup: read %s: %w", t.Name, err)
}
return lines, nil
}
// canonical re-encodes a row with its keys sorted. Numbers pass through as
// written, so a bigint or a float stays exact.
func canonical(raw []byte) ([]byte, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var row map[string]any
if err := dec.Decode(&row); err != nil {
return nil, err
}
return json.Marshal(row)
}
// primaryKey returns a table's primary-key columns in key order.
func primaryKey(ctx context.Context, tx pgx.Tx, table string) ([]string, error) {
rows, err := tx.Query(ctx, `
SELECT a.attname
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey)
WHERE i.indrelid = to_regclass($1) AND i.indisprimary
ORDER BY array_position(i.indkey, a.attnum)`, table)
if err != nil {
return nil, fmt.Errorf("backup: read %s's primary key: %w", table, err)
}
cols, err := pgx.CollectRows(rows, pgx.RowTo[string])
if err != nil {
return nil, fmt.Errorf("backup: read %s's primary key: %w", table, err)
}
if len(cols) == 0 {
return nil, fmt.Errorf("backup: %s has no primary key to order its rows by", table)
}
return cols, nil
}
func schemaVersion(ctx context.Context, tx pgx.Tx) (int64, error) {
var v int64
if err := tx.QueryRow(ctx, "SELECT version FROM schema_migrations").Scan(&v); err != nil {
return 0, fmt.Errorf("backup: read the schema version: %w", err)
}
return v, nil
}