diff --git a/AGENTS.md b/AGENTS.md index 4f08a0e..469d329 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,15 +14,15 @@ This repository is a Go service that syncs calendar status and exposes availabil ## Architecture - `main.go` loads config, opens Pebble, builds the HTTP mux, and starts the sync loops after startup validation. - `internal/calendar` fetches iCal feeds, parses events, expands recurrences, and extracts feed timezone; `internal/calendar/handler.go` owns the status syncer. -- `internal/availability` fetches and stores the raw availability feed, optionally caches GOV.UK bank holidays, computes free blocks, and serves `/api/availability`. -- `internal/store` owns Pebble persistence for status, events, channels, sync tokens, and the availability and holiday snapshots. +- `internal/availability` fetches and stores the raw availability feed, optionally caches GOV.UK bank holidays, computes free blocks, and serves precomputed `/api/availability` JSON. +- `internal/store` owns Pebble persistence for fixed-key status, availability, holiday, and deployment projections. - `internal/target` defines the status target interface; `internal/github` implements the GitHub target. - `internal/server` wraps `http.Server` with graceful shutdown. - `internal/config` loads defaults, `config.yaml`, and environment variables. ## Sync Flow -- Status sync is enabled only when `status.enabled` is true; it fetches `status.sources.ical.url`, stores events, computes the current active event, and syncs configured targets on `status.sources.ical.interval`. -- Availability sync is enabled only when `availability.enabled` is true; it fetches `availability.sources.ical.url` on `availability.sources.ical.interval`, stores the raw ICS body plus timezone metadata in Pebble, exposes the API, and starts availability targets. +- Status sync is enabled only when `status.enabled` is true; it fetches `status.sources.ical.url`, stores raw/current projections, computes the current active event in memory, and syncs configured targets on `status.sources.ical.interval`. +- Availability sync is enabled only when `availability.enabled` is true; it fetches `availability.sources.ical.url` on `availability.sources.ical.interval`, stores the raw ICS body plus timezone metadata in Pebble, stores a precomputed API response projection, exposes the API, and starts availability targets. - If a feature-level `enabled` flag is false, both fetching and publishing for that feature must stay disabled regardless of nested configuration. - If `availability.suppressions.exclude_england_bank_holidays` is enabled, the app fetches GOV.UK bank holidays once at startup and stores the parsed holiday dates locally. - That startup seed is required for the availability endpoint when holiday exclusion is enabled, so startup should fail if the holiday feed cannot be read or parsed. @@ -42,18 +42,20 @@ This repository is a Go service that syncs calendar status and exposes availabil - Status and availability fetch intervals are separate config values and default to `5m`. - Time blocks come from config and are checked in order. - `availability.suppressions.working_hours.start` defaults to `09:00` and `availability.suppressions.working_hours.end` defaults to `17:50`; together they are treated as weekday working time, not as an availability block. -- Availability data is stored as the fetched raw ICS body plus metadata, not as live network state. +- Status and availability raw data are stored as fetched ICS bodies plus metadata, not as live network state. - When enabled, bank holiday data is fetched from `https://www.gov.uk/bank-holidays.json` at startup and cached in Pebble. - The availability route is disabled when `availability.enabled` is false. - Empty env vars are treated as unset. -- Pebble key design includes: - - `status` for the current status record - - `event:{eventID}` for stored calendar events - - `availability` for the latest raw availability snapshot - - `availability_dirty` for tracking if availability changed since last deploy (stores pending JSON) - - `availability_last_deployed` for tracking the availability entries JSON from the last successful deploy - - `availability_holidays` for the cached England bank holiday snapshot -- The availability snapshot stores the raw ICS body, extracted timezone, and fetch timestamp so computation can happen locally without another network read. +- Pebble key design is documented in `internal/store/README.md` and uses versioned fixed keys: + - `v1:status:current` for the current status record + - `v1:status:raw` for the latest raw status calendar snapshot + - `v1:availability:raw` for the latest raw availability snapshot + - `v1:availability:current` for the precomputed availability API response + - `v1:availability:deploy:dirty` for pending deploy JSON + - `v1:availability:deploy:last` for the last successfully deployed availability JSON + - `v1:availability:holidays:england` for the cached England bank holiday snapshot +- Runtime Pebble reads should be O(1) direct key lookups; avoid iterator-backed reads and prefix scans on request or hot paths. +- Raw calendar snapshots store the ICS body, extracted timezone, and fetch timestamp so computation can happen locally without another network read. - The holiday snapshot stores the raw GOV.UK JSON body, parsed dates, and fetch timestamp so availability can be computed offline. - Status is single-tenant. - Time zone handling should use the feed timezone when available, with UTC fallback. @@ -69,7 +71,7 @@ This repository is a Go service that syncs calendar status and exposes availabil ## Important Implementation Notes - `internal/calendar/ical.go` is the central parser; keep recurrence expansion and date handling there. - Avoid reimplementing iCal line folding or date parsing by hand; keep tests around parser behavior instead. -- Cancelled events are stored but do not count as active. +- Cancelled status events do not count as active. - Availability computation checks today plus the next 9 days and returns the first free configured block per day. - On weekdays, blocks that overlap working hours are suppressed unless the day is a bank holiday and holiday exclusion is enabled. - If `availability.enabled` is true but availability config is incomplete, startup should fail fast. @@ -78,7 +80,7 @@ This repository is a Go service that syncs calendar status and exposes availabil ## Cloudflare Pages auto-deploy - Feature: When `availability.enabled` is true, the application triggers a Cloudflare Pages deployment at a regular interval. -- Change Tracking: To save build minutes, deployments are only triggered if the availability calendar has changed since the last successful deployment. This is managed by the `availability.Syncer`, which compares the current computed availability JSON with the `availability_last_deployed` JSON in Pebble. If a change is detected (including time-based changes), the new JSON is stored in `availability_dirty`, signaling the `Deployer` to trigger a build. +- Change Tracking: To save build minutes, deployments are only triggered if the availability calendar has changed since the last successful deployment. This is managed by the `availability.Syncer`, which compares the current computed availability JSON with the `v1:availability:deploy:last` JSON in Pebble. If a change is detected (including time-based changes), the new JSON is stored in `v1:availability:deploy:dirty`, signaling the `Deployer` to trigger a build. - Config keys: `availability.enabled` (bool), `availability.targets.cloudflare_pages.interval` (Go duration string, e.g., "10m"), `availability.targets.cloudflare_pages.deploy_hook` (Pages Build Hook URL). - Scheduling: Deploys are scheduled to always fall offset by one minute after the hour. Example: with `availability.targets.cloudflare_pages.interval = 10m` deploys occur at HH:01, HH:11, HH:21, ... This reduces the chance of publishing stale calendar events that commonly start at round minutes (e.g., HH:20, HH:30). - Security: Do not commit `availability.targets.cloudflare_pages.deploy_hook` into source control; provide it via `config.yaml` or the `AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK` environment variable. diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 0e4db45..6cd035d 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -4,5 +4,5 @@ description: A Helm chart for Kubernetes type: application -version: 0.2.8 -appVersion: "v0.2.8" +version: 0.2.9 +appVersion: "v0.2.9" diff --git a/internal/availability/availability.go b/internal/availability/availability.go index 4a228d0..aeb8111 100644 --- a/internal/availability/availability.go +++ b/internal/availability/availability.go @@ -88,14 +88,56 @@ func NewProvider(st *store.Store, blocks []Block, workingHours WorkingHours, exc // GetEntries returns current availability entries. func (p *Provider) GetEntries() ([]Entry, error) { - snap, ok, err := p.store.GetAvailabilitySnapshot() + data, err := p.GetEntriesJSON() if err != nil { - return nil, fmt.Errorf("get availability snapshot: %w", err) + return nil, err + } + var entries []Entry + if err := json.Unmarshal(data, &entries); err != nil { + return nil, fmt.Errorf("decode current availability: %w", err) + } + return entries, nil +} + +// GetEntriesJSON returns current availability entries serialized as JSON. +func (p *Provider) GetEntriesJSON() ([]byte, error) { + data, ok, err := p.store.GetAvailabilityCurrent() + if err != nil { + return nil, fmt.Errorf("get current availability: %w", err) } if !ok { return nil, ErrSnapshotNotFound } + return data, nil +} +// RefreshCurrentFromStoredSnapshot recomputes and stores the current API +// response from the latest persisted raw availability snapshot. +func (p *Provider) RefreshCurrentFromStoredSnapshot() error { + snap, ok, err := p.store.GetAvailabilityRawSnapshot() + if err != nil { + return fmt.Errorf("get availability raw snapshot: %w", err) + } + if !ok { + if err := p.store.ClearAvailabilityCurrent(); err != nil { + return fmt.Errorf("clear current availability: %w", err) + } + return ErrSnapshotNotFound + } + + data, err := p.ComputeEntriesJSONFromSnapshot(snap) + if err != nil { + return err + } + if err := p.store.SetAvailabilityCurrent(data); err != nil { + return fmt.Errorf("store current availability: %w", err) + } + return nil +} + +// ComputeEntriesJSONFromSnapshot computes the current API response JSON from a +// raw snapshot. This is intended for sync/startup paths, not request serving. +func (p *Provider) ComputeEntriesJSONFromSnapshot(snap *store.CalendarSnapshot) ([]byte, error) { opts := ComputeOptions{ WorkingHours: p.workingHours, Now: p.nowFunc(), @@ -112,12 +154,7 @@ func (p *Provider) GetEntries() ([]Entry, error) { opts.HolidayDates = holidaySnap.Dates } - return Compute(snap.Body, snap.Timezone, p.blocks, opts) -} - -// GetEntriesJSON returns current availability entries serialized as JSON. -func (p *Provider) GetEntriesJSON() ([]byte, error) { - entries, err := p.GetEntries() + entries, err := Compute(snap.Body, snap.Timezone, p.blocks, opts) if err != nil { return nil, err } diff --git a/internal/availability/availability_test.go b/internal/availability/availability_test.go index 1d63069..a48ded9 100644 --- a/internal/availability/availability_test.go +++ b/internal/availability/availability_test.go @@ -3,6 +3,7 @@ package availability import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -241,15 +242,18 @@ END:VCALENDAR` func TestHandler_AuthorizationAndResponse(t *testing.T) { st := newTestStore(t) blocks := testBlocks(t) - if err := st.SetAvailabilitySnapshot(&store.AvailabilitySnapshot{ + if err := st.SetAvailabilityRawSnapshot(&store.CalendarSnapshot{ Body: baseCalendarBody(), Timezone: "Europe/London", FetchedAt: time.Now().UTC(), }); err != nil { - t.Fatalf("SetAvailabilitySnapshot: %v", err) + t.Fatalf("SetAvailabilityRawSnapshot: %v", err) } p := NewProvider(st, blocks, testWorkingHours(t), false) + if err := p.RefreshCurrentFromStoredSnapshot(); err != nil { + t.Fatalf("RefreshCurrentFromStoredSnapshot: %v", err) + } h := NewHandler(p, "secret", zerolog.Nop()) t.Run("unauthorized", func(t *testing.T) { @@ -287,43 +291,62 @@ func TestHandler_AuthorizationAndResponse(t *testing.T) { }) } -func TestHandler_HolidaySnapshotRequiredWhenEnabled(t *testing.T) { +func TestProvider_HolidaySnapshotRequiredWhenRefreshing(t *testing.T) { st := newTestStore(t) blocks := testBlocks(t) - if err := st.SetAvailabilitySnapshot(&store.AvailabilitySnapshot{ + if err := st.SetAvailabilityRawSnapshot(&store.CalendarSnapshot{ Body: baseCalendarBody(), Timezone: "Europe/London", FetchedAt: time.Now().UTC(), }); err != nil { - t.Fatalf("SetAvailabilitySnapshot: %v", err) + t.Fatalf("SetAvailabilityRawSnapshot: %v", err) } p := NewProvider(st, blocks, testWorkingHours(t), true) - h := NewHandler(p, "secret", zerolog.Nop()) + if err := p.RefreshCurrentFromStoredSnapshot(); !errors.Is(err, ErrSnapshotNotFound) { + t.Fatalf("RefreshCurrentFromStoredSnapshot error: got %v, want %v", err, ErrSnapshotNotFound) + } +} + +func TestHandler_ServesPrecomputedAvailability(t *testing.T) { + st := newTestStore(t) + current := []byte(`[{"day_of_week":"Monday","block":"Morning","date":"2026-04-06"}]`) + if err := st.SetAvailabilityCurrent(current); err != nil { + t.Fatalf("SetAvailabilityCurrent: %v", err) + } + + p := NewProvider(st, testBlocks(t), testWorkingHours(t), false) + h := NewHandler(p, "secret", zerolog.Nop()) req := httptest.NewRequest(http.MethodGet, "/api/availability", nil) req.Header.Set("Authorization", "secret") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) - if rec.Code != http.StatusServiceUnavailable { - t.Fatalf("status: got %d, want %d", rec.Code, http.StatusServiceUnavailable) + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d", rec.Code, http.StatusOK) + } + if got := rec.Body.String(); got != string(current) { + t.Fatalf("body: got %s, want %s", got, current) } } func TestProvider_GetEntriesJSON(t *testing.T) { st := newTestStore(t) blocks := testBlocks(t) - if err := st.SetAvailabilitySnapshot(&store.AvailabilitySnapshot{ + if err := st.SetAvailabilityRawSnapshot(&store.CalendarSnapshot{ Body: baseCalendarBody(), Timezone: "Europe/London", FetchedAt: time.Now().UTC(), }); err != nil { - t.Fatalf("SetAvailabilitySnapshot: %v", err) + t.Fatalf("SetAvailabilityRawSnapshot: %v", err) } p := NewProvider(st, blocks, testWorkingHours(t), false) + if err := p.RefreshCurrentFromStoredSnapshot(); err != nil { + t.Fatalf("RefreshCurrentFromStoredSnapshot: %v", err) + } data, err := p.GetEntriesJSON() if err != nil { @@ -339,7 +362,27 @@ func TestProvider_GetEntriesJSON(t *testing.T) { } } -func TestSyncer_StoresAvailabilitySnapshot(t *testing.T) { +func TestProvider_RefreshCurrentFromStoredSnapshotClearsCurrentWhenMissing(t *testing.T) { + st := newTestStore(t) + if err := st.SetAvailabilityCurrent([]byte(`[{"date":"stale"}]`)); err != nil { + t.Fatalf("SetAvailabilityCurrent: %v", err) + } + p := NewProvider(st, testBlocks(t), testWorkingHours(t), false) + + err := p.RefreshCurrentFromStoredSnapshot() + if !errors.Is(err, ErrSnapshotNotFound) { + t.Fatalf("RefreshCurrentFromStoredSnapshot error: got %v, want %v", err, ErrSnapshotNotFound) + } + _, ok, err := st.GetAvailabilityCurrent() + if err != nil { + t.Fatalf("GetAvailabilityCurrent: %v", err) + } + if ok { + t.Fatal("expected stale current availability to be cleared") + } +} + +func TestSyncer_StoresAvailabilityRawSnapshot(t *testing.T) { st := newTestStore(t) blocks := testBlocks(t) p := NewProvider(st, blocks, testWorkingHours(t), false) @@ -352,9 +395,9 @@ func TestSyncer_StoresAvailabilitySnapshot(t *testing.T) { t.Fatalf("syncOnce: %v", err) } - snap, ok, err := st.GetAvailabilitySnapshot() + snap, ok, err := st.GetAvailabilityRawSnapshot() if err != nil { - t.Fatalf("GetAvailabilitySnapshot: %v", err) + t.Fatalf("GetAvailabilityRawSnapshot: %v", err) } if !ok { t.Fatal("expected stored availability snapshot") @@ -364,17 +407,17 @@ func TestSyncer_StoresAvailabilitySnapshot(t *testing.T) { } } -func TestSyncer_PreservesAvailabilitySnapshotOnFetchError(t *testing.T) { +func TestSyncer_PreservesAvailabilityRawSnapshotOnFetchError(t *testing.T) { st := newTestStore(t) blocks := testBlocks(t) p := NewProvider(st, blocks, testWorkingHours(t), false) - seed := &store.AvailabilitySnapshot{ + seed := &store.CalendarSnapshot{ Body: "seed", Timezone: "UTC", FetchedAt: time.Now().UTC(), } - if err := st.SetAvailabilitySnapshot(seed); err != nil { - t.Fatalf("SetAvailabilitySnapshot: %v", err) + if err := st.SetAvailabilityRawSnapshot(seed); err != nil { + t.Fatalf("SetAvailabilityRawSnapshot: %v", err) } s := NewSyncer(st, p, &mockFetchClient{err: context.Canceled}, zerolog.Nop()) @@ -383,9 +426,9 @@ func TestSyncer_PreservesAvailabilitySnapshotOnFetchError(t *testing.T) { t.Fatal("expected syncOnce to fail") } - snap, ok, err := st.GetAvailabilitySnapshot() + snap, ok, err := st.GetAvailabilityRawSnapshot() if err != nil { - t.Fatalf("GetAvailabilitySnapshot: %v", err) + t.Fatalf("GetAvailabilityRawSnapshot: %v", err) } if !ok { t.Fatal("expected snapshot to remain after failure") diff --git a/internal/availability/handler.go b/internal/availability/handler.go index 9fd08a0..5f4adc6 100644 --- a/internal/availability/handler.go +++ b/internal/availability/handler.go @@ -1,14 +1,13 @@ package availability import ( - "encoding/json" "errors" "net/http" "github.com/rs/zerolog" ) -// Handler serves availability entries from the stored snapshot. +// Handler serves precomputed availability entries from the store. type Handler struct { provider *Provider apiKey string @@ -31,7 +30,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - entries, err := h.provider.GetEntries() + data, err := h.provider.GetEntriesJSON() if err != nil { if errors.Is(err, ErrSnapshotNotFound) { http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) @@ -43,7 +42,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(entries); err != nil { - h.logger.Error().Err(err).Msg("encode availability response") + if _, err := w.Write(data); err != nil { + h.logger.Error().Err(err).Msg("write availability response") } } diff --git a/internal/availability/syncer.go b/internal/availability/syncer.go index 8dbac98..1f95f37 100644 --- a/internal/availability/syncer.go +++ b/internal/availability/syncer.go @@ -51,30 +51,46 @@ func (s *Syncer) Run(ctx context.Context, interval time.Duration) error { } func (s *Syncer) syncOnce(ctx context.Context) error { + var snap *store.CalendarSnapshot + fetched := false + body, err := s.cal.Fetch(ctx) if err != nil { s.logger.Error().Err(err).Msg("fetch availability calendar failed; using cached snapshot if available") + cached, ok, err := s.store.GetAvailabilityRawSnapshot() + if err != nil { + return fmt.Errorf("get cached availability raw snapshot: %w", err) + } + if !ok { + return ErrSnapshotNotFound + } + snap = cached } else { timezone, err := calendar.ExtractICalendarTimezone(body) if err != nil { return fmt.Errorf("extract availability timezone: %w", err) } - snap := &store.AvailabilitySnapshot{ + snap = &store.CalendarSnapshot{ Body: string(body), Timezone: timezone, FetchedAt: s.nowFunc().UTC(), } - if err := s.store.SetAvailabilitySnapshot(snap); err != nil { - return fmt.Errorf("store availability snapshot: %w", err) - } + fetched = true } // Change detection: compare current entries with last deployed ones. - currentEntries, err := s.provider.GetEntriesJSON() + currentEntries, err := s.provider.ComputeEntriesJSONFromSnapshot(snap) if err != nil { return fmt.Errorf("compute current availability: %w", err) } + if fetched { + if err := s.store.ReplaceAvailabilityProjection(snap, currentEntries); err != nil { + return fmt.Errorf("store availability projection: %w", err) + } + } else if err := s.store.SetAvailabilityCurrent(currentEntries); err != nil { + return fmt.Errorf("store current availability: %w", err) + } lastDeployed, ok, err := s.store.GetLastDeployedAvailability() if err != nil { diff --git a/internal/calendar/client.go b/internal/calendar/client.go index 80be948..8f55056 100644 --- a/internal/calendar/client.go +++ b/internal/calendar/client.go @@ -35,13 +35,21 @@ type ChangedEvent struct { Cancelled bool } -// FetchEvents fetches all events from the iCal URL. -func (c *Client) FetchEvents(ctx context.Context) ([]ChangedEvent, error) { +// Fetch fetches the raw iCal body from the configured URL. +func (c *Client) Fetch(ctx context.Context) ([]byte, error) { body, err := c.feed.Fetch(ctx) if err != nil { return nil, fmt.Errorf("fetch calendar: %w", err) } + return body, nil +} +// FetchEvents fetches and parses events from the iCal URL. +func (c *Client) FetchEvents(ctx context.Context) ([]ChangedEvent, error) { + body, err := c.Fetch(ctx) + if err != nil { + return nil, err + } now := c.nowFunc() parsed, err := ParseICalendar(body, now.Add(-24*time.Hour), now.Add(24*time.Hour)) if err != nil { diff --git a/internal/calendar/handler.go b/internal/calendar/handler.go index 6df563b..d95ffd2 100644 --- a/internal/calendar/handler.go +++ b/internal/calendar/handler.go @@ -13,9 +13,9 @@ import ( "github.com/gldraphael/status/internal/target" ) -// calendarClient defines the interface for fetching calendar events. +// calendarClient defines the interface for fetching raw calendar data. type calendarClient interface { - FetchEvents(ctx context.Context) ([]ChangedEvent, error) + Fetch(ctx context.Context) ([]byte, error) } // Syncer periodically syncs calendar events to configured targets. @@ -53,47 +53,35 @@ func (s *Syncer) Run(ctx context.Context, interval time.Duration) error { } func (s *Syncer) syncOnce(ctx context.Context) error { - // Fetch all events from iCal. - events, err := s.cal.FetchEvents(ctx) + now := s.nowFunc() + body, err := s.cal.Fetch(ctx) if err != nil { - return fmt.Errorf("fetch events: %w", err) - } - - // Store events and sync status. - for _, ev := range events { - stored := &store.Event{ - ID: ev.ID, - Summary: ev.Summary, - StartTime: ev.StartTime, - EndTime: ev.EndTime, - Cancelled: ev.Cancelled, + s.logger.Error().Err(err).Msg("fetch status calendar failed; using cached snapshot if available") + cached, ok, getErr := s.store.GetStatusRawSnapshot() + if getErr != nil { + return fmt.Errorf("get cached status raw snapshot: %w", getErr) + } + if !ok { + return fmt.Errorf("fetch status calendar: %w", err) } - if err := s.store.SetEvent(stored); err != nil { - s.logger.Error().Err(err).Str("eventID", ev.ID).Msg("store event") + events, err := parseStatusEvents([]byte(cached.Body), now) + if err != nil { + return fmt.Errorf("parse cached status raw snapshot: %w", err) } + return s.syncStatus(ctx, events, nil) } - return s.syncStatus(ctx) + raw, events, err := statusRawSnapshot(body, now) + if err != nil { + return err + } + return s.syncStatus(ctx, events, raw) } // syncStatus computes and syncs the current status to all targets. -func (s *Syncer) syncStatus(ctx context.Context) error { +func (s *Syncer) syncStatus(ctx context.Context, events []ChangedEvent, raw *store.CalendarSnapshot) error { now := s.nowFunc() - active, err := s.store.ListActiveEvents(now) - if err != nil { - return fmt.Errorf("list active events: %w", err) - } - - // Build the status to sync (nil = clear). - var st *target.Status - if len(active) > 0 { - ev := active[0] - st = &target.Status{ - Emoji: ":calendar:", - Text: ev.Summary, - Expiration: ev.EndTime, - } - } + st := currentStatus(events, now) // Push to every target, collecting errors. var errs []error @@ -104,19 +92,98 @@ func (s *Syncer) syncStatus(ctx context.Context) error { } } - // Persist the effective status regardless of individual target errors. - if st != nil { - stored := &store.Status{Emoji: st.Emoji, Text: st.Text, Expiration: st.Expiration} - if err := s.store.SetStatus(stored); err != nil { - errs = append(errs, fmt.Errorf("store status: %w", err)) + stored := storeStatus(st) + if raw != nil { + if err := s.store.ReplaceStatusProjection(raw, stored); err != nil { + errs = append(errs, fmt.Errorf("store status projection: %w", err)) } + } else if err := s.store.ReplaceStatusCurrent(stored); err != nil { + errs = append(errs, fmt.Errorf("store status: %w", err)) + } + + if st != nil { s.logger.Info().Str("emoji", st.Emoji).Str("text", st.Text).Time("expiry", st.Expiration).Msg("synced status") } else { - if err := s.store.DeleteStatus(); err != nil { - errs = append(errs, fmt.Errorf("delete status: %w", err)) - } s.logger.Info().Msg("cleared status") } return errors.Join(errs...) } + +func statusRawSnapshot(body []byte, fetchedAt time.Time) (*store.CalendarSnapshot, []ChangedEvent, error) { + parsed, err := ParseICalendar(body, fetchedAt.Add(-24*time.Hour), fetchedAt.Add(24*time.Hour)) + if err != nil { + return nil, nil, fmt.Errorf("parse status calendar: %w", err) + } + return &store.CalendarSnapshot{ + Body: string(body), + Timezone: parsed.Timezone, + FetchedAt: fetchedAt.UTC(), + }, changedEvents(parsed.Events), nil +} + +func parseStatusEvents(body []byte, now time.Time) ([]ChangedEvent, error) { + parsed, err := ParseICalendar(body, now.Add(-24*time.Hour), now.Add(24*time.Hour)) + if err != nil { + return nil, err + } + return changedEvents(parsed.Events), nil +} + +func changedEvents(parsedEvents []ParsedEvent) []ChangedEvent { + events := make([]ChangedEvent, len(parsedEvents)) + for i, ev := range parsedEvents { + events[i] = ChangedEvent{ + ID: ev.ID, + Summary: ev.Summary, + StartTime: ev.StartTime, + EndTime: ev.EndTime, + Cancelled: ev.Cancelled, + } + } + return events +} + +func currentStatus(events []ChangedEvent, now time.Time) *target.Status { + selected := -1 + for i := range events { + ev := events[i] + if ev.Cancelled || ev.StartTime.After(now) || !ev.EndTime.After(now) { + continue + } + if selected == -1 || eventSortsBefore(ev, events[selected]) { + selected = i + } + } + if selected == -1 { + return nil + } + + ev := events[selected] + return &target.Status{ + Emoji: ":calendar:", + Text: ev.Summary, + Expiration: ev.EndTime, + } +} + +func eventSortsBefore(a, b ChangedEvent) bool { + if !a.EndTime.Equal(b.EndTime) { + return a.EndTime.Before(b.EndTime) + } + if !a.StartTime.Equal(b.StartTime) { + return a.StartTime.Before(b.StartTime) + } + return a.ID < b.ID +} + +func storeStatus(st *target.Status) *store.Status { + if st == nil { + return nil + } + return &store.Status{ + Emoji: st.Emoji, + Text: st.Text, + Expiration: st.Expiration, + } +} diff --git a/internal/calendar/handler_test.go b/internal/calendar/handler_test.go index 653e203..87c58ab 100644 --- a/internal/calendar/handler_test.go +++ b/internal/calendar/handler_test.go @@ -2,6 +2,7 @@ package calendar import ( "context" + "strings" "testing" "time" @@ -14,12 +15,15 @@ import ( // --- mocks --- type mockCalendarClient struct { - events []ChangedEvent - err error + body []byte + err error } -func (m *mockCalendarClient) FetchEvents(_ context.Context) ([]ChangedEvent, error) { - return m.events, m.err +func (m *mockCalendarClient) Fetch(_ context.Context) ([]byte, error) { + if m.err != nil { + return nil, m.err + } + return m.body, nil } // mockTarget records the last Sync call for assertions. @@ -49,22 +53,49 @@ func newTestSyncer(t *testing.T, cal calendarClient, targets []target.Target) (* return s, st } +type statusEventSpec struct { + id string + summary string + startTime time.Time + endTime time.Time + cancelled bool +} + +func statusCalendarBody(events ...statusEventSpec) []byte { + var b strings.Builder + b.WriteString("BEGIN:VCALENDAR\n") + b.WriteString("VERSION:2.0\n") + b.WriteString("X-WR-TIMEZONE:UTC\n") + for _, ev := range events { + b.WriteString("BEGIN:VEVENT\n") + b.WriteString("UID:" + ev.id + "\n") + b.WriteString("DTSTART:" + ev.startTime.UTC().Format("20060102T150405Z") + "\n") + b.WriteString("DTEND:" + ev.endTime.UTC().Format("20060102T150405Z") + "\n") + b.WriteString("SUMMARY:" + ev.summary + "\n") + if ev.cancelled { + b.WriteString("STATUS:CANCELLED\n") + } + b.WriteString("END:VEVENT\n") + } + b.WriteString("END:VCALENDAR") + return []byte(b.String()) +} + // --- syncOnce tests --- func TestSyncOnce_SetsStatusFromActiveEvent(t *testing.T) { - now := time.Now() + now := time.Date(2026, 4, 6, 10, 30, 0, 0, time.UTC) cal := &mockCalendarClient{ - events: []ChangedEvent{ - { - ID: "e1", - Summary: "Team Sync", - StartTime: now.Add(-10 * time.Minute), - EndTime: now.Add(50 * time.Minute), - }, - }, + body: statusCalendarBody(statusEventSpec{ + id: "e1", + summary: "Team Sync", + startTime: now.Add(-10 * time.Minute), + endTime: now.Add(50 * time.Minute), + }), } tgt := &mockTarget{} s, st := newTestSyncer(t, cal, []target.Target{tgt}) + s.nowFunc = func() time.Time { return now } if err := s.syncOnce(context.Background()); err != nil { t.Fatal(err) @@ -80,30 +111,32 @@ func TestSyncOnce_SetsStatusFromActiveEvent(t *testing.T) { t.Errorf("status emoji: got %q, want %q", tgt.status.Emoji, ":calendar:") } - // Verify event is stored. - events, err := st.ListActiveEvents(now) + // Verify the fetched raw snapshot is stored. + snap, ok, err := st.GetStatusRawSnapshot() if err != nil { t.Fatal(err) } - if len(events) != 1 || events[0].Summary != "Team Sync" { - t.Errorf("stored event: got %v", events) + if !ok { + t.Fatal("expected status raw snapshot") + } + if !strings.Contains(snap.Body, "SUMMARY:Team Sync") || snap.Timezone != "UTC" { + t.Errorf("stored status raw snapshot: got %+v", snap) } } func TestSyncOnce_ClearsStatusWhenNoActiveEvents(t *testing.T) { - now := time.Now() + now := time.Date(2026, 4, 6, 10, 30, 0, 0, time.UTC) tgt := &mockTarget{} cal := &mockCalendarClient{ - events: []ChangedEvent{ - { - ID: "e1", - Summary: "Past meeting", - StartTime: now.Add(-2 * time.Hour), - EndTime: now.Add(-1 * time.Hour), - }, - }, + body: statusCalendarBody(statusEventSpec{ + id: "e1", + summary: "Past meeting", + startTime: now.Add(-2 * time.Hour), + endTime: now.Add(-1 * time.Hour), + }), } s, _ := newTestSyncer(t, cal, []target.Target{tgt}) + s.nowFunc = func() time.Time { return now } if err := s.syncOnce(context.Background()); err != nil { t.Fatal(err) @@ -118,19 +151,18 @@ func TestSyncOnce_ClearsStatusWhenNoActiveEvents(t *testing.T) { } func TestSyncOnce_MultipleTargets(t *testing.T) { - now := time.Now() + now := time.Date(2026, 4, 6, 10, 30, 0, 0, time.UTC) tgt1, tgt2 := &mockTarget{}, &mockTarget{} cal := &mockCalendarClient{ - events: []ChangedEvent{ - { - ID: "e1", - Summary: "Standup", - StartTime: now.Add(-5 * time.Minute), - EndTime: now.Add(25 * time.Minute), - }, - }, + body: statusCalendarBody(statusEventSpec{ + id: "e1", + summary: "Standup", + startTime: now.Add(-5 * time.Minute), + endTime: now.Add(25 * time.Minute), + }), } s, _ := newTestSyncer(t, cal, []target.Target{tgt1, tgt2}) + s.nowFunc = func() time.Time { return now } if err := s.syncOnce(context.Background()); err != nil { t.Fatal(err) @@ -147,20 +179,19 @@ func TestSyncOnce_MultipleTargets(t *testing.T) { } func TestSyncOnce_CancelledEventsIgnored(t *testing.T) { - now := time.Now() + now := time.Date(2026, 4, 6, 10, 30, 0, 0, time.UTC) tgt := &mockTarget{} cal := &mockCalendarClient{ - events: []ChangedEvent{ - { - ID: "c1", - Summary: "Cancelled meeting", - StartTime: now.Add(-15 * time.Minute), - EndTime: now.Add(45 * time.Minute), - Cancelled: true, - }, - }, + body: statusCalendarBody(statusEventSpec{ + id: "c1", + summary: "Cancelled meeting", + startTime: now.Add(-15 * time.Minute), + endTime: now.Add(45 * time.Minute), + cancelled: true, + }), } s, _ := newTestSyncer(t, cal, []target.Target{tgt}) + s.nowFunc = func() time.Time { return now } if err := s.syncOnce(context.Background()); err != nil { t.Fatal(err) @@ -171,20 +202,53 @@ func TestSyncOnce_CancelledEventsIgnored(t *testing.T) { } } -func TestSyncOnce_StoresPersistentStatus(t *testing.T) { - now := time.Now() +func TestSyncOnce_SelectsEarliestEndingActiveEvent(t *testing.T) { + now := time.Date(2026, 4, 6, 10, 30, 0, 0, time.UTC) tgt := &mockTarget{} cal := &mockCalendarClient{ - events: []ChangedEvent{ - { - ID: "e1", - Summary: "Design Review", - StartTime: now.Add(-15 * time.Minute), - EndTime: now.Add(45 * time.Minute), + body: statusCalendarBody( + statusEventSpec{ + id: "later", + summary: "Later event", + startTime: now.Add(-20 * time.Minute), + endTime: now.Add(60 * time.Minute), }, - }, + statusEventSpec{ + id: "earlier", + summary: "Earlier event", + startTime: now.Add(-10 * time.Minute), + endTime: now.Add(30 * time.Minute), + }, + ), + } + s, _ := newTestSyncer(t, cal, []target.Target{tgt}) + s.nowFunc = func() time.Time { return now } + + if err := s.syncOnce(context.Background()); err != nil { + t.Fatal(err) + } + + if tgt.status == nil { + t.Fatal("expected Sync with non-nil status") + } + if tgt.status.Text != "Earlier event" { + t.Errorf("status text: got %q, want %q", tgt.status.Text, "Earlier event") + } +} + +func TestSyncOnce_StoresPersistentStatus(t *testing.T) { + now := time.Date(2026, 4, 6, 10, 30, 0, 0, time.UTC) + tgt := &mockTarget{} + cal := &mockCalendarClient{ + body: statusCalendarBody(statusEventSpec{ + id: "e1", + summary: "Design Review", + startTime: now.Add(-15 * time.Minute), + endTime: now.Add(45 * time.Minute), + }), } s, st := newTestSyncer(t, cal, []target.Target{tgt}) + s.nowFunc = func() time.Time { return now } if err := s.syncOnce(context.Background()); err != nil { t.Fatal(err) @@ -203,6 +267,70 @@ func TestSyncOnce_StoresPersistentStatus(t *testing.T) { } } +func TestSyncOnce_UsesCachedRawSnapshotOnFetchError(t *testing.T) { + now := time.Date(2026, 4, 6, 10, 30, 0, 0, time.UTC) + body := statusCalendarBody(statusEventSpec{ + id: "cached", + summary: "Cached Status", + startTime: now.Add(-15 * time.Minute), + endTime: now.Add(45 * time.Minute), + }) + cal := &mockCalendarClient{err: context.DeadlineExceeded} + tgt := &mockTarget{} + s, st := newTestSyncer(t, cal, []target.Target{tgt}) + s.nowFunc = func() time.Time { return now } + if err := st.SetStatusRawSnapshot(&store.CalendarSnapshot{ + Body: string(body), + Timezone: "UTC", + FetchedAt: now.Add(-time.Hour), + }); err != nil { + t.Fatalf("SetStatusRawSnapshot: %v", err) + } + + if err := s.syncOnce(context.Background()); err != nil { + t.Fatal(err) + } + + if !tgt.synced || tgt.status == nil { + t.Fatal("expected target sync from cached raw snapshot") + } + if tgt.status.Text != "Cached Status" { + t.Fatalf("status text: got %q, want %q", tgt.status.Text, "Cached Status") + } + stored, ok, err := st.GetStatus() + if err != nil || !ok { + t.Fatalf("status not stored: err=%v ok=%v", err, ok) + } + if stored.Text != "Cached Status" { + t.Fatalf("stored status text: got %q, want %q", stored.Text, "Cached Status") + } +} + +func TestSyncOnce_FetchErrorWithoutCachedRawPreservesCurrentStatus(t *testing.T) { + cal := &mockCalendarClient{err: context.DeadlineExceeded} + tgt := &mockTarget{} + s, st := newTestSyncer(t, cal, []target.Target{tgt}) + now := time.Date(2026, 4, 6, 10, 30, 0, 0, time.UTC) + s.nowFunc = func() time.Time { return now } + if err := st.SetStatus(&store.Status{Emoji: ":calendar:", Text: "Old"}); err != nil { + t.Fatal(err) + } + + if err := s.syncOnce(context.Background()); err == nil { + t.Fatal("expected syncOnce to fail without cached raw snapshot") + } + if tgt.synced { + t.Fatal("expected target not to sync without fetched or cached data") + } + stored, ok, err := st.GetStatus() + if err != nil || !ok { + t.Fatalf("status not stored: err=%v ok=%v", err, ok) + } + if stored.Text != "Old" { + t.Fatalf("stored status changed: got %q, want %q", stored.Text, "Old") + } +} + func TestSyncOnce_DeletesStatusWhenIdle(t *testing.T) { // Seed with a status. st, err := store.New(t.TempDir()) @@ -217,8 +345,10 @@ func TestSyncOnce_DeletesStatusWhenIdle(t *testing.T) { // Sync with no active events should delete the status. tgt := &mockTarget{} - cal := &mockCalendarClient{events: []ChangedEvent{}} + cal := &mockCalendarClient{body: statusCalendarBody()} s := NewSyncer(st, cal, []target.Target{tgt}, zerolog.Nop()) + now := time.Date(2026, 4, 6, 10, 30, 0, 0, time.UTC) + s.nowFunc = func() time.Time { return now } if err := s.syncOnce(context.Background()); err != nil { t.Fatal(err) @@ -236,19 +366,18 @@ func TestSyncOnce_DeletesStatusWhenIdle(t *testing.T) { // --- Run loop tests --- func TestRun_SyncsImmediatelyOnStartup(t *testing.T) { - now := time.Now() + now := time.Date(2026, 4, 6, 10, 30, 0, 0, time.UTC) cal := &mockCalendarClient{ - events: []ChangedEvent{ - { - ID: "e1", - Summary: "Boot Sync", - StartTime: now.Add(-10 * time.Minute), - EndTime: now.Add(50 * time.Minute), - }, - }, + body: statusCalendarBody(statusEventSpec{ + id: "e1", + summary: "Boot Sync", + startTime: now.Add(-10 * time.Minute), + endTime: now.Add(50 * time.Minute), + }), } tgt := &mockTarget{} s, _ := newTestSyncer(t, cal, []target.Target{tgt}) + s.nowFunc = func() time.Time { return now } ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -269,9 +398,11 @@ func TestRun_SyncsImmediatelyOnStartup(t *testing.T) { } func TestRun_ContextCancellationStopsLoop(t *testing.T) { - cal := &mockCalendarClient{events: []ChangedEvent{}} + cal := &mockCalendarClient{body: statusCalendarBody()} tgt := &mockTarget{} s, _ := newTestSyncer(t, cal, []target.Target{tgt}) + now := time.Date(2026, 4, 6, 10, 30, 0, 0, time.UTC) + s.nowFunc = func() time.Time { return now } ctx, cancel := context.WithCancel(context.Background()) go func() { diff --git a/internal/deploy/syncer.go b/internal/deploy/syncer.go index 49d46c9..c16b06d 100644 --- a/internal/deploy/syncer.go +++ b/internal/deploy/syncer.go @@ -81,11 +81,8 @@ func (d *Deployer) triggerIfDirty(ctx context.Context, scheduledAt time.Time) { d.logger.Error().Err(err).Time("scheduled_at", scheduledAt).Msg("deploy failed") } else { d.logger.Info().Time("scheduled_at", scheduledAt).Msg("deploy triggered") - if err := d.store.SetLastDeployedAvailability(dirtyJSON); err != nil { - d.logger.Error().Err(err).Msg("failed to update last deployed availability") - } - if err := d.store.ClearAvailabilityDirty(); err != nil { - d.logger.Error().Err(err).Msg("failed to clear availability dirty flag") + if err := d.store.MarkAvailabilityDeployed(dirtyJSON); err != nil { + d.logger.Error().Err(err).Msg("failed to update availability deployment state") } } } diff --git a/internal/store/README.md b/internal/store/README.md new file mode 100644 index 0000000..a3756ed --- /dev/null +++ b/internal/store/README.md @@ -0,0 +1,44 @@ +# Pebble schema + +The store uses versioned singleton keys. Runtime reads must be direct Pebble +`Get` calls against one known key. Do not add iterator-backed reads or prefix +scans to serve current status, availability, deploy state, or HTTP requests. + +Writes can do slower work. Sync and startup paths are responsible for fetching, +parsing, computing, and replacing projections. + +## Versioning + +All active keys use the `v1:` prefix. Older keys, including previous unversioned +keys and the short-lived `v1:*:source` keys, may remain on disk, but the code +does not read or migrate them. This is intentional because the app does not +require schema backward compatibility. + +## Keys + +| Key | Payload | Written by | Read by | Notes | +| ---------------------------------- | ----------------------- | ----------------------------- | ----------------------------- | ---------------------- | +| `v1:status:current` | JSON `Status` | Status sync | Status callers/tests | Current active status, or absent when idle. | +| `v1:status:raw` | JSON `CalendarSnapshot` | Status sync | Status sync | Raw ICS body, extracted timezone, and fetch timestamp. Used as fallback when a status fetch fails. | +| `v1:availability:raw` | JSON `CalendarSnapshot` | Availability sync | Startup and availability sync | Raw ICS body, extracted timezone, and fetch timestamp. | +| `v1:availability:current` | JSON API response bytes | Startup and availability sync | `/api/availability` | Precomputed response served directly by the handler. | +| `v1:availability:holidays:england` | JSON `HolidaySnapshot` | Startup holiday seed | Availability recomputation | Required when England bank holiday suppression is enabled. | +| `v1:availability:deploy:dirty` | JSON API response bytes | Availability sync | Cloudflare Pages deployer | Pending availability response that has not been successfully deployed. | +| `v1:availability:deploy:last` | JSON API response bytes | Cloudflare Pages deployer | Availability sync | Last availability response successfully deployed. | + +## Atomic updates + +Use Pebble batches when multiple keys represent one logical state transition: + +- Status sync replaces `v1:status:raw` and `v1:status:current` together after a successful fetch. On fetch failure, it may recompute and replace only `v1:status:current` from cached raw data. +- Availability sync replaces `v1:availability:raw` and + `v1:availability:current` together after a successful fetch. +- Deploy success writes `v1:availability:deploy:last` and deletes + `v1:availability:deploy:dirty` together. + +## Startup behavior + +Startup must not wipe the configured Pebble directory. Availability startup +rehydrates `v1:availability:current` from `v1:availability:raw` when a raw +snapshot exists. If no raw snapshot exists, the current availability response +is cleared and the API returns `503` until the first successful sync. diff --git a/internal/store/keys.go b/internal/store/keys.go index 8eacb9d..411db34 100644 --- a/internal/store/keys.go +++ b/internal/store/keys.go @@ -1,51 +1,32 @@ package store -// Key schema: -// status → current status (single-tenant) -// availability → cached availability calendar snapshot -// availability_dirty → flag indicating availability changed since last deploy -// availability_last_deployed → availability entries JSON from the last successful deploy -// availability_holidays → cached England bank holiday snapshot -// event:{eventID} → Google Calendar event state - -func statusKey() []byte { - return []byte("status") -} +// Pebble schema keys are versioned singleton records. See README.md in this +// package for payload ownership, read paths, and durability expectations. -func availabilityKey() []byte { - return []byte("availability") +func statusCurrentKey() []byte { + return []byte("v1:status:current") } -func availabilityDirtyKey() []byte { - return []byte("availability_dirty") +func statusRawKey() []byte { + return []byte("v1:status:raw") } -func availabilityLastDeployedKey() []byte { - return []byte("availability_last_deployed") +func availabilityRawKey() []byte { + return []byte("v1:availability:raw") } -func availabilityHolidaysKey() []byte { - return []byte("availability_holidays") +func availabilityCurrentKey() []byte { + return []byte("v1:availability:current") } -func eventKey(eventID string) []byte { - return []byte("event:" + eventID) +func availabilityHolidaysEnglandKey() []byte { + return []byte("v1:availability:holidays:england") } -func eventKeyPrefix() []byte { - return []byte("event:") +func availabilityDeployDirtyKey() []byte { + return []byte("v1:availability:deploy:dirty") } -// prefixUpperBound returns the smallest key that is lexicographically greater -// than all keys with the given prefix, for use as an iterator upper bound. -func prefixUpperBound(prefix []byte) []byte { - upper := make([]byte, len(prefix)) - copy(upper, prefix) - for i := len(upper) - 1; i >= 0; i-- { - upper[i]++ - if upper[i] != 0 { - return upper[:i+1] - } - } - return nil +func availabilityDeployLastKey() []byte { + return []byte("v1:availability:deploy:last") } diff --git a/internal/store/store.go b/internal/store/store.go index f48bbe7..31cf0f0 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -16,17 +16,8 @@ type Status struct { Expiration time.Time `json:"expiration"` } -// Event represents a Google Calendar event persisted in the store. -type Event struct { - ID string `json:"id"` - Summary string `json:"summary"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` - Cancelled bool `json:"cancelled"` -} - -// AvailabilitySnapshot stores the latest fetched availability calendar data. -type AvailabilitySnapshot struct { +// CalendarSnapshot stores the latest fetched raw calendar feed. +type CalendarSnapshot struct { Body string `json:"body"` Timezone string `json:"timezone"` FetchedAt time.Time `json:"fetched_at"` @@ -88,6 +79,38 @@ func (s *Store) deleteKey(key []byte, label string) error { return nil } +func setBatchJSON(batch *pebble.Batch, key []byte, value any, label string) error { + data, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("marshal %s: %w", label, err) + } + if err := batch.Set(key, data, nil); err != nil { + return fmt.Errorf("set %s: %w", label, err) + } + return nil +} + +func deleteBatchKey(batch *pebble.Batch, key []byte, label string) error { + err := batch.Delete(key, nil) + if err != nil && !errors.Is(err, pebble.ErrNotFound) { + return fmt.Errorf("delete %s: %w", label, err) + } + return nil +} + +func (s *Store) commitBatch(label string, fn func(*pebble.Batch) error) error { + batch := s.db.NewBatch() + defer batch.Close() + + if err := fn(batch); err != nil { + return err + } + if err := batch.Commit(pebble.Sync); err != nil { + return fmt.Errorf("commit %s: %w", label, err) + } + return nil +} + func (s *Store) getJSON(key []byte, value any, label string) (bool, error) { data, ok, err := s.getBytes(key, label) if err != nil || !ok { @@ -110,7 +133,7 @@ func (s *Store) setJSON(key []byte, value any, label string) error { // GetStatus returns the current status (O(1) lookup). func (s *Store) GetStatus() (*Status, bool, error) { var st Status - ok, err := s.getJSON(statusKey(), &st, "status") + ok, err := s.getJSON(statusCurrentKey(), &st, "status") if err != nil || !ok { return nil, ok, err } @@ -119,58 +142,135 @@ func (s *Store) GetStatus() (*Status, bool, error) { // SetStatus persists the current status. func (s *Store) SetStatus(st *Status) error { - return s.setJSON(statusKey(), st, "status") + return s.setJSON(statusCurrentKey(), st, "status") } // DeleteStatus removes the stored status. func (s *Store) DeleteStatus() error { - return s.deleteKey(statusKey(), "status") + return s.deleteKey(statusCurrentKey(), "status") } -// GetAvailabilitySnapshot returns the stored availability calendar snapshot. -func (s *Store) GetAvailabilitySnapshot() (*AvailabilitySnapshot, bool, error) { - var snap AvailabilitySnapshot - ok, err := s.getJSON(availabilityKey(), &snap, "availability snapshot") +// ReplaceStatusCurrent stores or clears the currently active status projection. +func (s *Store) ReplaceStatusCurrent(current *Status) error { + if current == nil { + return s.DeleteStatus() + } + return s.SetStatus(current) +} + +// GetStatusRawSnapshot returns the latest fetched raw status calendar snapshot. +func (s *Store) GetStatusRawSnapshot() (*CalendarSnapshot, bool, error) { + var snap CalendarSnapshot + ok, err := s.getJSON(statusRawKey(), &snap, "status raw snapshot") if err != nil || !ok { return nil, ok, err } return &snap, true, nil } -// SetAvailabilitySnapshot persists the latest availability calendar snapshot. -func (s *Store) SetAvailabilitySnapshot(snap *AvailabilitySnapshot) error { - return s.setJSON(availabilityKey(), snap, "availability snapshot") +// SetStatusRawSnapshot persists the latest raw status calendar snapshot. +func (s *Store) SetStatusRawSnapshot(snap *CalendarSnapshot) error { + return s.setJSON(statusRawKey(), snap, "status raw snapshot") +} + +// ReplaceStatusProjection atomically stores the fetched raw status snapshot +// and the currently active status projection. +func (s *Store) ReplaceStatusProjection(raw *CalendarSnapshot, current *Status) error { + return s.commitBatch("status projection", func(batch *pebble.Batch) error { + if err := setBatchJSON(batch, statusRawKey(), raw, "status raw snapshot"); err != nil { + return err + } + if current == nil { + return deleteBatchKey(batch, statusCurrentKey(), "status") + } + return setBatchJSON(batch, statusCurrentKey(), current, "status") + }) +} + +// GetAvailabilityRawSnapshot returns the stored raw availability calendar snapshot. +func (s *Store) GetAvailabilityRawSnapshot() (*CalendarSnapshot, bool, error) { + var snap CalendarSnapshot + ok, err := s.getJSON(availabilityRawKey(), &snap, "availability raw snapshot") + if err != nil || !ok { + return nil, ok, err + } + return &snap, true, nil +} + +// SetAvailabilityRawSnapshot persists the latest raw availability calendar snapshot. +func (s *Store) SetAvailabilityRawSnapshot(snap *CalendarSnapshot) error { + return s.setJSON(availabilityRawKey(), snap, "availability raw snapshot") +} + +// GetAvailabilityCurrent returns the precomputed availability API response JSON. +func (s *Store) GetAvailabilityCurrent() ([]byte, bool, error) { + return s.getBytes(availabilityCurrentKey(), "current availability") +} + +// SetAvailabilityCurrent persists the precomputed availability API response JSON. +func (s *Store) SetAvailabilityCurrent(data []byte) error { + return s.setBytes(availabilityCurrentKey(), data, "current availability") +} + +// ClearAvailabilityCurrent removes the precomputed availability API response JSON. +func (s *Store) ClearAvailabilityCurrent() error { + return s.deleteKey(availabilityCurrentKey(), "current availability") +} + +// ReplaceAvailabilityProjection atomically stores the fetched raw availability +// snapshot and the precomputed API response JSON. +func (s *Store) ReplaceAvailabilityProjection(raw *CalendarSnapshot, currentJSON []byte) error { + return s.commitBatch("availability projection", func(batch *pebble.Batch) error { + if err := setBatchJSON(batch, availabilityRawKey(), raw, "availability raw snapshot"); err != nil { + return err + } + if err := batch.Set(availabilityCurrentKey(), currentJSON, nil); err != nil { + return fmt.Errorf("set current availability: %w", err) + } + return nil + }) } // GetAvailabilityDirty returns the pending availability entries JSON if the calendar has changed since the last deployment. func (s *Store) GetAvailabilityDirty() ([]byte, bool, error) { - return s.getBytes(availabilityDirtyKey(), "availability dirty") + return s.getBytes(availabilityDeployDirtyKey(), "availability dirty") } // SetAvailabilityDirty persists the pending availability entries JSON. func (s *Store) SetAvailabilityDirty(data []byte) error { - return s.setBytes(availabilityDirtyKey(), data, "availability dirty") + return s.setBytes(availabilityDeployDirtyKey(), data, "availability dirty") } // ClearAvailabilityDirty removes the availability dirty flag. func (s *Store) ClearAvailabilityDirty() error { - return s.deleteKey(availabilityDirtyKey(), "availability dirty") + return s.deleteKey(availabilityDeployDirtyKey(), "availability dirty") } // GetLastDeployedAvailability returns the availability entries JSON from the last successful deployment. func (s *Store) GetLastDeployedAvailability() ([]byte, bool, error) { - return s.getBytes(availabilityLastDeployedKey(), "availability last deployed") + return s.getBytes(availabilityDeployLastKey(), "availability last deployed") } // SetLastDeployedAvailability persists the availability entries JSON from a successful deployment. func (s *Store) SetLastDeployedAvailability(data []byte) error { - return s.setBytes(availabilityLastDeployedKey(), data, "availability last deployed") + return s.setBytes(availabilityDeployLastKey(), data, "availability last deployed") +} + +// MarkAvailabilityDeployed atomically stores the deployed availability JSON and +// clears the pending dirty marker. +func (s *Store) MarkAvailabilityDeployed(data []byte) error { + return s.commitBatch("availability deployment state", func(batch *pebble.Batch) error { + if err := batch.Set(availabilityDeployLastKey(), data, nil); err != nil { + return fmt.Errorf("set availability last deployed: %w", err) + } + return deleteBatchKey(batch, availabilityDeployDirtyKey(), "availability dirty") + }) } // GetHolidaySnapshot returns the stored bank holiday snapshot. func (s *Store) GetHolidaySnapshot() (*HolidaySnapshot, bool, error) { var snap HolidaySnapshot - ok, err := s.getJSON(availabilityHolidaysKey(), &snap, "holiday snapshot") + ok, err := s.getJSON(availabilityHolidaysEnglandKey(), &snap, "holiday snapshot") if err != nil || !ok { return nil, ok, err } @@ -179,49 +279,5 @@ func (s *Store) GetHolidaySnapshot() (*HolidaySnapshot, bool, error) { // SetHolidaySnapshot persists the latest bank holiday snapshot. func (s *Store) SetHolidaySnapshot(snap *HolidaySnapshot) error { - return s.setJSON(availabilityHolidaysKey(), snap, "holiday snapshot") -} - -// GetEvent retrieves a stored calendar event. -func (s *Store) GetEvent(eventID string) (*Event, bool, error) { - var ev Event - ok, err := s.getJSON(eventKey(eventID), &ev, "event") - if err != nil || !ok { - return nil, ok, err - } - return &ev, true, nil -} - -// SetEvent persists a calendar event. -func (s *Store) SetEvent(ev *Event) error { - return s.setJSON(eventKey(ev.ID), ev, "event") -} - -// ListActiveEvents returns events that overlap with now -// (not cancelled, started at or before now, ending after now). -func (s *Store) ListActiveEvents(now time.Time) ([]*Event, error) { - prefix := eventKeyPrefix() - iter, err := s.db.NewIter(&pebble.IterOptions{ - LowerBound: prefix, - UpperBound: prefixUpperBound(prefix), - }) - if err != nil { - return nil, fmt.Errorf("new iter: %w", err) - } - defer iter.Close() - - var active []*Event - for valid := iter.First(); valid; valid = iter.Next() { - var ev Event - if err := json.Unmarshal(iter.Value(), &ev); err != nil { - continue - } - if !ev.Cancelled && !ev.StartTime.After(now) && ev.EndTime.After(now) { - active = append(active, &ev) - } - } - if err := iter.Error(); err != nil { - return nil, fmt.Errorf("iter events: %w", err) - } - return active, nil + return s.setJSON(availabilityHolidaysEnglandKey(), snap, "holiday snapshot") } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 131a46e..15c23e9 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -67,10 +67,10 @@ func TestStatus_SetGetDelete(t *testing.T) { } } -func TestAvailabilitySnapshot_SetGet(t *testing.T) { +func TestStatusRawSnapshot_SetGet(t *testing.T) { st := newTestStore(t) - _, ok, err := st.GetAvailabilitySnapshot() + _, ok, err := st.GetStatusRawSnapshot() if err != nil { t.Fatal(err) } @@ -78,16 +78,101 @@ func TestAvailabilitySnapshot_SetGet(t *testing.T) { t.Fatal("expected not found before set") } - want := &store.AvailabilitySnapshot{ + want := &store.CalendarSnapshot{ Body: "BEGIN:VCALENDAR\nEND:VCALENDAR", Timezone: "Europe/London", FetchedAt: time.Now().Truncate(time.Millisecond), } - if err := st.SetAvailabilitySnapshot(want); err != nil { + if err := st.SetStatusRawSnapshot(want); err != nil { t.Fatal(err) } - got, ok, err := st.GetAvailabilitySnapshot() + got, ok, err := st.GetStatusRawSnapshot() + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected found after set") + } + if got.Body != want.Body || got.Timezone != want.Timezone { + t.Errorf("status raw snapshot mismatch: got %+v, want %+v", got, want) + } +} + +func TestStatusProjection_Replace(t *testing.T) { + st := newTestStore(t) + now := time.Now().Truncate(time.Millisecond) + raw := &store.CalendarSnapshot{ + Body: "BEGIN:VCALENDAR\nEND:VCALENDAR", + Timezone: "Europe/London", + FetchedAt: now, + } + current := &store.Status{ + Emoji: ":calendar:", + Text: "Team meeting", + Expiration: now.Add(30 * time.Minute), + } + + if err := st.ReplaceStatusProjection(raw, current); err != nil { + t.Fatal(err) + } + + gotRaw, ok, err := st.GetStatusRawSnapshot() + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected status raw snapshot after replace") + } + if gotRaw.Body != raw.Body || gotRaw.Timezone != raw.Timezone { + t.Fatalf("unexpected status raw snapshot: %+v", gotRaw) + } + + gotStatus, ok, err := st.GetStatus() + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected current status after replace") + } + if gotStatus.Text != current.Text { + t.Fatalf("status text: got %q, want %q", gotStatus.Text, current.Text) + } + + raw.Body = "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR" + if err := st.ReplaceStatusProjection(raw, nil); err != nil { + t.Fatal(err) + } + _, ok, err = st.GetStatus() + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected current status to be deleted") + } +} + +func TestAvailabilityRawSnapshot_SetGet(t *testing.T) { + st := newTestStore(t) + + _, ok, err := st.GetAvailabilityRawSnapshot() + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected not found before set") + } + + want := &store.CalendarSnapshot{ + Body: "BEGIN:VCALENDAR\nEND:VCALENDAR", + Timezone: "Europe/London", + FetchedAt: time.Now().Truncate(time.Millisecond), + } + if err := st.SetAvailabilityRawSnapshot(want); err != nil { + t.Fatal(err) + } + + got, ok, err := st.GetAvailabilityRawSnapshot() if err != nil { t.Fatal(err) } @@ -100,10 +185,10 @@ func TestAvailabilitySnapshot_SetGet(t *testing.T) { want.Body = "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR" want.Timezone = "UTC" - if err := st.SetAvailabilitySnapshot(want); err != nil { + if err := st.SetAvailabilityRawSnapshot(want); err != nil { t.Fatal(err) } - got, _, err = st.GetAvailabilitySnapshot() + got, _, err = st.GetAvailabilityRawSnapshot() if err != nil { t.Fatal(err) } @@ -112,6 +197,81 @@ func TestAvailabilitySnapshot_SetGet(t *testing.T) { } } +func TestAvailabilityCurrent_SetGetClear(t *testing.T) { + st := newTestStore(t) + + _, ok, err := st.GetAvailabilityCurrent() + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected current availability not found before set") + } + + current := []byte(`[{"day_of_week":"Monday","block":"Morning","date":"2026-04-06"}]`) + if err := st.SetAvailabilityCurrent(current); err != nil { + t.Fatal(err) + } + + got, ok, err := st.GetAvailabilityCurrent() + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected current availability after set") + } + if string(got) != string(current) { + t.Fatalf("current availability mismatch: got %s, want %s", got, current) + } + + if err := st.ClearAvailabilityCurrent(); err != nil { + t.Fatal(err) + } + _, ok, err = st.GetAvailabilityCurrent() + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected current availability to be cleared") + } +} + +func TestAvailabilityProjection_Replace(t *testing.T) { + st := newTestStore(t) + raw := &store.CalendarSnapshot{ + Body: "BEGIN:VCALENDAR\nEND:VCALENDAR", + Timezone: "Europe/London", + FetchedAt: time.Now().UTC().Truncate(time.Millisecond), + } + current := []byte(`[{"date":"2026-04-06","block":"Morning"}]`) + + if err := st.ReplaceAvailabilityProjection(raw, current); err != nil { + t.Fatal(err) + } + + gotRaw, ok, err := st.GetAvailabilityRawSnapshot() + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected availability raw snapshot") + } + if gotRaw.Body != raw.Body || gotRaw.Timezone != raw.Timezone { + t.Fatalf("unexpected raw snapshot: %+v", gotRaw) + } + + gotCurrent, ok, err := st.GetAvailabilityCurrent() + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected current availability") + } + if string(gotCurrent) != string(current) { + t.Fatalf("current availability mismatch: got %s, want %s", gotCurrent, current) + } +} + func TestHolidaySnapshot_SetGet(t *testing.T) { st := newTestStore(t) @@ -207,142 +367,26 @@ func TestAvailabilityDeploymentState_SetGetClear(t *testing.T) { if ok { t.Fatal("expected dirty state to be cleared") } -} - -func TestEvent_SetGet(t *testing.T) { - st := newTestStore(t) - - _, ok, err := st.GetEvent("evt1") - if err != nil { - t.Fatal(err) - } - if ok { - t.Fatal("expected not found before set") - } - - want := &store.Event{ - ID: "evt1", - Summary: "Team meeting", - StartTime: time.Now().Add(-30 * time.Minute).Truncate(time.Millisecond), - EndTime: time.Now().Add(30 * time.Minute).Truncate(time.Millisecond), - Cancelled: false, - } - if err := st.SetEvent(want); err != nil { - t.Fatal(err) - } - - got, ok, err := st.GetEvent("evt1") - if err != nil { - t.Fatal(err) - } - if !ok { - t.Fatal("expected found after set") - } - if got.ID != want.ID || got.Summary != want.Summary || got.Cancelled != want.Cancelled { - t.Errorf("event mismatch: got %+v, want %+v", got, want) - } - // Overwrite: mark as cancelled. - want.Cancelled = true - if err := st.SetEvent(want); err != nil { + dirty = []byte(`[{"date":"2026-04-07","block":"Afternoon"}]`) + if err := st.SetAvailabilityDirty(dirty); err != nil { t.Fatal(err) } - got, _, err = st.GetEvent("evt1") - if err != nil { + if err := st.MarkAvailabilityDeployed(dirty); err != nil { t.Fatal(err) } - if !got.Cancelled { - t.Error("expected event to be cancelled after update") - } -} - -func TestListActiveEvents(t *testing.T) { - st := newTestStore(t) - now := time.Now() - - tests := []struct { - ev store.Event - active bool // expected to appear in active events at `now` - }{ - { - ev: store.Event{ - ID: "past", Summary: "Past meeting", - StartTime: now.Add(-2 * time.Hour), EndTime: now.Add(-time.Hour), - }, - active: false, - }, - { - ev: store.Event{ - ID: "current", Summary: "Current meeting", - StartTime: now.Add(-30 * time.Minute), EndTime: now.Add(30 * time.Minute), - }, - active: true, - }, - { - ev: store.Event{ - ID: "future", Summary: "Future meeting", - StartTime: now.Add(time.Hour), EndTime: now.Add(2 * time.Hour), - }, - active: false, - }, - { - // Cancelled events should never be active. - ev: store.Event{ - ID: "cancelled", Summary: "Cancelled meeting", - StartTime: now.Add(-30 * time.Minute), EndTime: now.Add(30 * time.Minute), - Cancelled: true, - }, - active: false, - }, - { - // Event that starts exactly now is active. - ev: store.Event{ - ID: "starts-now", Summary: "Starts now", - StartTime: now, EndTime: now.Add(time.Hour), - }, - active: true, - }, - { - // Multi-day event. - ev: store.Event{ - ID: "multi-day", Summary: "Multi-day workshop", - StartTime: now.Add(-24 * time.Hour), EndTime: now.Add(24 * time.Hour), - }, - active: true, - }, - } - - for i := range tests { - if err := st.SetEvent(&tests[i].ev); err != nil { - t.Fatalf("set event %q: %v", tests[i].ev.ID, err) - } - } - - active, err := st.ListActiveEvents(now) + last, ok, err = st.GetLastDeployedAvailability() if err != nil { t.Fatal(err) } - - activeIDs := make(map[string]bool, len(active)) - for _, ev := range active { - activeIDs[ev.ID] = true + if !ok || string(last) != string(dirty) { + t.Fatalf("last deployed mismatch after mark deployed: got %s ok=%v", last, ok) } - - for _, tc := range tests { - got := activeIDs[tc.ev.ID] - if got != tc.active { - t.Errorf("event %q: active=%v, want active=%v", tc.ev.ID, got, tc.active) - } - } -} - -func TestListActiveEvents_Empty(t *testing.T) { - st := newTestStore(t) - active, err := st.ListActiveEvents(time.Now()) + _, ok, err = st.GetAvailabilityDirty() if err != nil { t.Fatal(err) } - if len(active) != 0 { - t.Errorf("expected 0 active events, got %d", len(active)) + if ok { + t.Fatal("expected dirty state to be cleared after mark deployed") } } diff --git a/main.go b/main.go index a8f51a0..ce15c99 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "net/http" "os" @@ -60,10 +61,6 @@ func run(logger zerolog.Logger) error { } } - // Clear any persisted data on startup to ensure a fresh sync from the calendar. - // Ignore errors if directory doesn't exist or can't be cleared (e.g., in containers with restricted permissions). - _ = os.RemoveAll(cfg.PebblePath) - st, err := store.New(cfg.PebblePath) if err != nil { return fmt.Errorf("open store: %w", err) @@ -148,6 +145,9 @@ func registerAvailability(ctx context.Context, cfg config.AvailabilityConfig, st } provider := availability.NewProvider(st, availabilityBlocks, workingHours, cfg.Suppressions.ExcludeEnglandBankHolidays) + if err := provider.RefreshCurrentFromStoredSnapshot(); err != nil && !errors.Is(err, availability.ErrSnapshotNotFound) { + return nil, fmt.Errorf("hydrate availability snapshot: %w", err) + } mux.Handle("GET /api/availability", availability.NewHandler(provider, cfg.API.Key, logger)) return availability.NewSyncer(st, provider, availabilityClient, logger), nil }