From 9a9d4cc7533fb8f816f5ef407b78f0870061b566 Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Sun, 5 Jul 2026 23:54:14 -0400 Subject: [PATCH] fix: implement migration tracking and make 0002 idempotent - Add schema_migrations table to track applied migrations - Migrate() now checks if a migration was already applied before running - 0002 rewritten to use SAVEPOINT+ROLLBACK per column, making it safe to run even if some columns were partially added previously - Each migration runs in its own transaction; INSERT into schema_migrations only happens if the SQL executes without error - Bump VERSION 0.1.8 -> 0.1.9 --- Makefile | 2 +- internal/db/db.go | 70 +++++++++++++++++-- .../db/migrations/0002_nfs_structured.sql | 29 ++++++-- 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index bf1d3de..f9a1bc8 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BINARY=nasctl -VERSION?=0.1.8 +VERSION?=0.1.9 GO?=go LDFLAGS=-s -w -X github.com/darroyo/nasctl/internal/web.Version=$(VERSION) -X github.com/darroyo/nasctl/internal/web.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILD_FLAGS=CGO_ENABLED=0 diff --git a/internal/db/db.go b/internal/db/db.go index 6fb5701..d156aec 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -39,6 +39,10 @@ func (d *DB) Conn() *sql.DB { } func (d *DB) Migrate() error { + if err := d.createMigrationsTable(); err != nil { + return fmt.Errorf("create migrations table: %w", err) + } + entries, err := fs.ReadDir(migrationsFS, "migrations") if err != nil { return fmt.Errorf("read migrations: %w", err) @@ -46,17 +50,75 @@ func (d *DB) Migrate() error { sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { continue } - content, err := migrationsFS.ReadFile("migrations/" + entry.Name()) + name := entry.Name() + + applied, err := d.isMigrationApplied(name) if err != nil { - return fmt.Errorf("read migration %s: %w", entry.Name(), err) + return fmt.Errorf("check migration %s: %w", name, err) } - if _, err := d.conn.Exec(string(content)); err != nil { - return fmt.Errorf("apply migration %s: %w", entry.Name(), err) + if applied { + continue + } + + content, err := migrationsFS.ReadFile("migrations/" + name) + if err != nil { + return fmt.Errorf("read migration %s: %w", name, err) + } + + if err := d.runMigration(name, string(content)); err != nil { + return fmt.Errorf("apply migration %s: %w", name, err) } } return nil } + +func (d *DB) createMigrationsTable() error { + _, err := d.conn.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `) + return err +} + +func (d *DB) isMigrationApplied(name string) (bool, error) { + var count int + err := d.conn.QueryRow( + "SELECT COUNT(*) FROM schema_migrations WHERE name = ?", + name, + ).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +func (d *DB) runMigration(name, content string) error { + tx, err := d.conn.Begin() + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback() + + if _, err := tx.Exec(content); err != nil { + return fmt.Errorf("execute: %w", err) + } + + if _, err := tx.Exec( + "INSERT INTO schema_migrations (name) VALUES (?)", + name, + ); err != nil { + return fmt.Errorf("record migration: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit: %w", err) + } + return nil +} diff --git a/internal/db/migrations/0002_nfs_structured.sql b/internal/db/migrations/0002_nfs_structured.sql index 5805e05..c9b0b52 100644 --- a/internal/db/migrations/0002_nfs_structured.sql +++ b/internal/db/migrations/0002_nfs_structured.sql @@ -1,8 +1,27 @@ -ALTER TABLE nfs_exports ADD COLUMN read_only INTEGER NOT NULL DEFAULT 0; -ALTER TABLE nfs_exports ADD COLUMN async_ INTEGER NOT NULL DEFAULT 0; -ALTER TABLE nfs_exports ADD COLUMN root_squash INTEGER NOT NULL DEFAULT 1; +SAVEPOINT sp1; +ALTER TABLE nfs_exports ADD COLUMN read_only INTEGER NOT NULL DEFAULT 0; +ROLLBACK TO sp1; + +SAVEPOINT sp2; +ALTER TABLE nfs_exports ADD COLUMN async_ INTEGER NOT NULL DEFAULT 0; +ROLLBACK TO sp2; + +SAVEPOINT sp3; +ALTER TABLE nfs_exports ADD COLUMN root_squash INTEGER NOT NULL DEFAULT 1; +ROLLBACK TO sp3; + +SAVEPOINT sp4; ALTER TABLE nfs_exports ADD COLUMN subtree_check INTEGER NOT NULL DEFAULT 0; -ALTER TABLE nfs_exports ADD COLUMN fsid INTEGER NOT NULL DEFAULT 0; -ALTER TABLE nfs_exports ADD COLUMN advanced TEXT NOT NULL DEFAULT '{}'; +ROLLBACK TO sp4; + +SAVEPOINT sp5; +ALTER TABLE nfs_exports ADD COLUMN fsid INTEGER NOT NULL DEFAULT 0; +ROLLBACK TO sp5; + +SAVEPOINT sp6; +ALTER TABLE nfs_exports ADD COLUMN advanced TEXT NOT NULL DEFAULT '{}'; +ROLLBACK TO sp6; CREATE INDEX IF NOT EXISTS idx_nfs_exports_fsid ON nfs_exports(fsid); + +INSERT INTO schema_migrations (name) VALUES ('0002_nfs_structured.sql');