package store import ( "database/sql" "errors" "fmt" ) // InsertDeployHistory appends one row to the per-workload deploy ledger. // Callers (the deployer choke point) treat this as best-effort: a failure // here must never fail an otherwise-successful deploy. Error is expected to // be a fixed, secret-free marker — never the raw source error. func (s *Store) InsertDeployHistory(e DeployHistoryEntry) (DeployHistoryEntry, error) { if e.StartedAt == "" { e.StartedAt = Now() } if e.FinishedAt == "" { e.FinishedAt = Now() } res, err := s.db.Exec( `INSERT INTO deploy_history (workload_id, source_kind, reference, reason, triggered_by, note, outcome, error, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.WorkloadID, e.SourceKind, e.Reference, e.Reason, e.TriggeredBy, e.Note, e.Outcome, e.Error, e.StartedAt, e.FinishedAt, ) if err != nil { return DeployHistoryEntry{}, fmt.Errorf("insert deploy history: %w", err) } id, err := res.LastInsertId() if err != nil { return DeployHistoryEntry{}, fmt.Errorf("get deploy history id: %w", err) } e.ID = id return e, nil } // ListDeployHistory returns a workload's ledger newest-first. limit/offset // are assumed pre-clamped by the API layer; a non-positive limit falls back // to a sane default so a bad query can't return the whole table. func (s *Store) ListDeployHistory(workloadID string, limit, offset int) ([]DeployHistoryEntry, error) { if limit <= 0 { limit = 50 } if offset < 0 { offset = 0 } rows, err := s.db.Query( `SELECT id, workload_id, source_kind, reference, reason, triggered_by, note, outcome, error, started_at, finished_at FROM deploy_history WHERE workload_id = ? ORDER BY id DESC LIMIT ? OFFSET ?`, workloadID, limit, offset, ) if err != nil { return nil, fmt.Errorf("query deploy history: %w", err) } defer rows.Close() out := make([]DeployHistoryEntry, 0, limit) for rows.Next() { var e DeployHistoryEntry if err := rows.Scan( &e.ID, &e.WorkloadID, &e.SourceKind, &e.Reference, &e.Reason, &e.TriggeredBy, &e.Note, &e.Outcome, &e.Error, &e.StartedAt, &e.FinishedAt, ); err != nil { return nil, fmt.Errorf("scan deploy history: %w", err) } out = append(out, e) } return out, rows.Err() } // GetDeployHistory fetches one ledger row by id, or ErrNotFound. The // rollback handler uses this to resolve the pinned reference to replay. func (s *Store) GetDeployHistory(id int64) (DeployHistoryEntry, error) { row := s.db.QueryRow( `SELECT id, workload_id, source_kind, reference, reason, triggered_by, note, outcome, error, started_at, finished_at FROM deploy_history WHERE id = ?`, id, ) var e DeployHistoryEntry err := row.Scan( &e.ID, &e.WorkloadID, &e.SourceKind, &e.Reference, &e.Reason, &e.TriggeredBy, &e.Note, &e.Outcome, &e.Error, &e.StartedAt, &e.FinishedAt, ) if errors.Is(err, sql.ErrNoRows) { return DeployHistoryEntry{}, fmt.Errorf("deploy history %d: %w", id, ErrNotFound) } if err != nil { return DeployHistoryEntry{}, fmt.Errorf("scan deploy history: %w", err) } return e, nil } // PruneDeployHistory keeps only the newest `keep` rows for a workload, // deleting older ones. Bounds unbounded growth on hot workloads. Best- // effort and id-monotonic (newer rows always have larger ids), so it // deletes everything below the keep-th id. A non-positive keep is treated // as "keep a sane default" rather than "delete everything". func (s *Store) PruneDeployHistory(workloadID string, keep int) error { if keep <= 0 { keep = 50 } _, err := s.db.Exec( `DELETE FROM deploy_history WHERE workload_id = ? AND id NOT IN ( SELECT id FROM deploy_history WHERE workload_id = ? ORDER BY id DESC LIMIT ? )`, workloadID, workloadID, keep, ) if err != nil { return fmt.Errorf("prune deploy history: %w", err) } return nil }