From 65a83368bf393e2c1d8cf5005816cc89819ff4f1 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 5 Jun 2026 12:55:43 -0400 Subject: [PATCH] fix(store): cascade delete related rows when removing a site DeleteSite now removes maintenance_windows, check_history, and state_changes for the site within a transaction before deleting the site itself. Prevents orphaned rows. Closes #71 --- internal/store/sqlstore.go | 18 +++++++++++- internal/store/sqlstore_test.go | 52 ++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index c053e3e..3b866ef 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -152,10 +152,26 @@ func (s *SQLStore) UpdateSitePaused(id int, paused bool) error { } func (s *SQLStore) DeleteSite(id int) error { - _, err := s.db.Exec(s.q("DELETE FROM sites WHERE id=?"), id) + tx, err := s.db.Begin() if err != nil { return err } + defer func() { _ = tx.Rollback() }() + + for _, q := range []string{ + "DELETE FROM maintenance_windows WHERE monitor_id = ?", + "DELETE FROM check_history WHERE site_id = ?", + "DELETE FROM state_changes WHERE site_id = ?", + "DELETE FROM sites WHERE id = ?", + } { + if _, err := tx.Exec(s.q(q), id); err != nil { + return err + } + } + + if err := tx.Commit(); err != nil { + return err + } s.dialect.ResetSequenceOnEmpty(s.db, "sites") return nil } diff --git a/internal/store/sqlstore_test.go b/internal/store/sqlstore_test.go index b759a7a..ef5956b 100644 --- a/internal/store/sqlstore_test.go +++ b/internal/store/sqlstore_test.go @@ -1,8 +1,10 @@ package store import ( - "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "testing" + "time" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" ) func newTestStore(t *testing.T) *SQLStore { @@ -229,3 +231,51 @@ func TestCheckHistory(t *testing.T) { t.Errorf("expected 1 up record for site 1, got %d", upCount) } } + +func TestDeleteSiteCascade(t *testing.T) { + s := newTestStore(t) + + site := models.Site{Name: "Cascade Test", URL: "https://example.com", Interval: 30} + if err := s.AddSite(site); err != nil { + t.Fatalf("AddSite: %v", err) + } + sites, _ := s.GetSites() + siteID := sites[0].ID + + if err := s.SaveCheck(siteID, 1000, true); err != nil { + t.Fatalf("SaveCheck: %v", err) + } + if err := s.SaveStateChange(siteID, "UP", "DOWN", "timeout"); err != nil { + t.Fatalf("SaveStateChange: %v", err) + } + mw := models.MaintenanceWindow{ + MonitorID: siteID, + Title: "Test MW", + Type: "maintenance", + StartTime: time.Now(), + } + if err := s.AddMaintenanceWindow(mw); err != nil { + t.Fatalf("AddMaintenanceWindow: %v", err) + } + + if err := s.DeleteSite(siteID); err != nil { + t.Fatalf("DeleteSite: %v", err) + } + + history, _ := s.LoadAllHistory(100) + if len(history[siteID]) != 0 { + t.Errorf("expected 0 check_history rows, got %d", len(history[siteID])) + } + + changes, _ := s.GetStateChanges(siteID, 100) + if len(changes) != 0 { + t.Errorf("expected 0 state_changes rows, got %d", len(changes)) + } + + windows, _ := s.GetActiveMaintenanceWindows() + for _, w := range windows { + if w.MonitorID == siteID { + t.Errorf("orphaned maintenance window found: id=%d", w.ID) + } + } +} -- 2.52.0