store: return one calendar row per event
CalendarEvents range-scanned the key prefix and returned every historical row, voided ones included. The facts table is append-only and the event key is day plus summary, so moving a standup from 14:00 to 16:00 left two rows under one key. The day plan prints a time per line, so it recited both and told the owner he had two standups. The query now drops voided rows, keeps the latest row within a source, and prefers the best-evidenced source across them, so a notification relay guessing at a meeting cannot displace the calendar read of it. Found in review of #58.
This commit is contained in:
+49
-2
@@ -90,6 +90,13 @@ func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
|||||||
// the same answer. The source stays on each Fact, along with its confidence, so
|
// the same answer. The source stays on each Fact, along with its confidence, so
|
||||||
// the caller can hedge a reading it did not get from a calendar server —
|
// the caller can hedge a reading it did not get from a calendar server —
|
||||||
// filtering by source here would have thrown that judgement away.
|
// filtering by source here would have thrown that judgement away.
|
||||||
|
//
|
||||||
|
// One row per event, not one per write. The facts table is append-only, so a
|
||||||
|
// standup moved from 14:00 to 16:00 leaves two rows under the same key, and the
|
||||||
|
// day plan used to recite both as if the owner had two meetings. Voided rows
|
||||||
|
// are excluded, the latest row wins within a source, and the best-evidenced
|
||||||
|
// source wins across them — a calendar read beats the notification relay that
|
||||||
|
// guessed at the same meeting.
|
||||||
func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
||||||
prefixFrom := calendar.KeyPrefixForDay(from)
|
prefixFrom := calendar.KeyPrefixForDay(from)
|
||||||
prefixTo := calendar.KeyPrefixForDay(to)
|
prefixTo := calendar.KeyPrefixForDay(to)
|
||||||
@@ -104,7 +111,8 @@ func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact,
|
|||||||
FROM facts
|
FROM facts
|
||||||
WHERE source IN (`+placeholders(len(sources))+`)
|
WHERE source IN (`+placeholders(len(sources))+`)
|
||||||
AND key >= ? AND key < ?
|
AND key >= ? AND key < ?
|
||||||
ORDER BY key`, args...)
|
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
|
||||||
|
ORDER BY key, ts, id`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("calendar events: %w", err)
|
return nil, fmt.Errorf("calendar events: %w", err)
|
||||||
}
|
}
|
||||||
@@ -117,7 +125,46 @@ func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact,
|
|||||||
}
|
}
|
||||||
out = append(out, f)
|
out = append(out, f)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return latestPerCalendarKey(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// latestPerCalendarKey reduces the append-only rows for one day to one row per
|
||||||
|
// event key. Input must be ordered by key then oldest-first, so the last row
|
||||||
|
// seen for a key and source is that source's current value.
|
||||||
|
func latestPerCalendarKey(in []Fact) []Fact {
|
||||||
|
type slot struct {
|
||||||
|
bySource map[string]Fact
|
||||||
|
order []string
|
||||||
|
}
|
||||||
|
var keys []string
|
||||||
|
byKey := map[string]*slot{}
|
||||||
|
for _, f := range in {
|
||||||
|
s, ok := byKey[f.Key]
|
||||||
|
if !ok {
|
||||||
|
s = &slot{bySource: map[string]Fact{}}
|
||||||
|
byKey[f.Key] = s
|
||||||
|
keys = append(keys, f.Key)
|
||||||
|
}
|
||||||
|
if _, seen := s.bySource[f.Source]; !seen {
|
||||||
|
s.order = append(s.order, f.Source)
|
||||||
|
}
|
||||||
|
s.bySource[f.Source] = f
|
||||||
|
}
|
||||||
|
out := make([]Fact, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
s := byKey[k]
|
||||||
|
best := s.bySource[s.order[0]]
|
||||||
|
for _, src := range s.order[1:] {
|
||||||
|
if s.bySource[src].Confidence > best.Confidence {
|
||||||
|
best = s.bySource[src]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, best)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// LatestFactBySource — provenance-scoped. A rule on `service_down` trusts only
|
// LatestFactBySource — provenance-scoped. A rule on `service_down` trusts only
|
||||||
|
|||||||
@@ -381,6 +381,66 @@ func TestCalendarEvents(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A rescheduled meeting keeps its key and appends a row. The query must return
|
||||||
|
// the current value, not the history: reciting both told the owner he had two
|
||||||
|
// standups when one had been moved.
|
||||||
|
func TestCalendarEventsReturnsOneRowPerEvent(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
|
||||||
|
const key = "calendar_event_20260803_Standup"
|
||||||
|
|
||||||
|
store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, key,
|
||||||
|
`"Standup @ 14:00-14:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
|
||||||
|
store.WriteFact(ctx, day.Add(16*time.Hour), KindEnv, key,
|
||||||
|
`"Standup @ 16:00-16:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
|
||||||
|
// The notification relay guessed at the same meeting. A calendar read is
|
||||||
|
// better evidence, so the hedged row must not displace it.
|
||||||
|
store.WriteFact(ctx, day.Add(17*time.Hour), KindEnv, key,
|
||||||
|
`"Standup @ 17:00-17:30"`, calendar.SourceAmbient, calendar.AmbientConfidence, sql.NullInt64{})
|
||||||
|
|
||||||
|
events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalendarEvents: %v", err)
|
||||||
|
}
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("got %d rows, want the current one only: %+v", len(events), events)
|
||||||
|
}
|
||||||
|
if events[0].Value != `"Standup @ 16:00-16:30"` {
|
||||||
|
t.Errorf("value = %q, want the latest calendar read", events[0].Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A voided calendar fact is gone, not history to recite.
|
||||||
|
func TestCalendarEventsSkipsVoidedRows(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
|
||||||
|
id, err := store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, "calendar_event_20260803_Cancelled",
|
||||||
|
`"Cancelled @ 14:00-14:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteFact: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.WriteFact(ctx, day.Add(15*time.Hour), KindEnv, "calendar_event_20260803_Cancelled",
|
||||||
|
`"Cancelled @ 14:00-14:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{Int64: id, Valid: true}); err != nil {
|
||||||
|
t.Fatalf("WriteFact void: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalendarEvents: %v", err)
|
||||||
|
}
|
||||||
|
for _, e := range events {
|
||||||
|
if e.ID == id {
|
||||||
|
t.Fatalf("voided row %d came back: %+v", id, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The work calendar arrives as relayed phone notifications, not a CalDAV read
|
// The work calendar arrives as relayed phone notifications, not a CalDAV read
|
||||||
// (Vikunja #126). Those events belong in the same day's answer, and their
|
// (Vikunja #126). Those events belong in the same day's answer, and their
|
||||||
// provenance has to survive the query so the caller can hedge them.
|
// provenance has to survive the query so the caller can hedge them.
|
||||||
|
|||||||
Reference in New Issue
Block a user