From 7d371219b70dfa75b34de8a1bcbe3e4102e4bce1 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Mon, 27 Jul 2026 18:58:21 +0200 Subject: [PATCH] feat(store): add workout_not_found_at, exclude it from missing-workout queries A confirmed-404 workout must stop being retried forever, but workout_raw_json should never be fabricated -- it stays null exactly as it does for "not yet fetched." workout_not_found_at is the separate marker ActivitiesMissingWorkout/CountActivitiesMissingWorkout now check for, mirroring the existing pattern of the other _fetched_at columns. UpsertActivity's ON CONFLICT clause already never touches these columns, so the marker persists across every later re-sync. --- backend/internal/store/activities.go | 34 ++++++++++--- backend/internal/store/schema.sql | 8 +++ backend/internal/store/store_test.go | 74 ++++++++++++++++++++++++++++ docs/DATABASE.md | 8 +++ 4 files changed, 118 insertions(+), 6 deletions(-) diff --git a/backend/internal/store/activities.go b/backend/internal/store/activities.go index f5021fb..b96f05d 100644 --- a/backend/internal/store/activities.go +++ b/backend/internal/store/activities.go @@ -45,8 +45,13 @@ type Activity struct { // internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity // has no WorkoutID, or was synced before this column existed. WorkoutRawJSON *string - CreatedAt string - UpdatedAt string + // WorkoutNotFoundAt is set when get_workout_by_id returned a definitive + // HTTP 404 for this activity's WorkoutID -- distinct from + // WorkoutRawJSON staying nil for "not yet fetched" (see + // SetActivityWorkoutNotFound). + WorkoutNotFoundAt *string + CreatedAt string + UpdatedAt string } // UpsertActivity inserts a new activity for userID or updates the existing @@ -102,7 +107,7 @@ func scanActivity(row interface{ Scan(...any) error }) (Activity, error) { &a.AvgSpeedMps, &a.ElevationGainM, &a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue, &a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON, - &a.CreatedAt, &a.UpdatedAt, + &a.WorkoutNotFoundAt, &a.CreatedAt, &a.UpdatedAt, ) return a, err } @@ -113,7 +118,7 @@ const activityColumns = ` avg_speed_mps, elevation_gain_m, aerobic_training_effect, anaerobic_training_effect, vo2max_value, raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, - created_at, updated_at + workout_not_found_at, created_at, updated_at ` // GetActivity fetches one activity by its internal id, scoped to userID. @@ -225,6 +230,21 @@ func (db *DB) SetActivityWorkout(ctx context.Context, userID, activityID int64, return nil } +// SetActivityWorkoutNotFound records that get_workout_by_id returned a +// definitive HTTP 404 for this activity's WorkoutID -- the workout was +// deleted on Garmin's side after being linked to this activity. +// WorkoutRawJSON is deliberately left nil (never fabricated); this is a +// separate marker so ActivitiesMissingWorkout stops retrying it forever. +func (db *DB) SetActivityWorkoutNotFound(ctx context.Context, userID, activityID int64) error { + _, err := db.ExecContext(ctx, ` + UPDATE activities SET workout_not_found_at = datetime('now'), updated_at = datetime('now') + WHERE id = ? AND user_id = ?`, activityID, userID) + if err != nil { + return fmt.Errorf("set activity %d workout not found for user %d: %w", activityID, userID, err) + } + return nil +} + // SetActivitySplitsFetched records that get_activity_splits has been fetched // for this activity. func (db *DB) SetActivitySplitsFetched(ctx context.Context, userID, activityID int64) error { @@ -293,7 +313,8 @@ func (db *DB) CountActivitiesMissingDetails(ctx context.Context, userID int64) ( // that simply haven't been processed by fillActivityDetails at all yet. func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit int) ([]Activity, error) { rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities - WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL AND details_fetched_at IS NOT NULL + WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL + AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL ORDER BY start_time_utc DESC LIMIT ?`, userID, limit) if err != nil { return nil, fmt.Errorf("list activities missing workout for user %d: %w", userID, err) @@ -319,7 +340,8 @@ func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit func (db *DB) CountActivitiesMissingWorkout(ctx context.Context, userID int64) (int, error) { var n int err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities - WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL AND details_fetched_at IS NOT NULL`, userID).Scan(&n) + WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL + AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL`, userID).Scan(&n) if err != nil { return 0, fmt.Errorf("count activities missing workout for user %d: %w", userID, err) } diff --git a/backend/internal/store/schema.sql b/backend/internal/store/schema.sql index a5d7442..3ad9695 100644 --- a/backend/internal/store/schema.sql +++ b/backend/internal/store/schema.sql @@ -151,6 +151,14 @@ CREATE TABLE activities ( -- Genuine raw get_workout_by_id() response, the source used to compute -- alignWorkoutTargets. Null when the activity has no workout_id. workout_raw_json TEXT, + -- Set when get_workout_by_id returned a definitive HTTP 404 (the + -- workout was deleted on Garmin's side after being linked to this + -- activity) -- distinct from workout_raw_json staying null for "not yet + -- fetched": this activity is excluded from ActivitiesMissingWorkout so + -- it stops being retried forever (see + -- docs/superpowers/specs/2026-07-27-workout-not-found-design.md). + -- workout_raw_json itself is never fabricated; it just stays null. + workout_not_found_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, garmin_activity_id) diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 3b89c7a..a132dbf 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -459,6 +459,80 @@ func TestActivitiesMissingWorkout_OnlyIncludesActivitiesWithDetailsAlreadyFetche } } +func TestActivitiesMissingWorkout_ExcludesConfirmedNotFound(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + userID, err := db.ProvisionUser(ctx, "test-sub", "Test") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + + workoutID := int64(999) + // Details already fetched, workout confirmed 404 on Garmin -- must not + // be retried, so must not appear in ActivitiesMissingWorkout/Count. + notFound := Activity{ + GarminActivityID: 1, WorkoutID: &workoutID, + StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}", + } + idA, err := db.UpsertActivity(ctx, userID, notFound) + if err != nil { + t.Fatalf("UpsertActivity (a): %v", err) + } + if err := db.SetActivityDetails(ctx, userID, idA, "{}"); err != nil { + t.Fatalf("SetActivityDetails (a): %v", err) + } + if err := db.SetActivitySplitsFetched(ctx, userID, idA); err != nil { + t.Fatalf("SetActivitySplitsFetched (a): %v", err) + } + if err := db.SetActivityWorkoutNotFound(ctx, userID, idA); err != nil { + t.Fatalf("SetActivityWorkoutNotFound (a): %v", err) + } + + // A genuinely still-pending activity (details fetched, workout not yet + // attempted) must still be included, for contrast. + stillPending := Activity{ + GarminActivityID: 2, WorkoutID: &workoutID, + StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}", + } + idB, err := db.UpsertActivity(ctx, userID, stillPending) + if err != nil { + t.Fatalf("UpsertActivity (b): %v", err) + } + if err := db.SetActivityDetails(ctx, userID, idB, "{}"); err != nil { + t.Fatalf("SetActivityDetails (b): %v", err) + } + if err := db.SetActivitySplitsFetched(ctx, userID, idB); err != nil { + t.Fatalf("SetActivitySplitsFetched (b): %v", err) + } + + n, err := db.CountActivitiesMissingWorkout(ctx, userID) + if err != nil { + t.Fatalf("CountActivitiesMissingWorkout: %v", err) + } + if n != 1 { + t.Fatalf("CountActivitiesMissingWorkout = %d, want 1 (only activity b)", n) + } + + pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10) + if err != nil { + t.Fatalf("ActivitiesMissingWorkout: %v", err) + } + if len(pending) != 1 || pending[0].ID != idB { + t.Fatalf("ActivitiesMissingWorkout = %+v, want only activity b (id %d)", pending, idB) + } + + confirmed, _, err := db.GetActivity(ctx, userID, idA) + if err != nil { + t.Fatalf("GetActivity (a): %v", err) + } + if confirmed.WorkoutNotFoundAt == nil { + t.Error("activity a's WorkoutNotFoundAt is nil, want it set") + } + if confirmed.WorkoutRawJSON != nil { + t.Error("activity a's WorkoutRawJSON should stay nil -- not-found is recorded separately, not faked") + } +} + func contains(s string, substrs ...string) bool { lower := strings.ToLower(s) for _, substr := range substrs { diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 1afc19f..cbfd0b3 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -150,6 +150,14 @@ CREATE TABLE activities ( -- Genuine raw get_workout_by_id() response, the source used to compute -- alignWorkoutTargets. Null when the activity has no workout_id. workout_raw_json TEXT, + -- Set when get_workout_by_id returned a definitive HTTP 404 (the + -- workout was deleted on Garmin's side after being linked to this + -- activity) -- distinct from workout_raw_json staying null for "not yet + -- fetched": this activity is excluded from ActivitiesMissingWorkout so + -- it stops being retried forever (see + -- docs/superpowers/specs/2026-07-27-workout-not-found-design.md). + -- workout_raw_json itself is never fabricated; it just stays null. + workout_not_found_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, garmin_activity_id)