package store import ( "database/sql" "errors" "fmt" "github.com/google/uuid" ) // dbExec is the subset of *sql.DB and *sql.Tx used by the sync helpers so // CRUD callers can pass in either a transaction or the raw DB handle. Keeps // the sync logic atomic with the parent row when wrapped in a Begin/Commit. type dbExec interface { Exec(query string, args ...any) (sql.Result, error) QueryRow(query string, args ...any) *sql.Row } // syncWorkloadTx is the shared upsert path used by every kind-specific // sync helper. Caller passes the kind, ref, and the projection of fields // that map onto the workload row. Idempotent — uses the (kind, ref_id) UNIQUE // constraint to decide INSERT vs UPDATE. func syncWorkloadTx(ex dbExec, kind WorkloadKind, refID, name, notifURL, notifSecret, hookSecret, signSecret string, requireSig bool) error { now := Now() requireInt := 0 if requireSig { requireInt = 1 } var existingID string err := ex.QueryRow( `SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`, string(kind), refID, ).Scan(&existingID) if errors.Is(err, sql.ErrNoRows) { _, err := ex.Exec( `INSERT INTO workloads (id, kind, ref_id, name, app_id, notification_url, notification_secret, webhook_secret, webhook_signing_secret, webhook_require_signature, created_at, updated_at) VALUES (?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?)`, uuid.New().String(), string(kind), refID, name, notifURL, notifSecret, hookSecret, signSecret, requireInt, now, now, ) if err != nil { return fmt.Errorf("insert %s workload: %w", kind, err) } return nil } if err != nil { return fmt.Errorf("lookup %s workload: %w", kind, err) } _, err = ex.Exec( `UPDATE workloads SET name=?, notification_url=?, notification_secret=?, webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?, updated_at=? WHERE id=?`, name, notifURL, notifSecret, hookSecret, signSecret, requireInt, now, existingID, ) if err != nil { return fmt.Errorf("update %s workload: %w", kind, err) } return nil } // SyncProjectWorkloadTx upserts the workload row paired with a project inside // the caller's transaction. Used by CreateProject / UpdateProject / // SetProject*Secret so the parent UPDATE and the workload sync share atomicity. func SyncProjectWorkloadTx(tx *sql.Tx, p Project) error { return syncWorkloadTx(tx, WorkloadKindProject, p.ID, p.Name, p.NotificationURL, p.NotificationSecret, p.WebhookSecret, p.WebhookSigningSecret, p.WebhookRequireSignature) } // SyncStackWorkloadTx upserts the workload row paired with a stack inside the // caller's transaction. Stacks don't carry notification or webhook config yet. func SyncStackWorkloadTx(tx *sql.Tx, st Stack) error { return syncWorkloadTx(tx, WorkloadKindStack, st.ID, st.Name, "", "", "", "", false) } // SyncStaticSiteWorkloadTx upserts the workload row paired with a static site // inside the caller's transaction. func SyncStaticSiteWorkloadTx(tx *sql.Tx, site StaticSite) error { return syncWorkloadTx(tx, WorkloadKindSite, site.ID, site.Name, site.NotificationURL, site.NotificationSecret, site.WebhookSecret, site.WebhookSigningSecret, site.WebhookRequireSignature) } // SyncProjectWorkload is the non-transactional convenience used by // BackfillWorkloads (a boot-time, single-row, idempotent recovery pass). // CRUD paths must use SyncProjectWorkloadTx instead, with their parent // UPDATE inside the same transaction. func (s *Store) SyncProjectWorkload(p Project) error { return syncWorkloadTx(s.db, WorkloadKindProject, p.ID, p.Name, p.NotificationURL, p.NotificationSecret, p.WebhookSecret, p.WebhookSigningSecret, p.WebhookRequireSignature) } // SyncStackWorkload is the non-transactional convenience used by BackfillWorkloads. func (s *Store) SyncStackWorkload(st Stack) error { return syncWorkloadTx(s.db, WorkloadKindStack, st.ID, st.Name, "", "", "", "", false) } // SyncStaticSiteWorkload is the non-transactional convenience used by BackfillWorkloads. func (s *Store) SyncStaticSiteWorkload(site StaticSite) error { return syncWorkloadTx(s.db, WorkloadKindSite, site.ID, site.Name, site.NotificationURL, site.NotificationSecret, site.WebhookSecret, site.WebhookSigningSecret, site.WebhookRequireSignature) } // BackfillWorkloads scans every project / stack / static_site row and ensures // each has a matching workload row. Called once at boot before HTTP starts so // any pre-Workload-refactor data is upgraded transparently. Idempotent. func (s *Store) BackfillWorkloads() error { projects, err := s.GetAllProjects() if err != nil { return fmt.Errorf("backfill: list projects: %w", err) } for _, p := range projects { if err := s.SyncProjectWorkload(p); err != nil { return fmt.Errorf("backfill project %s: %w", p.ID, err) } } stacks, err := s.GetAllStacks() if err != nil { return fmt.Errorf("backfill: list stacks: %w", err) } for _, st := range stacks { if err := s.SyncStackWorkload(st); err != nil { return fmt.Errorf("backfill stack %s: %w", st.ID, err) } } sites, err := s.GetAllStaticSites() if err != nil { return fmt.Errorf("backfill: list static sites: %w", err) } for _, site := range sites { if err := s.SyncStaticSiteWorkload(site); err != nil { return fmt.Errorf("backfill static site %s: %w", site.ID, err) } } return nil }