package store import ( "database/sql" "errors" "fmt" "github.com/google/uuid" ) const workloadColumns = `id, kind, ref_id, name, app_id, source_kind, source_config, trigger_kind, trigger_config, public_faces, parent_workload_id, notification_url, notification_secret, webhook_secret, webhook_signing_secret, webhook_require_signature, created_at, updated_at` func scanWorkload(scanner interface{ Scan(...any) error }) (Workload, error) { var w Workload err := scanner.Scan( &w.ID, &w.Kind, &w.RefID, &w.Name, &w.AppID, &w.SourceKind, &w.SourceConfig, &w.TriggerKind, &w.TriggerConfig, &w.PublicFaces, &w.ParentWorkloadID, &w.NotificationURL, &w.NotificationSecret, &w.WebhookSecret, &w.WebhookSigningSecret, &w.WebhookRequireSignature, &w.CreatedAt, &w.UpdatedAt, ) return w, err } // CreateWorkload inserts a new workload row. The (Kind, RefID) pair // must be unique; for plugin-native rows (Kind="plugin") the caller // typically leaves RefID empty and we self-reference it to the row's // own ID so the UNIQUE(kind, ref_id) constraint holds for many sibling // plugin workloads. Legacy bridge code that wired ref_id to a // project/stack/site row was deleted in the hard cutover. func (s *Store) CreateWorkload(w Workload) (Workload, error) { if w.ID == "" { w.ID = uuid.New().String() } if w.RefID == "" { w.RefID = w.ID } w.CreatedAt = Now() w.UpdatedAt = w.CreatedAt if w.SourceConfig == "" { w.SourceConfig = "{}" } if w.TriggerConfig == "" { w.TriggerConfig = "{}" } if w.PublicFaces == "" { w.PublicFaces = "[]" } _, err := s.db.Exec( `INSERT INTO workloads (`+workloadColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, w.ID, w.Kind, w.RefID, w.Name, w.AppID, w.SourceKind, w.SourceConfig, w.TriggerKind, w.TriggerConfig, w.PublicFaces, w.ParentWorkloadID, w.NotificationURL, w.NotificationSecret, w.WebhookSecret, w.WebhookSigningSecret, BoolToInt(w.WebhookRequireSignature), w.CreatedAt, w.UpdatedAt, ) if err != nil { return Workload{}, fmt.Errorf("insert workload: %w", err) } return w, nil } // GetWorkloadByID returns a single workload by its ID. func (s *Store) GetWorkloadByID(id string) (Workload, error) { w, err := scanWorkload(s.db.QueryRow( `SELECT `+workloadColumns+` FROM workloads WHERE id = ?`, id, )) if errors.Is(err, sql.ErrNoRows) { return Workload{}, fmt.Errorf("workload %s: %w", id, ErrNotFound) } if err != nil { return Workload{}, fmt.Errorf("query workload: %w", err) } return w, nil } // GetWorkloadByRef returns the workload paired with a given (kind, ref_id). // Returns ErrNotFound if the project/stack/site has no workload row yet // (which means the boot-time backfill hasn't run, or the kind/ref pair is wrong). func (s *Store) GetWorkloadByRef(kind WorkloadKind, refID string) (Workload, error) { w, err := scanWorkload(s.db.QueryRow( `SELECT `+workloadColumns+` FROM workloads WHERE kind = ? AND ref_id = ?`, string(kind), refID, )) if errors.Is(err, sql.ErrNoRows) { return Workload{}, fmt.Errorf("workload (%s,%s): %w", kind, refID, ErrNotFound) } if err != nil { return Workload{}, fmt.Errorf("query workload by ref: %w", err) } return w, nil } // ListWorkloads returns all workloads, optionally filtered by kind. Pass // empty string to get every workload regardless of kind. func (s *Store) ListWorkloads(kind WorkloadKind) ([]Workload, error) { var rows *sql.Rows var err error if kind == "" { rows, err = s.db.Query( `SELECT ` + workloadColumns + ` FROM workloads ORDER BY name`, ) } else { rows, err = s.db.Query( `SELECT `+workloadColumns+` FROM workloads WHERE kind = ? ORDER BY name`, string(kind), ) } if err != nil { return nil, fmt.Errorf("query workloads: %w", err) } defer rows.Close() out := []Workload{} for rows.Next() { w, err := scanWorkload(rows) if err != nil { return nil, fmt.Errorf("scan workload: %w", err) } out = append(out, w) } return out, rows.Err() } // UpdateWorkload updates the mutable fields of a workload (name, app_id, // source/trigger config, public faces, parent chain, notification + webhook // config). Kind and RefID are immutable post-create. func (s *Store) UpdateWorkload(w Workload) error { w.UpdatedAt = Now() if w.SourceConfig == "" { w.SourceConfig = "{}" } if w.TriggerConfig == "" { w.TriggerConfig = "{}" } if w.PublicFaces == "" { w.PublicFaces = "[]" } result, err := s.db.Exec( `UPDATE workloads SET name=?, app_id=?, source_kind=?, source_config=?, trigger_kind=?, trigger_config=?, public_faces=?, parent_workload_id=?, notification_url=?, notification_secret=?, webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?, updated_at=? WHERE id=?`, w.Name, w.AppID, w.SourceKind, w.SourceConfig, w.TriggerKind, w.TriggerConfig, w.PublicFaces, w.ParentWorkloadID, w.NotificationURL, w.NotificationSecret, w.WebhookSecret, w.WebhookSigningSecret, BoolToInt(w.WebhookRequireSignature), w.UpdatedAt, w.ID, ) if err != nil { return fmt.Errorf("update workload: %w", err) } n, err := result.RowsAffected() if err != nil { return fmt.Errorf("rows affected: %w", err) } if n == 0 { return fmt.Errorf("workload %s: %w", w.ID, ErrNotFound) } return nil } // DeleteWorkload removes a workload row. Cascading deletes for FK-backed // child tables (workload_env, workload_volumes, workload_trigger_bindings) // happen via SQLite's ON DELETE CASCADE. The `containers` table doesn't // yet have an FK to workloads (planned migration — see ops notes), so we // drop its rows explicitly here in the same transaction to prevent zombie // container rows from outliving their owning workload. func (s *Store) DeleteWorkload(id string) error { tx, err := s.db.Begin() if err != nil { return fmt.Errorf("begin: %w", err) } defer func() { _ = tx.Rollback() }() // Explicit container cleanup until the FK migration lands. if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, id); err != nil { return fmt.Errorf("delete containers: %w", err) } result, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete workload: %w", err) } n, err := result.RowsAffected() if err != nil { return fmt.Errorf("rows affected: %w", err) } if n == 0 { return fmt.Errorf("workload %s: %w", id, ErrNotFound) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit: %w", err) } return nil } // ListChildrenByParent returns every workload whose parent_workload_id // equals the given id. Used to render the stages chain ("dev → staging // → prod") on /apps/[id] without forcing a separate stages table. // // Returns rows ordered by name for a stable UI. func (s *Store) ListChildrenByParent(parentID string) ([]Workload, error) { if parentID == "" { return []Workload{}, nil } rows, err := s.db.Query( `SELECT `+workloadColumns+` FROM workloads WHERE parent_workload_id = ? ORDER BY name`, parentID, ) if err != nil { return nil, fmt.Errorf("query workload children: %w", err) } defer rows.Close() out := []Workload{} for rows.Next() { w, err := scanWorkload(rows) if err != nil { return nil, fmt.Errorf("scan child workload: %w", err) } out = append(out, w) } return out, rows.Err() } // Workload-level webhook secret accessors (Get/Set/Ensure) were dropped // in the hard legacy cutover: the inbound `/api/webhook/workloads/...` // route is gone. The trigger-split refactor's boot backfill still reads // the `workloads.webhook_secret` column directly via SQL to lift any // pre-cutover embedded secret onto its standalone Trigger row, then the // column is effectively dead. // DeleteWorkloadByRef removes the workload paired with a given (kind, ref_id). // Idempotent — returns nil if no row exists, since the kind-specific Delete // callers don't always know whether a workload row was created. func (s *Store) DeleteWorkloadByRef(kind WorkloadKind, refID string) error { _, err := s.db.Exec( `DELETE FROM workloads WHERE kind = ? AND ref_id = ?`, string(kind), refID, ) if err != nil { return fmt.Errorf("delete workload by ref: %w", err) } return nil }