d52f60c54e
- Add CalendarEvents method to recordingAPI in auth_test.go - Add CalendarEvents method to fakeCore in handlers_test.go Co-Authored-By: opencode <opencode@anthropic.com>
257 lines
8.5 KiB
Go
257 lines
8.5 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// WriteFact appends a fact row. confidence must be 1.0 for taps and (0,1) for
|
|
// inferences; the caller is responsible for that provenance discipline.
|
|
//
|
|
// If voidsID is Valid, this row voids (cancels) the referenced fact. The
|
|
// caller must have verified voidsID points at an existing fact — we check it
|
|
// here too and refuse to write a dangling voids pointer (the audit trail must
|
|
// stay coherent).
|
|
func (s *Store) WriteFact(ctx context.Context, ts time.Time, kind FactKind, key, value, source string, confidence float64, voidsID sql.NullInt64) (int64, error) {
|
|
if confidence <= 0.0 || confidence > 1.0 {
|
|
return 0, fmt.Errorf("%w: %f", ErrConfidence, confidence)
|
|
}
|
|
if voidsID.Valid {
|
|
var ok int64
|
|
err := s.db.QueryRowContext(ctx, "SELECT 1 FROM facts WHERE id = ?", voidsID.Int64).Scan(&ok)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, fmt.Errorf("%w: id=%d", ErrVoidsMissing, voidsID.Int64)
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("voids lookup: %w", err)
|
|
}
|
|
}
|
|
res, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id) VALUES (?,?,?,?,?,?,?)`,
|
|
ts.UnixMilli(), string(kind), key, value, source, confidence, voidsID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("write fact: %w", err)
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("last insert id: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// LatestFact returns the latest non-voided fact for key, or ErrNoFact.
|
|
// "Non-voided" = no later row has voids_id pointing at it. We resolve this by
|
|
// taking the newest row whose id is not referenced by any voids_id.
|
|
func (s *Store) LatestFact(ctx context.Context, key string) (Fact, error) {
|
|
row := s.db.QueryRowContext(ctx, `
|
|
SELECT id, ts, kind, key, value, source, confidence, voids_id
|
|
FROM facts
|
|
WHERE key = ?
|
|
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
|
|
ORDER BY ts DESC, id DESC
|
|
LIMIT 1`, key)
|
|
return scanFact(row)
|
|
}
|
|
|
|
// RecentFacts — the newest n facts across all keys, for the monitoring dash.
|
|
// Includes voided rows (the audit trail is the point: you want to SEE a
|
|
// correction, not have it hidden). Newest first.
|
|
func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT id, ts, kind, key, value, source, confidence, voids_id
|
|
FROM facts ORDER BY ts DESC, id DESC LIMIT ?`, n)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("recent facts: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []Fact
|
|
for rows.Next() {
|
|
f, err := scanFact(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// CalendarEvents returns caldav facts whose key date falls within [from, to).
|
|
// Calendar event keys have the format calendar_event_YYYYMMDD_<summary>.
|
|
func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
|
prefixFrom := fmt.Sprintf("calendar_event_%s", from.Format("20060102"))
|
|
prefixTo := fmt.Sprintf("calendar_event_%s", to.Format("20060102"))
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT id, ts, kind, key, value, source, confidence, voids_id
|
|
FROM facts
|
|
WHERE source = 'poll:caldav'
|
|
AND key >= ? AND key < ?
|
|
ORDER BY key`, prefixFrom, prefixTo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("calendar events: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []Fact
|
|
for rows.Next() {
|
|
f, err := scanFact(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// LatestFactBySource — provenance-scoped. A rule on `service_down` trusts only
|
|
// source=poll:healthcheck; a compromised poller can't forge a trigger. Use this
|
|
// from rules, not LatestFact, whenever the rule's source contract matters.
|
|
func (s *Store) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
|
|
row := s.db.QueryRowContext(ctx, `
|
|
SELECT id, ts, kind, key, value, source, confidence, voids_id
|
|
FROM facts
|
|
WHERE key = ? AND source = ?
|
|
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
|
|
ORDER BY ts DESC, id DESC
|
|
LIMIT 1`, key, source)
|
|
return scanFact(row)
|
|
}
|
|
|
|
// Since returns how long ago the latest non-voided fact for key landed, or
|
|
// (0, ErrNoFact). Implements the `since(key)==null → don't fire` guard from
|
|
// the spec — silence on no-data is "shuts up when uncertain".
|
|
func (s *Store) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
|
|
f, err := s.LatestFact(ctx, key)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if now.Before(f.Ts) {
|
|
return 0, nil
|
|
}
|
|
return now.Sub(f.Ts), nil
|
|
}
|
|
|
|
// SetValue is a convenience for writing a structured (json) value at confidence 1.0
|
|
// from a tap. Self-taps (water, meal, shower) land here. Caller supplies source
|
|
// like "tap:water"; we serialize the value.
|
|
func (s *Store) SetValue(ctx context.Context, kind FactKind, key, source string, value any, ts time.Time) (int64, error) {
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("marshal value: %w", err)
|
|
}
|
|
return s.WriteFact(ctx, ts, kind, key, string(raw), source, 1.0, sql.NullInt64{})
|
|
}
|
|
|
|
// CorrectValue voids the latest non-voided fact for key and writes a replacement
|
|
// in one transaction. Use this for "you corrected a bad fact" feedback — keeps
|
|
// the audit trail, supersedes the wrong value.
|
|
func (s *Store) CorrectValue(ctx context.Context, key, source string, value any, ts time.Time) (newID int64, err error) {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer func() {
|
|
if err != nil {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
var oldID int64
|
|
err = tx.QueryRowContext(ctx, `
|
|
SELECT id FROM facts
|
|
WHERE key = ?
|
|
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
|
|
ORDER BY ts DESC, id DESC LIMIT 1`, key).Scan(&oldID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
// nothing to correct; write as a plain new fact instead — no voiding needed.
|
|
} else if err != nil {
|
|
return 0, fmt.Errorf("correct: find old: %w", err)
|
|
}
|
|
raw, merr := json.Marshal(value)
|
|
if merr != nil {
|
|
return 0, fmt.Errorf("marshal value: %w", merr)
|
|
}
|
|
var voids sql.NullInt64
|
|
if oldID != 0 {
|
|
voids = sql.NullInt64{Int64: oldID, Valid: true}
|
|
}
|
|
res, err := tx.ExecContext(ctx,
|
|
`INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id) VALUES (?,?,?,?,?,?,?)`,
|
|
ts.UnixMilli(), string(KindSelf), key, string(raw), source, 1.0, voids)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("write corrected: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, err
|
|
}
|
|
newID, err = res.LastInsertId()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("last insert id: %w", err)
|
|
}
|
|
return newID, nil
|
|
}
|
|
|
|
// VoidLatestFact voids the latest non-voided fact for key. It writes a new
|
|
// fact with voids_id pointing at the old one, keeping the audit trail intact.
|
|
// Returns the voided fact's ID and the new void-marker fact's ID.
|
|
// If no fact exists for the key, returns ErrNoFact.
|
|
func (s *Store) VoidLatestFact(ctx context.Context, key, source string, ts time.Time) (oldID, newID int64, err error) {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
defer func() {
|
|
if err != nil {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
err = tx.QueryRowContext(ctx, `
|
|
SELECT id FROM facts
|
|
WHERE key = ?
|
|
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
|
|
ORDER BY ts DESC, id DESC LIMIT 1`, key).Scan(&oldID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, 0, ErrNoFact
|
|
}
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("void: find latest: %w", err)
|
|
}
|
|
res, err := tx.ExecContext(ctx,
|
|
`INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id) VALUES (?,?,?,?,?,?,?)`,
|
|
ts.UnixMilli(), string(KindSelf), key, `"voided"`, source, 1.0,
|
|
sql.NullInt64{Int64: oldID, Valid: true})
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("void: write void-marker: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
newID, err = res.LastInsertId()
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("void: last insert id: %w", err)
|
|
}
|
|
return oldID, newID, nil
|
|
}
|
|
|
|
type rowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanFact(r rowScanner) (Fact, error) {
|
|
var f Fact
|
|
var tsMilli int64
|
|
var kind string
|
|
var voids sql.NullInt64
|
|
if err := r.Scan(&f.ID, &tsMilli, &kind, &f.Key, &f.Value, &f.Source, &f.Confidence, &voids); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Fact{}, ErrNoFact
|
|
}
|
|
return Fact{}, err
|
|
}
|
|
f.Ts = time.UnixMilli(tsMilli).UTC()
|
|
f.Kind = FactKind(kind)
|
|
f.VoidsID = voids
|
|
return f, nil
|
|
}
|