package store import ( "context" "database/sql" "encoding/json" "fmt" "time" ) // Snapshot mirrors the snapshots projection table. type Snapshot struct { ID string HostID string ShortID string Time time.Time Hostname string Paths []string Tags []string SizeBytes int64 FileCount int64 RefreshedAt time.Time } // ReplaceHostSnapshots atomically replaces the snapshot projection for // one host. Snapshots are reported by the agent in full after each // successful backup, so we treat the message as the new source of // truth and delete-then-insert under one transaction. // // snapshot_count on the host row is updated in the same tx so the // dashboard's per-host count is always consistent with the snapshot // list the host detail page renders. func (s *Store) ReplaceHostSnapshots(ctx context.Context, hostID string, snaps []Snapshot, when time.Time) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("store: begin snapshots tx: %w", err) } defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, `DELETE FROM snapshots WHERE host_id = ?`, hostID); err != nil { return fmt.Errorf("store: clear snapshots for host: %w", err) } if len(snaps) > 0 { stmt, err := tx.PrepareContext(ctx, `INSERT INTO snapshots ( id, host_id, short_id, time, hostname, paths, tags, size_bytes, file_count, refreshed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) if err != nil { return fmt.Errorf("store: prepare snapshot insert: %w", err) } defer stmt.Close() refreshed := when.UTC().Format(time.RFC3339Nano) for _, snap := range snaps { paths, _ := json.Marshal(snap.Paths) tags, _ := json.Marshal(snap.Tags) if _, err := stmt.ExecContext(ctx, snap.ID, hostID, snap.ShortID, snap.Time.UTC().Format(time.RFC3339Nano), snap.Hostname, string(paths), string(tags), snap.SizeBytes, snap.FileCount, refreshed, ); err != nil { return fmt.Errorf("store: insert snapshot %s: %w", snap.ID, err) } } } if _, err := tx.ExecContext(ctx, `UPDATE hosts SET snapshot_count = ? WHERE id = ?`, len(snaps), hostID); err != nil { return fmt.Errorf("store: update host snapshot_count: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("store: commit snapshots: %w", err) } return nil } // ListSnapshotsByHost returns the cached snapshot list for a host, // most-recent first. Empty slice is a normal "no snapshots yet" case. func (s *Store) ListSnapshotsByHost(ctx context.Context, hostID string) ([]Snapshot, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, host_id, short_id, time, hostname, paths, tags, size_bytes, file_count, refreshed_at FROM snapshots WHERE host_id = ? ORDER BY time DESC`, hostID) if err != nil { return nil, fmt.Errorf("store: list snapshots: %w", err) } defer rows.Close() var out []Snapshot for rows.Next() { snap, err := scanSnapshotRow(rows) if err != nil { return nil, err } out = append(out, *snap) } return out, rows.Err() } func scanSnapshotRow(r *sql.Rows) (*Snapshot, error) { var ( snap Snapshot t, refresh string paths, tags string ) if err := r.Scan(&snap.ID, &snap.HostID, &snap.ShortID, &t, &snap.Hostname, &paths, &tags, &snap.SizeBytes, &snap.FileCount, &refresh); err != nil { return nil, fmt.Errorf("store: scan snapshot: %w", err) } snap.Time, _ = time.Parse(time.RFC3339Nano, t) snap.RefreshedAt, _ = time.Parse(time.RFC3339Nano, refresh) if paths != "" { _ = json.Unmarshal([]byte(paths), &snap.Paths) } if tags != "" { _ = json.Unmarshal([]byte(tags), &snap.Tags) } return &snap, nil }