package store import ( "context" "database/sql" "errors" "fmt" "time" ) // LoadPresenceState — returns the singleton hysteresis row, or a cold-start // default (away, score 0) when no row exists yet. fail-closed. func (s *Store) LoadPresenceState(ctx context.Context) (bucket Bucket, score float64, updated time.Time, err error) { var b string var updMilli int64 row := s.db.QueryRowContext(ctx, `SELECT last_bucket, last_score, updated_ts FROM presence_state WHERE id = 1`) err = row.Scan(&b, &score, &updMilli) if errors.Is(err, sql.ErrNoRows) { return Away, 0.0, time.Time{}, nil } if err != nil { return "", 0, time.Time{}, fmt.Errorf("load presence_state: %w", err) } return Bucket(b), score, time.UnixMilli(updMilli).UTC(), nil } // SavePresenceState — upsert the singleton row. called every tick after resolve hysteresis. func (s *Store) SavePresenceState(ctx context.Context, bucket Bucket, score float64, now time.Time) error { _, err := s.db.ExecContext(ctx, ` INSERT INTO presence_state (id, last_bucket, last_score, updated_ts) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET last_bucket = excluded.last_bucket, last_score = excluded.last_score, updated_ts = excluded.updated_ts`, string(bucket), score, now.UnixMilli()) if err != nil { return fmt.Errorf("save presence_state: %w", err) } return nil } // PresenceProbes — gather the latest non-voided fact ts per signal key. // Returns a SignalProbe slice aligned with PresenceSignals. nil LastTs where // the key has no data. This is the only I/O presence needs; the actual score // computation happens in the pure PresenceScore() function. func (s *Store) PresenceProbes(ctx context.Context) ([]SignalProbe, error) { probes := make([]SignalProbe, 0, len(PresenceSignals)) for _, sig := range PresenceSignals { f, err := s.LatestFact(ctx, sig.Key) if errors.Is(err, ErrNoFact) { probes = append(probes, SignalProbe{Key: sig.Key, LastTs: nil}) continue } if err != nil { return nil, err } t := f.Ts probes = append(probes, SignalProbe{Key: sig.Key, LastTs: &t}) } return probes, nil }