diff --git a/internal/store/dialect.go b/internal/store/dialect.go index 2e9ce2c..81aa157 100644 --- a/internal/store/dialect.go +++ b/internal/store/dialect.go @@ -5,10 +5,16 @@ import ( "strconv" ) +type Migration struct { + Version int + SQL string +} + type Dialect interface { DriverName() string CreateTablesSQL() []string - MigrationsSQL() []string + Migrations() []Migration + BaselineVersion() int BoolFalse() string ResetSequenceOnEmpty(db *sql.DB, table string) ImportWipe(tx *sql.Tx) diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 1be31ca..00511dd 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -13,8 +13,9 @@ func NewPostgresStore(connStr string) (*SQLStore, error) { return NewSQLStore("postgres", connStr, &PostgresDialect{}) } -func (d *PostgresDialect) DriverName() string { return "postgres" } -func (d *PostgresDialect) BoolFalse() string { return "FALSE" } +func (d *PostgresDialect) DriverName() string { return "postgres" } +func (d *PostgresDialect) BoolFalse() string { return "FALSE" } +func (d *PostgresDialect) BaselineVersion() int { return 21 } func (d *PostgresDialect) CreateTablesSQL() []string { return []string{ @@ -32,7 +33,8 @@ func (d *PostgresDialect) CreateTablesSQL() []string { method TEXT DEFAULT 'GET', description TEXT DEFAULT '', parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299', dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '', - ignore_tls BOOLEAN DEFAULT FALSE, paused BOOLEAN DEFAULT FALSE + ignore_tls BOOLEAN DEFAULT FALSE, paused BOOLEAN DEFAULT FALSE, + regions TEXT DEFAULT '' )`, `CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, @@ -42,7 +44,8 @@ func (d *PostgresDialect) CreateTablesSQL() []string { `CREATE TABLE IF NOT EXISTS check_history ( id SERIAL PRIMARY KEY, site_id INTEGER NOT NULL, latency_ns BIGINT, - is_up BOOLEAN, checked_at TIMESTAMPTZ DEFAULT NOW() + is_up BOOLEAN, checked_at TIMESTAMPTZ DEFAULT NOW(), + node_id TEXT DEFAULT '' )`, `CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`, `CREATE TABLE IF NOT EXISTS nodes ( @@ -92,29 +95,29 @@ func (d *PostgresDialect) CreateTablesSQL() []string { } } -func (d *PostgresDialect) MigrationsSQL() []string { - return []string{ - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS timeout INTEGER DEFAULT 0", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS method TEXT DEFAULT 'GET'", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS description TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS parent_id INTEGER DEFAULT 0", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS accepted_codes TEXT DEFAULT '200-299'", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_resolve_type TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_server TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS paused BOOLEAN DEFAULT FALSE", - "ALTER TABLE check_history ADD COLUMN IF NOT EXISTS node_id TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN IF NOT EXISTS regions TEXT DEFAULT ''", - "ALTER TABLE check_history ALTER COLUMN checked_at TYPE TIMESTAMPTZ USING checked_at AT TIME ZONE 'UTC'", - "ALTER TABLE nodes ALTER COLUMN last_seen TYPE TIMESTAMPTZ USING last_seen AT TIME ZONE 'UTC'", - "ALTER TABLE logs ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'", - "ALTER TABLE maintenance_windows ALTER COLUMN start_time TYPE TIMESTAMPTZ USING start_time AT TIME ZONE 'UTC'", - "ALTER TABLE maintenance_windows ALTER COLUMN end_time TYPE TIMESTAMPTZ USING end_time AT TIME ZONE 'UTC'", - "ALTER TABLE maintenance_windows ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'", - "ALTER TABLE state_changes ALTER COLUMN changed_at TYPE TIMESTAMPTZ USING changed_at AT TIME ZONE 'UTC'", - "ALTER TABLE alert_health ALTER COLUMN last_send_at TYPE TIMESTAMPTZ USING last_send_at AT TIME ZONE 'UTC'", +func (d *PostgresDialect) Migrations() []Migration { + return []Migration{ + {1, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''"}, + {2, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0"}, + {3, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS timeout INTEGER DEFAULT 0"}, + {4, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS method TEXT DEFAULT 'GET'"}, + {5, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS description TEXT DEFAULT ''"}, + {6, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS parent_id INTEGER DEFAULT 0"}, + {7, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS accepted_codes TEXT DEFAULT '200-299'"}, + {8, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_resolve_type TEXT DEFAULT ''"}, + {9, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_server TEXT DEFAULT ''"}, + {10, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE"}, + {11, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS paused BOOLEAN DEFAULT FALSE"}, + {12, "ALTER TABLE check_history ADD COLUMN IF NOT EXISTS node_id TEXT DEFAULT ''"}, + {13, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS regions TEXT DEFAULT ''"}, + {14, "ALTER TABLE check_history ALTER COLUMN checked_at TYPE TIMESTAMPTZ USING checked_at AT TIME ZONE 'UTC'"}, + {15, "ALTER TABLE nodes ALTER COLUMN last_seen TYPE TIMESTAMPTZ USING last_seen AT TIME ZONE 'UTC'"}, + {16, "ALTER TABLE logs ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'"}, + {17, "ALTER TABLE maintenance_windows ALTER COLUMN start_time TYPE TIMESTAMPTZ USING start_time AT TIME ZONE 'UTC'"}, + {18, "ALTER TABLE maintenance_windows ALTER COLUMN end_time TYPE TIMESTAMPTZ USING end_time AT TIME ZONE 'UTC'"}, + {19, "ALTER TABLE maintenance_windows ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'"}, + {20, "ALTER TABLE state_changes ALTER COLUMN changed_at TYPE TIMESTAMPTZ USING changed_at AT TIME ZONE 'UTC'"}, + {21, "ALTER TABLE alert_health ALTER COLUMN last_send_at TYPE TIMESTAMPTZ USING last_send_at AT TIME ZONE 'UTC'"}, } } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index ca294e5..53b1489 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -28,8 +28,9 @@ func NewSQLiteStore(path string) (*SQLStore, error) { return s, nil } -func (d *SQLiteDialect) DriverName() string { return "sqlite" } -func (d *SQLiteDialect) BoolFalse() string { return "0" } +func (d *SQLiteDialect) DriverName() string { return "sqlite" } +func (d *SQLiteDialect) BoolFalse() string { return "0" } +func (d *SQLiteDialect) BaselineVersion() int { return 13 } func (d *SQLiteDialect) CreateTablesSQL() []string { return []string{ @@ -47,7 +48,8 @@ func (d *SQLiteDialect) CreateTablesSQL() []string { method TEXT DEFAULT 'GET', description TEXT DEFAULT '', parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299', dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '', - ignore_tls BOOLEAN DEFAULT 0, paused BOOLEAN DEFAULT 0 + ignore_tls BOOLEAN DEFAULT 0, paused BOOLEAN DEFAULT 0, + regions TEXT DEFAULT '' )`, `CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -57,7 +59,8 @@ func (d *SQLiteDialect) CreateTablesSQL() []string { `CREATE TABLE IF NOT EXISTS check_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, site_id INTEGER NOT NULL, latency_ns INTEGER, - is_up BOOLEAN, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP + is_up BOOLEAN, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP, + node_id TEXT DEFAULT '' )`, `CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`, `CREATE TABLE IF NOT EXISTS nodes ( @@ -107,21 +110,21 @@ func (d *SQLiteDialect) CreateTablesSQL() []string { } } -func (d *SQLiteDialect) MigrationsSQL() []string { - return []string{ - "ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0", - "ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0", - "ALTER TABLE sites ADD COLUMN method TEXT DEFAULT 'GET'", - "ALTER TABLE sites ADD COLUMN description TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN parent_id INTEGER DEFAULT 0", - "ALTER TABLE sites ADD COLUMN accepted_codes TEXT DEFAULT '200-299'", - "ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0", - "ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0", - "ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''", - "ALTER TABLE sites ADD COLUMN regions TEXT DEFAULT ''", +func (d *SQLiteDialect) Migrations() []Migration { + return []Migration{ + {1, "ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''"}, + {2, "ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0"}, + {3, "ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0"}, + {4, "ALTER TABLE sites ADD COLUMN method TEXT DEFAULT 'GET'"}, + {5, "ALTER TABLE sites ADD COLUMN description TEXT DEFAULT ''"}, + {6, "ALTER TABLE sites ADD COLUMN parent_id INTEGER DEFAULT 0"}, + {7, "ALTER TABLE sites ADD COLUMN accepted_codes TEXT DEFAULT '200-299'"}, + {8, "ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''"}, + {9, "ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''"}, + {10, "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0"}, + {11, "ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0"}, + {12, "ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''"}, + {13, "ALTER TABLE sites ADD COLUMN regions TEXT DEFAULT ''"}, } } diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index 05eff31..5821225 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -7,7 +7,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "strings" "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" @@ -80,13 +79,34 @@ func (s *SQLStore) Init(ctx context.Context) error { return err } } - for _, m := range s.dialect.MigrationsSQL() { - if _, err := s.db.ExecContext(ctx, m); err != nil { - errMsg := err.Error() - if strings.Contains(errMsg, "already exists") || strings.Contains(errMsg, "duplicate column") { - continue - } - return fmt.Errorf("migration failed: %w", err) + + if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); err != nil { + return fmt.Errorf("create schema_version: %w", err) + } + + var current int + _ = s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(version), 0) FROM schema_version").Scan(¤t) //nolint:errcheck + + if current == 0 { + baseline := s.dialect.BaselineVersion() + if _, err := s.db.ExecContext(ctx, s.q("INSERT INTO schema_version (version) VALUES (?)"), baseline); err != nil { + return fmt.Errorf("seed baseline version: %w", err) + } + current = baseline + } + + for _, m := range s.dialect.Migrations() { + if m.Version <= current { + continue + } + if _, err := s.db.ExecContext(ctx, m.SQL); err != nil { + return fmt.Errorf("migration %d failed: %w", m.Version, err) + } + if _, err := s.db.ExecContext(ctx, s.q("INSERT INTO schema_version (version) VALUES (?)"), m.Version); err != nil { + return fmt.Errorf("record migration %d: %w", m.Version, err) } } return nil @@ -325,8 +345,10 @@ func (s *SQLStore) UpdateAlert(ctx context.Context, id int, name, aType string, } func (s *SQLStore) DeleteAlert(ctx context.Context, id int) error { - _, err := s.db.ExecContext(ctx, s.q("DELETE FROM alerts WHERE id=?"), id) - if err != nil { + if _, err := s.db.ExecContext(ctx, s.q("UPDATE sites SET alert_id = 0 WHERE alert_id = ?"), id); err != nil { + return err + } + if _, err := s.db.ExecContext(ctx, s.q("DELETE FROM alerts WHERE id=?"), id); err != nil { return err } s.dialect.ResetSequenceOnEmpty(s.db, "alerts")