package fleet import ( "context" "database/sql" "encoding/json" "fmt" "time" "forge-mesh/internal/api/types" "github.com/google/uuid" ) // Store persists fleet host and mining profile state. type Store struct { db *sql.DB } func NewStore(db *sql.DB) *Store { return &Store{db: db} } func (s *Store) UpsertHeartbeat(hb types.HeartbeatPayload) (*types.Host, error) { hostID := hb.HostID if hostID == "" { hostID = uuid.NewString() } hps := hb.EffectiveHashrate() now := time.Now().UTC().Format(time.RFC3339) _, err := s.db.Exec(` INSERT INTO hosts ( id, hostname, fingerprint, status, hashrate, hashrate_hps, current_tier, tier_type, tier_state, last_seen_at, updated_at ) VALUES (?, ?, ?, 'online', ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET hostname = excluded.hostname, fingerprint = COALESCE(NULLIF(excluded.fingerprint, ''), hosts.fingerprint), status = 'online', hashrate = excluded.hashrate, hashrate_hps = excluded.hashrate_hps, current_tier = excluded.current_tier, tier_type = excluded.tier_type, tier_state = excluded.tier_state, last_seen_at = excluded.last_seen_at, updated_at = excluded.updated_at `, hostID, hb.Hostname, hb.Fingerprint, hps, hps, hb.CurrentTier, hb.TierType, hb.TierState, now, now) if err != nil { return nil, fmt.Errorf("upsert host: %w", err) } return s.GetHost(hostID) } func (s *Store) GetHost(id string) (*types.Host, error) { row := s.db.QueryRow(` SELECT id, hostname, fingerprint, phenotype, status, hashrate, hashrate_hps, current_tier, tier_type, tier_state, clearance_level, mining_profile_id, last_seen_at, created_at, updated_at FROM hosts WHERE id = ? `, id) return scanHost(row) } func (s *Store) ListHosts() ([]types.Host, error) { rows, err := s.db.Query(` SELECT id, hostname, fingerprint, phenotype, status, hashrate, hashrate_hps, current_tier, tier_type, tier_state, clearance_level, mining_profile_id, last_seen_at, created_at, updated_at FROM hosts ORDER BY updated_at DESC `) if err != nil { return nil, fmt.Errorf("list hosts: %w", err) } defer rows.Close() var hosts []types.Host for rows.Next() { h, err := scanHost(rows) if err != nil { return nil, err } hosts = append(hosts, *h) } return hosts, rows.Err() } func scanHost(scanner interface { Scan(dest ...any) error }) (*types.Host, error) { var h types.Host var fp, pheno, tierType, tierState, mpID, lastSeen sql.NullString var createdAt, updatedAt string err := scanner.Scan( &h.ID, &h.Hostname, &fp, &pheno, &h.Status, &h.Hashrate, &h.HashrateHps, &h.CurrentTier, &tierType, &tierState, &h.ClearanceLevel, &mpID, &lastSeen, &createdAt, &updatedAt, ) if err != nil { return nil, fmt.Errorf("scan host: %w", err) } if fp.Valid { h.Fingerprint = fp.String } if pheno.Valid { h.Phenotype = pheno.String } if tierType.Valid { h.TierType = tierType.String } if tierState.Valid { h.TierState = tierState.String } if mpID.Valid { h.MiningProfileID = &mpID.String } if lastSeen.Valid { t, _ := time.Parse(time.RFC3339, lastSeen.String) h.LastSeenAt = &t } h.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) h.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) return &h, nil } func (s *Store) MarkOffline(id string) error { now := time.Now().UTC().Format(time.RFC3339) _, err := s.db.Exec(`UPDATE hosts SET status = 'offline', updated_at = ? WHERE id = ?`, now, id) return err } func (s *Store) SaveMiningProfile(ctx context.Context, profile *types.MiningProfile) error { tiersJSON, err := json.Marshal(profile.Tiers) if err != nil { return fmt.Errorf("marshal tiers: %w", err) } policy := 0 if profile.PolicyFromServer { policy = 1 } if profile.CreatedAt.IsZero() { profile.CreatedAt = time.Now().UTC() } profile.UpdatedAt = time.Now().UTC() _, err = s.db.ExecContext(ctx, ` INSERT INTO mining_profiles (id, name, wallet_address, tiers_json, policy_from_server, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, wallet_address = excluded.wallet_address, tiers_json = excluded.tiers_json, policy_from_server = excluded.policy_from_server, updated_at = excluded.updated_at `, profile.ID, profile.Name, profile.WalletAddress, string(tiersJSON), policy, profile.CreatedAt.UTC().Format(time.RFC3339), profile.UpdatedAt.UTC().Format(time.RFC3339)) return err } func (s *Store) GetMiningProfile(ctx context.Context, id string) (*types.MiningProfile, error) { row := s.db.QueryRowContext(ctx, ` SELECT id, name, wallet_address, tiers_json, policy_from_server, created_at, updated_at FROM mining_profiles WHERE id = ? `, id) var p types.MiningProfile var tiersJSON, created, updated sql.NullString var policy int if err := row.Scan(&p.ID, &p.Name, &p.WalletAddress, &tiersJSON, &policy, &created, &updated); err != nil { return nil, fmt.Errorf("get mining profile: %w", err) } if tiersJSON.Valid { _ = json.Unmarshal([]byte(tiersJSON.String), &p.Tiers) } p.PolicyFromServer = policy == 1 if created.Valid { p.CreatedAt, _ = time.Parse(time.RFC3339, created.String) } if updated.Valid { p.UpdatedAt, _ = time.Parse(time.RFC3339, updated.String) } return &p, nil } func (s *Store) AssignMiningProfile(ctx context.Context, hostID, profileID string) error { now := time.Now().UTC().Format(time.RFC3339) res, err := s.db.ExecContext(ctx, ` UPDATE hosts SET mining_profile_id = ?, updated_at = ? WHERE id = ? `, profileID, now, hostID) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return sql.ErrNoRows } return nil } func (s *Store) GetHostMiningProfile(ctx context.Context, hostID string) (*types.MiningProfile, error) { host, err := s.GetHost(hostID) if err != nil { return nil, err } if host.MiningProfileID == nil || *host.MiningProfileID == "" { return nil, sql.ErrNoRows } return s.GetMiningProfile(ctx, *host.MiningProfileID) }