diff --git a/internal/handler/sync_handler.go b/internal/handler/sync_handler.go index fb36df1..4c31d66 100644 --- a/internal/handler/sync_handler.go +++ b/internal/handler/sync_handler.go @@ -54,7 +54,14 @@ func (h *SyncHandler) Pull(w http.ResponseWriter, r *http.Request) { } } - changes, serverVersion, err := h.sync.Pull(r.Context(), since) + var changes []store.SyncChange + var serverVersion int64 + var err error + if since == 0 { + changes, serverVersion, err = h.sync.Bootstrap(r.Context()) + } else { + changes, serverVersion, err = h.sync.Pull(r.Context(), since) + } if err != nil { if errors.Is(err, store.ErrSyncStale) { writeError(w, http.StatusGone, "sync_stale") diff --git a/internal/store/sync_store.go b/internal/store/sync_store.go index fc7ac6d..b13908d 100644 --- a/internal/store/sync_store.go +++ b/internal/store/sync_store.go @@ -11,10 +11,9 @@ import ( "github.com/wotra/wotra/internal/domain" ) -// ErrSyncStale is returned when the client's since_version is behind the prune marker. +// ErrSyncStale is returned when an existing client is behind the prune marker. var ErrSyncStale = errors.New("sync state stale: full re-sync required") -// pruneEntity and pruneOp are sentinel values written as a prune marker row. const pruneEntity = "_pruned" const pruneOp = "marker" @@ -30,15 +29,79 @@ func NewSyncStore(db *sql.DB) *SyncStore { type SyncChange struct { Entity string `json:"entity"` EntityID string `json:"entity_id"` - Op string `json:"op"` // "upsert" | "delete" | "marker" + Op string `json:"op"` Version int64 `json:"version"` Payload string `json:"payload"` } +// Bootstrap returns the current contents of every client-replicated entity. +// It deliberately bypasses sync_log so retention cannot hide old data. +func (s *SyncStore) Bootstrap(ctx context.Context) ([]SyncChange, int64, error) { + var changes []SyncChange + add := func(entity, id string, value any) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + changes = append(changes, SyncChange{Entity: entity, EntityID: id, Op: "upsert", Payload: string(payload)}) + return nil + } + + rows, err := s.db.QueryContext(ctx, `SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at FROM entries WHERE deleted_at IS NULL`) + if err != nil { return nil, 0, err } + for rows.Next() { + var e domain.Entry; var end, deleted sql.NullInt64; var stopped int + if err := rows.Scan(&e.ID, &e.StartTime, &end, &stopped, &e.Note, &e.DayKey, &e.UpdatedAt, &deleted); err != nil { rows.Close(); return nil, 0, err } + if end.Valid { e.EndTime = &end.Int64 }; e.AutoStopped = stopped != 0 + if err := add("entries", e.ID, e); err != nil { rows.Close(); return nil, 0, err } + } + if err := rows.Err(); err != nil { rows.Close(); return nil, 0, err }; rows.Close() + + rows, err = s.db.QueryContext(ctx, `SELECT day_key, start_time, end_time, worked_ms, kind, closed_at, updated_at FROM closed_days`) + if err != nil { return nil, 0, err } + for rows.Next() { + var d domain.ClosedDay; var start, end sql.NullInt64 + if err := rows.Scan(&d.DayKey, &start, &end, &d.WorkedMs, &d.Kind, &d.ClosedAt, &d.UpdatedAt); err != nil { rows.Close(); return nil, 0, err } + if start.Valid { d.StartTime = &start.Int64 }; if end.Valid { d.EndTime = &end.Int64 } + if err := add("closed_days", d.DayKey, d); err != nil { rows.Close(); return nil, 0, err } + } + if err := rows.Err(); err != nil { rows.Close(); return nil, 0, err }; rows.Close() + + rows, err = s.db.QueryContext(ctx, `SELECT week_key, expected_ms, worked_ms, delta_ms, closed_at, updated_at FROM closed_weeks`) + if err != nil { return nil, 0, err } + for rows.Next() { + var w domain.ClosedWeek + if err := rows.Scan(&w.WeekKey, &w.ExpectedMs, &w.WorkedMs, &w.DeltaMs, &w.ClosedAt, &w.UpdatedAt); err != nil { rows.Close(); return nil, 0, err } + if err := add("closed_weeks", w.WeekKey, w); err != nil { rows.Close(); return nil, 0, err } + } + if err := rows.Err(); err != nil { rows.Close(); return nil, 0, err }; rows.Close() + + rows, err = s.db.QueryContext(ctx, `SELECT id, effective_from, hours_per_week, workdays_mask, timezone, created_at, updated_at FROM settings_history`) + if err != nil { return nil, 0, err } + for rows.Next() { + var set domain.Settings + if err := rows.Scan(&set.ID, &set.EffectiveFrom, &set.HoursPerWeek, &set.WorkdaysMask, &set.Timezone, &set.CreatedAt, &set.UpdatedAt); err != nil { rows.Close(); return nil, 0, err } + if err := add("settings_history", set.ID, set); err != nil { rows.Close(); return nil, 0, err } + } + if err := rows.Err(); err != nil { rows.Close(); return nil, 0, err }; rows.Close() + + rows, err = s.db.QueryContext(ctx, `SELECT id, delta_ms, note, effective_at, created_at, updated_at FROM balance_adjustments`) + if err != nil { return nil, 0, err } + for rows.Next() { + var a domain.BalanceAdjustment + if err := rows.Scan(&a.ID, &a.DeltaMs, &a.Note, &a.EffectiveAt, &a.CreatedAt, &a.UpdatedAt); err != nil { rows.Close(); return nil, 0, err } + if err := add("balance_adjustments", a.ID, a); err != nil { rows.Close(); return nil, 0, err } + } + if err := rows.Err(); err != nil { rows.Close(); return nil, 0, err }; rows.Close() + + var version int64 + if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM sync_log`).Scan(&version); err != nil { return nil, 0, err } + return changes, version, nil +} + // Pull returns all sync_log rows with version > sinceVersion. -// It calls Prune first with a 30-day TTL. A zero-version pull is a bootstrap -// and may proceed past the prune marker; non-zero clients behind the marker -// must perform a full re-sync. +// It calls Prune first with a 30-day TTL. Clients behind a prune marker +// must bootstrap from current entities before resuming incremental sync. func (s *SyncStore) Pull(ctx context.Context, sinceVersion int64) ([]SyncChange, int64, error) { if err := s.Prune(ctx, 30*24*time.Hour); err != nil { return nil, 0, err @@ -60,10 +123,7 @@ func (s *SyncStore) Pull(ctx context.Context, sinceVersion int64) ([]SyncChange, return nil, 0, err } if c.Entity == pruneEntity { - if sinceVersion != 0 { - return nil, 0, ErrSyncStale - } - continue + return nil, 0, ErrSyncStale } if c.Version > maxVersion { maxVersion = c.Version diff --git a/internal/store/sync_store_test.go b/internal/store/sync_store_test.go index c160f51..eaa1b9f 100644 --- a/internal/store/sync_store_test.go +++ b/internal/store/sync_store_test.go @@ -83,33 +83,26 @@ func TestSyncPruneStaleClient(t *testing.T) { } } -func TestSyncBootstrapAfterPruneReturnsLatestData(t *testing.T) { - s := mustSyncStore(t) + +func TestSyncBootstrapIncludesPrunedEntityData(t *testing.T) { + db, err := store.Open(":memory:") + if err != nil { t.Fatal(err) } + defer db.Close() + ctx := context.Background() + entries := store.NewEntryStore(db) + entry := &domain.Entry{ID: "retained", StartTime: 1, DayKey: "2026-01-01", Note: "old"} + if err := entries.Create(ctx, entry); err != nil { t.Fatal(err) } + syncStore := store.NewSyncStore(db) + if err := syncStore.Prune(ctx, -time.Millisecond); err != nil { t.Fatal(err) } - old := &domain.Entry{ID: "old", DayKey: "2026-01-01", UpdatedAt: time.Now().Add(-31 * 24 * time.Hour).UnixMilli()} - latest := &domain.Entry{ID: "latest", DayKey: "2026-02-01", UpdatedAt: time.Now().UnixMilli()} - if err := s.LogEntry(ctx, old); err != nil { - t.Fatal(err) - } - if err := s.Prune(ctx, -time.Millisecond); err != nil { - t.Fatal(err) - } - if err := s.LogEntry(ctx, latest); err != nil { - t.Fatal(err) - } - - // A zero-version client has no history to reconcile. It should receive - // current data rather than being rejected because older history was pruned. - changes, _, err := s.Pull(ctx, 0) - if err != nil { - t.Fatalf("bootstrap should return latest data after pruning, got %v", err) - } - if len(changes) != 1 || changes[0].EntityID != "latest" { - t.Fatalf("expected only latest data, got %+v", changes) + changes, version, err := syncStore.Bootstrap(ctx) + if err != nil { t.Fatal(err) } + if version != 0 { t.Fatalf("expected sync version 0, got %d", version) } + if len(changes) == 0 || changes[0].Entity != "entries" || changes[0].EntityID != entry.ID { + t.Fatalf("expected current entry in bootstrap, got %+v", changes) } } - func TestSyncPruneNoRows(t *testing.T) { s := mustSyncStore(t) ctx := context.Background()