Merge branch 'mai/knuth/phase15-tags-management-unify' fix

This commit is contained in:
mAi
2026-05-15 16:36:50 +02:00
2 changed files with 47 additions and 6 deletions

View File

@@ -16,10 +16,40 @@ var migrations embed.FS
// EmbeddedMigrations exposes the raw embedded FS for tests.
var EmbeddedMigrations = migrations
// ApplyMigrations runs every embedded migration file in lexicographic order.
// Idempotent because each migration is written as CREATE ... IF NOT EXISTS /
// CREATE OR REPLACE / ON CONFLICT DO NOTHING.
// ApplyMigrations runs every embedded migration that has not yet been
// recorded in projax.schema_migrations. Migrations are applied in lexicographic
// filename order; each successful apply inserts a row in schema_migrations so
// subsequent boots short-circuit without re-running. This lets later
// migrations destructively drop columns earlier ones created — historical
// idempotency-on-re-run is no longer required.
func ApplyMigrations(ctx context.Context, pool *pgxpool.Pool) error {
// Bootstrap the tracker. Lives in the projax schema so it shares grants
// with the rest of the surface. CREATE SCHEMA IF NOT EXISTS would fail
// without database-level CREATE; the tracker only creates within a
// schema we already own.
if _, err := pool.Exec(ctx, `
create table if not exists projax.schema_migrations (
name text primary key,
applied_at timestamptz not null default now()
)`); err != nil {
return fmt.Errorf("ensure schema_migrations: %w", err)
}
applied := map[string]struct{}{}
rows, err := pool.Query(ctx, `select name from projax.schema_migrations`)
if err != nil {
return fmt.Errorf("read applied migrations: %w", err)
}
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
rows.Close()
return fmt.Errorf("scan applied: %w", err)
}
applied[n] = struct{}{}
}
rows.Close()
entries, err := migrations.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
@@ -34,6 +64,9 @@ func ApplyMigrations(ctx context.Context, pool *pgxpool.Pool) error {
sort.Strings(names)
for _, name := range names {
if _, ok := applied[name]; ok {
continue
}
body, err := migrations.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read %s: %w", name, err)
@@ -41,6 +74,12 @@ func ApplyMigrations(ctx context.Context, pool *pgxpool.Pool) error {
if _, err := pool.Exec(ctx, string(body)); err != nil {
return fmt.Errorf("apply %s: %w", name, err)
}
if _, err := pool.Exec(ctx,
`insert into projax.schema_migrations (name) values ($1) on conflict (name) do nothing`,
name,
); err != nil {
return fmt.Errorf("record applied %s: %w", name, err)
}
}
return nil
}

View File

@@ -58,11 +58,13 @@ func TestMigrationsAreIdempotent(t *testing.T) {
}
var n int
if err := pool.QueryRow(ctx, `select count(*) from projax.items where 'area' = any(kind) and parent_id is null`).Scan(&n); err != nil {
t.Fatalf("count areas: %v", err)
if err := pool.QueryRow(ctx,
`select count(*) from projax.items where cardinality(parent_ids) = 0`,
).Scan(&n); err != nil {
t.Fatalf("count roots: %v", err)
}
if n < 7 {
t.Fatalf("expected at least 7 seeded areas, got %d", n)
t.Fatalf("expected at least 7 seeded roots, got %d", n)
}
}