Merge backfill into Sync now, replace Full backfill with a destructive Reset all
Sync now now runs Backfill (resumes from the watermark, so a widened history horizon is picked up automatically) then IncrementalSync then detail-fill, in one click. "Full backfill" is gone -- in its place, "Reset all" (styled as a destructive action, gated by a confirm dialog) wipes every synced activity and its laps/kind assignments and rewinds the backfill watermark, so the next sync performs a genuinely fresh pull instead of trying to patch up existing rows with newer schema fields.
This commit is contained in:
@@ -335,6 +335,42 @@ func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||||
|
s, db := newTestServer(t)
|
||||||
|
ctx := newCtx()
|
||||||
|
|
||||||
|
if _, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
|
||||||
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := s.Router()
|
||||||
|
rec := doJSON(t, router, http.MethodPost, "/api/sync/reset", nil)
|
||||||
|
if rec.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActivities: %v", err)
|
||||||
|
}
|
||||||
|
if len(activities) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("timed out waiting for reset to delete activities, still have %d", len(activities))
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Full backfill" no longer exists as an endpoint -- superseded by reset.
|
||||||
|
rec = doJSON(t, router, http.MethodPost, "/api/sync/backfill", nil)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("/api/sync/backfill status = %d, want 404 (removed)", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
|
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
|
|
||||||
r.Route("/sync", func(r chi.Router) {
|
r.Route("/sync", func(r chi.Router) {
|
||||||
r.Post("/run", s.handleSyncRun)
|
r.Post("/run", s.handleSyncRun)
|
||||||
r.Post("/backfill", s.handleSyncBackfill)
|
r.Post("/reset", s.handleSyncReset)
|
||||||
r.Get("/runs", s.handleSyncRuns)
|
r.Get("/runs", s.handleSyncRuns)
|
||||||
r.Get("/status", s.handleSyncStatus)
|
r.Get("/status", s.handleSyncStatus)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,8 +10,17 @@ import (
|
|||||||
// internal/sync.Service.FillPendingDetails.
|
// internal/sync.Service.FillPendingDetails.
|
||||||
const detailFillBatchSize = 50
|
const detailFillBatchSize = 50
|
||||||
|
|
||||||
|
// handleSyncRun does a full sync pass: Backfill first (resumes from the
|
||||||
|
// watermark, so widening the configured history horizon between clicks is
|
||||||
|
// picked up automatically), then IncrementalSync (catches anything new since
|
||||||
|
// the latest known activity), then fills in details for whatever's still
|
||||||
|
// missing them. Activities already fully processed are left untouched --
|
||||||
|
// see internal/sync.Service.FillPendingDetails.
|
||||||
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
||||||
ok := s.backgroundSync(func(ctx context.Context) error {
|
ok := s.backgroundSync(func(ctx context.Context) error {
|
||||||
|
if err := s.Sync.Backfill(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := s.Sync.IncrementalSync(ctx); err != nil {
|
if err := s.Sync.IncrementalSync(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -24,12 +33,13 @@ func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSyncBackfill(w http.ResponseWriter, r *http.Request) {
|
// handleSyncReset wipes every synced activity (and its laps/samples/kind
|
||||||
|
// assignments) and rewinds the backfill watermark, so the next Sync Now
|
||||||
|
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
|
||||||
|
// gates this behind a confirmation.
|
||||||
|
func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
|
||||||
ok := s.backgroundSync(func(ctx context.Context) error {
|
ok := s.backgroundSync(func(ctx context.Context) error {
|
||||||
if err := s.Sync.Backfill(ctx); err != nil {
|
return s.Sync.ResetAll(ctx)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.Sync.FillPendingDetails(ctx, detailFillBatchSize)
|
|
||||||
})
|
})
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||||
|
|||||||
27
backend/internal/store/reset.go
Normal file
27
backend/internal/store/reset.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResetAllSyncedData deletes every synced activity (cascading to its laps,
|
||||||
|
// activity_samples, and kind_assignments via ON DELETE CASCADE) and rewinds
|
||||||
|
// the backfill watermark to its initial state, so a subsequent Backfill
|
||||||
|
// starts a genuinely fresh pull instead of thinking history is already
|
||||||
|
// covered. Workout kinds (the user's taxonomy) are left untouched.
|
||||||
|
func (db *DB) ResetAllSyncedData(ctx context.Context) error {
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin reset tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM activities`); err != nil {
|
||||||
|
return fmt.Errorf("delete activities: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE sync_state SET earliest_synced_date = NULL, backfill_complete = 0 WHERE id = 1`); err != nil {
|
||||||
|
return fmt.Errorf("reset sync state: %w", err)
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
71
backend/internal/store/reset_test.go
Normal file
71
backend/internal/store/reset_test.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
activityID, err := db.UpsertActivity(ctx, Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
|
}
|
||||||
|
kindID, err := db.CreateWorkoutKind(ctx, WorkoutKind{Name: "Test Reset Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.ReplaceLaps(ctx, activityID, []Lap{{LapIndex: 1}}); err != nil {
|
||||||
|
t.Fatalf("ReplaceLaps: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
|
||||||
|
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
|
||||||
|
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InsertKindAssignment: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.UpdateSyncState(ctx, "2020-01-01", true); err != nil {
|
||||||
|
t.Fatalf("UpdateSyncState: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.ResetAllSyncedData(ctx); err != nil {
|
||||||
|
t.Fatalf("ResetAllSyncedData: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activities, err := db.ListActivities(ctx, ActivityFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActivities: %v", err)
|
||||||
|
}
|
||||||
|
if len(activities) != 0 {
|
||||||
|
t.Errorf("expected 0 activities after reset, got %d", len(activities))
|
||||||
|
}
|
||||||
|
laps, err := db.LapsForActivity(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LapsForActivity: %v", err)
|
||||||
|
}
|
||||||
|
if len(laps) != 0 {
|
||||||
|
t.Errorf("expected laps to cascade-delete, got %d", len(laps))
|
||||||
|
}
|
||||||
|
if _, ok, err := db.CurrentAssignment(ctx, activityID); err != nil || ok {
|
||||||
|
t.Errorf("expected kind assignment to cascade-delete, ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := db.GetSyncState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSyncState: %v", err)
|
||||||
|
}
|
||||||
|
if state.EarliestSyncedDate != nil || state.BackfillComplete {
|
||||||
|
t.Errorf("expected watermark rewound to fresh state, got %+v", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workout kinds (the user's taxonomy) must survive a reset.
|
||||||
|
kind, ok, err := db.GetWorkoutKind(ctx, kindID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("GetWorkoutKind after reset: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if kind.Name != "Test Reset Kind" {
|
||||||
|
t.Errorf("workout kind was unexpectedly affected by reset: %+v", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,6 +203,14 @@ func (s *Service) IncrementalSync(ctx context.Context) error {
|
|||||||
return s.db.FinishSyncRun(ctx, runID, n, nil)
|
return s.db.FinishSyncRun(ctx, runID, n, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetAll deletes every synced activity (and its laps/samples/kind
|
||||||
|
// assignments) and rewinds the backfill watermark, so the next Backfill
|
||||||
|
// call performs a genuinely fresh pull from Garmin instead of resuming from
|
||||||
|
// wherever the previous one left off. Workout kinds are left untouched.
|
||||||
|
func (s *Service) ResetAll(ctx context.Context) error {
|
||||||
|
return s.db.ResetAllSyncedData(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (int, error) {
|
func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (int, error) {
|
||||||
activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
|
activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -427,6 +427,47 @@ func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
m := &mock.Client{Activities: []garmin.Activity{
|
||||||
|
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||||
|
}}
|
||||||
|
svc := NewService(m, db, Config{BackfillHorizonDays: 10, BackfillWindowDays: 10},
|
||||||
|
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
|
|
||||||
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
|
t.Fatalf("first Backfill: %v", err)
|
||||||
|
}
|
||||||
|
firstCallCount := m.GetActivitiesCalls
|
||||||
|
|
||||||
|
if err := svc.ResetAll(ctx); err != nil {
|
||||||
|
t.Fatalf("ResetAll: %v", err)
|
||||||
|
}
|
||||||
|
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActivities: %v", err)
|
||||||
|
}
|
||||||
|
if len(activities) != 0 {
|
||||||
|
t.Fatalf("expected 0 activities after ResetAll, got %d", len(activities))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
|
t.Fatalf("Backfill after reset: %v", err)
|
||||||
|
}
|
||||||
|
if m.GetActivitiesCalls <= firstCallCount {
|
||||||
|
t.Errorf("expected Backfill after ResetAll to call GetActivities again (fresh pull), call count stayed at %d", m.GetActivitiesCalls)
|
||||||
|
}
|
||||||
|
activities, err = db.ListActivities(ctx, store.ActivityFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActivities after re-backfill: %v", err)
|
||||||
|
}
|
||||||
|
if len(activities) != 1 {
|
||||||
|
t.Fatalf("expected 1 activity re-fetched after reset+backfill, got %d", len(activities))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -137,6 +137,17 @@ button:disabled {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-danger {
|
||||||
|
border-color: #7f1d1d;
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-danger:hover:not(:disabled) {
|
||||||
|
background: #7f1d1d;
|
||||||
|
border-color: #f87171;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-pills {
|
.filter-pills {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ export const api = {
|
|||||||
|
|
||||||
// Sync
|
// Sync
|
||||||
syncRun: () => request<{ status: string }>("/api/sync/run", { method: "POST" }),
|
syncRun: () => request<{ status: string }>("/api/sync/run", { method: "POST" }),
|
||||||
syncBackfill: () => request<{ status: string }>("/api/sync/backfill", { method: "POST" }),
|
// Deletes every synced activity (and its laps/samples/kind assignments)
|
||||||
|
// and rewinds the backfill watermark -- destructive, gated by a
|
||||||
|
// confirmation in the UI.
|
||||||
|
resetSync: () => request<{ status: string }>("/api/sync/reset", { method: "POST" }),
|
||||||
syncRuns: () => request<SyncRun[]>("/api/sync/runs"),
|
syncRuns: () => request<SyncRun[]>("/api/sync/runs"),
|
||||||
syncStatus: () => request<SyncStatus>("/api/sync/status"),
|
syncStatus: () => request<SyncStatus>("/api/sync/status"),
|
||||||
|
|
||||||
|
|||||||
@@ -68,11 +68,27 @@ export function GarminConnection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sync(kind: "run" | "backfill") {
|
async function sync() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await (kind === "run" ? api.syncRun() : api.syncBackfill());
|
await api.syncRun();
|
||||||
|
refreshStatus();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetAll() {
|
||||||
|
if (!window.confirm("This deletes every synced activity, lap, and kind assignment, then starts a fresh pull from Garmin next sync. This cannot be undone. Continue?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.resetSync();
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
@@ -102,11 +118,11 @@ export function GarminConnection() {
|
|||||||
|
|
||||||
{status === "authenticated" && (
|
{status === "authenticated" && (
|
||||||
<>
|
<>
|
||||||
<button disabled={busy} onClick={() => sync("run")}>
|
<button disabled={busy} onClick={sync}>
|
||||||
Sync now
|
Sync now
|
||||||
</button>
|
</button>
|
||||||
<button disabled={busy} onClick={() => sync("backfill")}>
|
<button className="button-danger" disabled={busy} onClick={resetAll}>
|
||||||
Full backfill
|
Reset all
|
||||||
</button>
|
</button>
|
||||||
{syncStatus?.in_progress && (
|
{syncStatus?.in_progress && (
|
||||||
<span className="status-label">{syncProgressLabel(syncStatus)}</span>
|
<span className="status-label">{syncProgressLabel(syncStatus)}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user