sync: scope Service to one user per instance

NewService now takes a userID, baked into the instance rather than passed
per-call -- matches internal/api's one-Service-per-logged-in-user model
(Task 13), so ClassifyActivity/Backfill/etc. keep their existing call
signatures unchanged everywhere they're already used.
This commit is contained in:
2026-07-25 17:45:41 +02:00
parent 3a1333017d
commit 69103b7a4b
2 changed files with 138 additions and 73 deletions

View File

@@ -61,10 +61,12 @@ type Progress struct {
Total int Total int
} }
// Service is the sync orchestrator. // Service is the sync orchestrator, scoped to one user -- every store call
// it makes is for userID's data only.
type Service struct { type Service struct {
garmin garmin.Client garmin garmin.Client
db *store.DB db *store.DB
userID int64
cfg Config cfg Config
now func() time.Time now func() time.Time
@@ -72,13 +74,13 @@ type Service struct {
progress Progress progress Progress
} }
// NewService builds a Service. now defaults to time.Now if nil (tests can // NewService builds a Service scoped to userID. now defaults to time.Now if
// override it for deterministic date windows). // nil (tests can override it for deterministic date windows).
func NewService(g garmin.Client, db *store.DB, cfg Config, now func() time.Time) *Service { func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service {
if now == nil { if now == nil {
now = time.Now now = time.Now
} }
return &Service{garmin: g, db: db, cfg: cfg.withDefaults(), now: now} return &Service{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now}
} }
// Progress returns the current detail-fill progress (0/0 when idle). // Progress returns the current detail-fill progress (0/0 when idle).
@@ -106,7 +108,7 @@ func (s *Service) setProgress(done, total int) {
// history. Widening the horizon between calls resumes further back instead // history. Widening the horizon between calls resumes further back instead
// of re-fetching everything. // of re-fetching everything.
func (s *Service) Backfill(ctx context.Context) error { func (s *Service) Backfill(ctx context.Context) error {
runID, err := s.db.StartSyncRun(ctx, store.SyncKindBackfill) runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindBackfill)
if err != nil { if err != nil {
return err return err
} }
@@ -114,10 +116,10 @@ func (s *Service) Backfill(ctx context.Context) error {
total, err := s.backfillCore(ctx) total, err := s.backfillCore(ctx)
if err != nil { if err != nil {
msg := err.Error() msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg) s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
return err return err
} }
return s.db.FinishSyncRun(ctx, runID, total, nil) return s.db.FinishSyncRun(ctx, s.userID, runID, total, nil)
} }
// backfillCore holds Backfill's actual fetch logic, without the SyncRun // backfillCore holds Backfill's actual fetch logic, without the SyncRun
@@ -126,13 +128,13 @@ func (s *Service) Backfill(ctx context.Context) error {
// whatever was fetched even when an error is also returned, matching // whatever was fetched even when an error is also returned, matching
// Backfill's own partial-progress-on-error behavior. // Backfill's own partial-progress-on-error behavior.
func (s *Service) backfillCore(ctx context.Context) (int, error) { func (s *Service) backfillCore(ctx context.Context) (int, error) {
profile, err := s.db.GetProfile(ctx) profile, err := s.db.GetProfile(ctx, s.userID)
if err != nil { if err != nil {
return 0, fmt.Errorf("load profile: %w", err) return 0, fmt.Errorf("load profile: %w", err)
} }
horizon := s.now().AddDate(0, 0, -profile.BackfillHorizonDays) horizon := s.now().AddDate(0, 0, -profile.BackfillHorizonDays)
state, err := s.db.GetSyncState(ctx) state, err := s.db.GetSyncState(ctx, s.userID)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -167,12 +169,12 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
// Empty page: reached the start of this account's history, // Empty page: reached the start of this account's history,
// regardless of the configured horizon. // regardless of the configured horizon.
reachedStartOfHistory = true reachedStartOfHistory = true
if err := s.db.UpdateSyncState(ctx, dateStr(start), true); err != nil { if err := s.db.UpdateSyncState(ctx, s.userID, dateStr(start), true); err != nil {
return total, err return total, err
} }
break break
} }
if err := s.db.UpdateSyncState(ctx, dateStr(start), false); err != nil { if err := s.db.UpdateSyncState(ctx, s.userID, dateStr(start), false); err != nil {
return total, err return total, err
} }
end = start.AddDate(0, 0, -1) end = start.AddDate(0, 0, -1)
@@ -181,7 +183,7 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
if !reachedStartOfHistory { if !reachedStartOfHistory {
// Reached the configured horizon (not Garmin's actual history // Reached the configured horizon (not Garmin's actual history
// start) -- mark complete relative to that horizon. // start) -- mark complete relative to that horizon.
if err := s.db.UpdateSyncState(ctx, dateStr(horizon), true); err != nil { if err := s.db.UpdateSyncState(ctx, s.userID, dateStr(horizon), true); err != nil {
return total, err return total, err
} }
} }
@@ -192,7 +194,7 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
// IncrementalSync fetches activities from just before the latest known // IncrementalSync fetches activities from just before the latest known
// activity (or a short recent window if none exist yet) through today. // activity (or a short recent window if none exist yet) through today.
func (s *Service) IncrementalSync(ctx context.Context) error { func (s *Service) IncrementalSync(ctx context.Context) error {
runID, err := s.db.StartSyncRun(ctx, store.SyncKindIncremental) runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindIncremental)
if err != nil { if err != nil {
return err return err
} }
@@ -200,17 +202,17 @@ func (s *Service) IncrementalSync(ctx context.Context) error {
n, err := s.incrementalSyncCore(ctx) n, err := s.incrementalSyncCore(ctx)
if err != nil { if err != nil {
msg := err.Error() msg := err.Error()
s.db.FinishSyncRun(ctx, runID, n, &msg) s.db.FinishSyncRun(ctx, s.userID, runID, n, &msg)
return err return err
} }
return s.db.FinishSyncRun(ctx, runID, n, nil) return s.db.FinishSyncRun(ctx, s.userID, runID, n, nil)
} }
// incrementalSyncCore holds IncrementalSync's actual fetch logic, without // incrementalSyncCore holds IncrementalSync's actual fetch logic, without
// the SyncRun bookkeeping -- see backfillCore. // the SyncRun bookkeeping -- see backfillCore.
func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) { func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays) start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
if latest, ok, err := s.db.LatestActivityStartTime(ctx); err == nil && ok { if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok {
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil { if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
start = t.AddDate(0, 0, -s.cfg.IncrementalOverlapDays) start = t.AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
} }
@@ -229,7 +231,7 @@ func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
// would silently hide however many activities Backfill fetched. Recording // would silently hide however many activities Backfill fetched. Recording
// one combined run makes the reported count match the whole action. // one combined run makes the reported count match the whole action.
func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error { func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
runID, err := s.db.StartSyncRun(ctx, store.SyncKindFull) runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull)
if err != nil { if err != nil {
return err return err
} }
@@ -237,7 +239,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
backfillCount, err := s.backfillCore(ctx) backfillCount, err := s.backfillCore(ctx)
if err != nil { if err != nil {
msg := err.Error() msg := err.Error()
s.db.FinishSyncRun(ctx, runID, backfillCount, &msg) s.db.FinishSyncRun(ctx, s.userID, runID, backfillCount, &msg)
return err return err
} }
@@ -245,17 +247,17 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
total := backfillCount + incrementalCount total := backfillCount + incrementalCount
if err != nil { if err != nil {
msg := err.Error() msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg) s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
return err return err
} }
if err := s.FillPendingDetails(ctx, detailFillLimit); err != nil { if err := s.FillPendingDetails(ctx, detailFillLimit); err != nil {
msg := err.Error() msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg) s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
return err return err
} }
return s.db.FinishSyncRun(ctx, runID, total, nil) return s.db.FinishSyncRun(ctx, s.userID, runID, total, nil)
} }
// ResetAll deletes every synced activity (and its laps/samples/kind // ResetAll deletes every synced activity (and its laps/samples/kind
@@ -263,7 +265,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
// call performs a genuinely fresh pull from Garmin instead of resuming from // call performs a genuinely fresh pull from Garmin instead of resuming from
// wherever the previous one left off. Workout kinds are left untouched. // wherever the previous one left off. Workout kinds are left untouched.
func (s *Service) ResetAll(ctx context.Context) error { func (s *Service) ResetAll(ctx context.Context) error {
return s.db.ResetAllSyncedData(ctx) return s.db.ResetAllSyncedData(ctx, s.userID)
} }
// fetchAndStoreWindow returns two counts: rawCount is every activity Garmin's // fetchAndStoreWindow returns two counts: rawCount is every activity Garmin's
@@ -290,11 +292,11 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st
if !isRunningActivityType(a.ActivityType.TypeKey) { if !isRunningActivityType(a.ActivityType.TypeKey) {
continue continue
} }
exists, err := s.db.ActivityExists(ctx, a.ActivityID) exists, err := s.db.ActivityExists(ctx, s.userID, a.ActivityID)
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
if _, err := s.db.UpsertActivity(ctx, toActivityRow(a)); err != nil { if _, err := s.db.UpsertActivity(ctx, s.userID, toActivityRow(a)); err != nil {
return 0, 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err) return 0, 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err)
} }
if !exists { if !exists {
@@ -309,11 +311,11 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st
// Calls are made sequentially with Config.InterCallDelay between them to // Calls are made sequentially with Config.InterCallDelay between them to
// avoid Garmin/Cloudflare rate limiting. // avoid Garmin/Cloudflare rate limiting.
func (s *Service) FillPendingDetails(ctx context.Context, limit int) error { func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
pending, err := s.db.ActivitiesMissingDetails(ctx, limit) pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit)
if err != nil { if err != nil {
return err return err
} }
profile, err := s.db.GetProfile(ctx) profile, err := s.db.GetProfile(ctx, s.userID)
if err != nil { if err != nil {
return fmt.Errorf("load profile: %w", err) return fmt.Errorf("load profile: %w", err)
} }
@@ -357,41 +359,41 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err) log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err)
} else { } else {
targets = alignWorkoutTargets(splits.Laps, workout) targets = alignWorkoutTargets(splits.Laps, workout)
if err := s.db.SetActivityWorkout(ctx, a.ID, string(workout.Raw)); err != nil { if err := s.db.SetActivityWorkout(ctx, s.userID, a.ID, string(workout.Raw)); err != nil {
return err return err
} }
} }
} }
samples := garmin.ExtractSamples(details) samples := garmin.ExtractSamples(details)
if err := s.db.ReplaceActivitySamples(ctx, a.ID, toSampleRows(samples)); err != nil { if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil {
return err return err
} }
if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil { if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
return err return err
} }
if err := s.db.SetActivityDetails(ctx, a.ID, string(details.Raw)); err != nil { if err := s.db.SetActivityDetails(ctx, s.userID, a.ID, string(details.Raw)); err != nil {
return err return err
} }
return s.db.SetActivitySplitsFetched(ctx, a.ID) return s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID)
} }
// ClassifyActivity (re)runs the rule engine for one activity against the // ClassifyActivity (re)runs the rule engine for one activity against the
// currently active workout kinds and appends a new kind_assignments row. // currently active workout kinds and appends a new kind_assignments row.
// Safe to call repeatedly (e.g. after editing a workout kind's rule). // Safe to call repeatedly (e.g. after editing a workout kind's rule).
func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error { func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error {
activity, ok, err := s.db.GetActivity(ctx, activityID) activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID)
if err != nil { if err != nil {
return err return err
} }
if !ok { if !ok {
return fmt.Errorf("activity %d not found", activityID) return fmt.Errorf("activity %d not found", activityID)
} }
laps, err := s.db.LapsForActivity(ctx, activityID) laps, err := s.db.LapsForActivity(ctx, s.userID, activityID)
if err != nil { if err != nil {
return err return err
} }
kindRows, err := s.db.ListWorkoutKinds(ctx, true) kindRows, err := s.db.ListWorkoutKinds(ctx, s.userID, true)
if err != nil { if err != nil {
return err return err
} }
@@ -399,7 +401,7 @@ func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error
if err != nil { if err != nil {
return fmt.Errorf("parse workout kind rules: %w", err) return fmt.Errorf("parse workout kind rules: %w", err)
} }
profile, err := s.db.GetProfile(ctx) profile, err := s.db.GetProfile(ctx, s.userID)
if err != nil { if err != nil {
return fmt.Errorf("load profile: %w", err) return fmt.Errorf("load profile: %w", err)
} }
@@ -416,7 +418,7 @@ func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error
return err return err
} }
_, err = s.db.InsertKindAssignment(ctx, store.KindAssignment{ _, err = s.db.InsertKindAssignment(ctx, s.userID, store.KindAssignment{
ActivityID: activityID, ActivityID: activityID,
WorkoutKindID: result.WorkoutKindID, WorkoutKindID: result.WorkoutKindID,
AssignmentSource: store.AssignmentSourceRuleEngine, AssignmentSource: store.AssignmentSourceRuleEngine,

View File

@@ -28,17 +28,26 @@ func fixedNow(t time.Time) func() time.Time {
return func() time.Time { return t } return func() time.Time { return t }
} }
func provisionTestUser(t *testing.T, db *store.DB) int64 {
t.Helper()
userID, err := db.ProvisionUser(context.Background(), "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
return userID
}
// setBackfillHorizon sets Profile.BackfillHorizonDays, which Backfill reads // setBackfillHorizon sets Profile.BackfillHorizonDays, which Backfill reads
// fresh on every call (it's no longer part of Config). // fresh on every call (it's no longer part of Config).
func setBackfillHorizon(t *testing.T, db *store.DB, days int) { func setBackfillHorizon(t *testing.T, db *store.DB, userID int64, days int) {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
profile, err := db.GetProfile(ctx) profile, err := db.GetProfile(ctx, userID)
if err != nil { if err != nil {
t.Fatalf("GetProfile: %v", err) t.Fatalf("GetProfile: %v", err)
} }
profile.BackfillHorizonDays = days profile.BackfillHorizonDays = days
if err := db.UpdateProfile(ctx, profile); err != nil { if err := db.UpdateProfile(ctx, userID, profile); err != nil {
t.Fatalf("UpdateProfile: %v", err) t.Fatalf("UpdateProfile: %v", err)
} }
} }
@@ -46,18 +55,19 @@ func setBackfillHorizon(t *testing.T, db *store.DB, days int) {
func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) { func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{ m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"}, {ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"},
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
}} }}
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil { if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err) t.Fatalf("Backfill: %v", err)
} }
activities, err := db.ListActivities(ctx, store.ActivityFilter{}) activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil { if err != nil {
t.Fatalf("ListActivities: %v", err) t.Fatalf("ListActivities: %v", err)
} }
@@ -68,7 +78,7 @@ func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
t.Errorf("GarminActivityID = %d, want 1", activities[0].GarminActivityID) t.Errorf("GarminActivityID = %d, want 1", activities[0].GarminActivityID)
} }
runs, err := db.ListSyncRuns(ctx, 10) runs, err := db.ListSyncRuns(ctx, userID, 10)
if err != nil { if err != nil {
t.Fatalf("ListSyncRuns: %v", err) t.Fatalf("ListSyncRuns: %v", err)
} }
@@ -188,6 +198,7 @@ func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
func TestBackfill_SkipsNonRunningActivities(t *testing.T) { func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{ m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, {ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"},
@@ -199,13 +210,13 @@ func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
{ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"}, {ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"},
StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800}, StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800},
}} }}
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil { if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err) t.Fatalf("Backfill: %v", err)
} }
activities, err := db.ListActivities(ctx, store.ActivityFilter{}) activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil { if err != nil {
t.Fatalf("ListActivities: %v", err) t.Fatalf("ListActivities: %v", err)
} }
@@ -222,6 +233,7 @@ func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) { func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 42 const garminActivityID = 42
m := &mock.Client{ m := &mock.Client{
@@ -244,7 +256,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
}, },
}, },
} }
svc := NewService(m, db, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) svc := NewService(m, db, userID, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil { if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err) t.Fatalf("Backfill: %v", err)
@@ -252,7 +264,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
// A workout kind that should cleanly match the seeded activity's pace. // A workout kind that should cleanly match the seeded activity's pace.
ruleJSON := `{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[280,320]}]}` ruleJSON := `{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[280,320]}]}`
if _, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Classification Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil { if _, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Classification Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil {
t.Fatalf("CreateWorkoutKind: %v", err) t.Fatalf("CreateWorkoutKind: %v", err)
} }
@@ -260,7 +272,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
t.Fatalf("FillPendingDetails: %v", err) t.Fatalf("FillPendingDetails: %v", err)
} }
activities, err := db.ListActivities(ctx, store.ActivityFilter{}) activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil || len(activities) != 1 { if err != nil || len(activities) != 1 {
t.Fatalf("ListActivities: %v, %+v", err, activities) t.Fatalf("ListActivities: %v, %+v", err, activities)
} }
@@ -273,12 +285,12 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
t.Error("expected SplitsFetchedAt to be set after FillPendingDetails") t.Error("expected SplitsFetchedAt to be set after FillPendingDetails")
} }
laps, err := db.LapsForActivity(ctx, activityID) laps, err := db.LapsForActivity(ctx, userID, activityID)
if err != nil || len(laps) != 1 { if err != nil || len(laps) != 1 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps) t.Fatalf("LapsForActivity: %v, %+v", err, laps)
} }
assignment, ok, err := db.CurrentAssignment(ctx, activityID) assignment, ok, err := db.CurrentAssignment(ctx, userID, activityID)
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err) t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
} }
@@ -290,6 +302,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) { func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 55 const garminActivityID = 55
const workoutID = 999 const workoutID = 999
@@ -313,7 +326,7 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
}}, }},
}, },
} }
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil { if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err) t.Fatalf("Backfill: %v", err)
@@ -322,11 +335,11 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
t.Fatalf("FillPendingDetails: %v", err) t.Fatalf("FillPendingDetails: %v", err)
} }
activities, err := db.ListActivities(ctx, store.ActivityFilter{}) activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil || len(activities) != 1 { if err != nil || len(activities) != 1 {
t.Fatalf("ListActivities: %v, %+v", err, activities) t.Fatalf("ListActivities: %v, %+v", err, activities)
} }
laps, err := db.LapsForActivity(ctx, activities[0].ID) laps, err := db.LapsForActivity(ctx, userID, activities[0].ID)
if err != nil || len(laps) != 1 { if err != nil || len(laps) != 1 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps) t.Fatalf("LapsForActivity: %v, %+v", err, laps)
} }
@@ -341,6 +354,7 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) { func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 56 const garminActivityID = 56
const workoutID = 1000 const workoutID = 1000
@@ -369,7 +383,7 @@ func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.
}}, }},
}, },
} }
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil { if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err) t.Fatalf("Backfill: %v", err)
@@ -378,8 +392,8 @@ func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.
t.Fatalf("FillPendingDetails: %v", err) t.Fatalf("FillPendingDetails: %v", err)
} }
activities, _ := db.ListActivities(ctx, store.ActivityFilter{}) activities, _ := db.ListActivities(ctx, userID, store.ActivityFilter{})
laps, err := db.LapsForActivity(ctx, activities[0].ID) laps, err := db.LapsForActivity(ctx, userID, activities[0].ID)
if err != nil || len(laps) != 2 { if err != nil || len(laps) != 2 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps) t.Fatalf("LapsForActivity: %v, %+v", err, laps)
} }
@@ -443,13 +457,14 @@ func TestBuildMetricContext_DerivesIsRace(t *testing.T) {
func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) { func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{ m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500}, {ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}} }}
svc := NewService(m, db, Config{BackfillWindowDays: 10}, svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, 10) setBackfillHorizon(t, db, userID, 10)
if err := svc.Backfill(ctx); err != nil { if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err) t.Fatalf("first Backfill: %v", err)
@@ -459,7 +474,7 @@ func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
t.Fatal("expected first backfill to call GetActivities at least once") t.Fatal("expected first backfill to call GetActivities at least once")
} }
state, err := db.GetSyncState(ctx) state, err := db.GetSyncState(ctx, userID)
if err != nil { if err != nil {
t.Fatalf("GetSyncState: %v", err) t.Fatalf("GetSyncState: %v", err)
} }
@@ -479,13 +494,14 @@ func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) { func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{ m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500}, {ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}} }}
svc := NewService(m, db, Config{BackfillWindowDays: 10}, svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, 10) setBackfillHorizon(t, db, userID, 10)
if err := svc.Backfill(ctx); err != nil { if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err) t.Fatalf("first Backfill: %v", err)
@@ -495,7 +511,7 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
if err := svc.ResetAll(ctx); err != nil { if err := svc.ResetAll(ctx); err != nil {
t.Fatalf("ResetAll: %v", err) t.Fatalf("ResetAll: %v", err)
} }
activities, err := db.ListActivities(ctx, store.ActivityFilter{}) activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil { if err != nil {
t.Fatalf("ListActivities: %v", err) t.Fatalf("ListActivities: %v", err)
} }
@@ -509,7 +525,7 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
if m.GetActivitiesCalls <= firstCallCount { if m.GetActivitiesCalls <= firstCallCount {
t.Errorf("expected Backfill after ResetAll to call GetActivities again (fresh pull), call count stayed at %d", m.GetActivitiesCalls) 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{}) activities, err = db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil { if err != nil {
t.Fatalf("ListActivities after re-backfill: %v", err) t.Fatalf("ListActivities after re-backfill: %v", err)
} }
@@ -521,14 +537,15 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) { func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{ m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500}, {ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}} }}
now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)) now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))
svc := NewService(m, db, Config{BackfillWindowDays: 10}, now) svc := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
setBackfillHorizon(t, db, 10) setBackfillHorizon(t, db, userID, 10)
if err := svc.Backfill(ctx); err != nil { if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err) t.Fatalf("first Backfill: %v", err)
} }
@@ -537,8 +554,8 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
// Simulate the user widening the horizon later -- should resume from the // Simulate the user widening the horizon later -- should resume from the
// watermark (not re-fetch the already-covered recent window) but still // watermark (not re-fetch the already-covered recent window) but still
// make progress toward the new, deeper horizon. // make progress toward the new, deeper horizon.
setBackfillHorizon(t, db, 30) setBackfillHorizon(t, db, userID, 30)
svc2 := NewService(m, db, Config{BackfillWindowDays: 10}, now) svc2 := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
if err := svc2.Backfill(ctx); err != nil { if err := svc2.Backfill(ctx); err != nil {
t.Fatalf("second Backfill: %v", err) t.Fatalf("second Backfill: %v", err)
} }
@@ -546,7 +563,7 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
t.Errorf("expected additional GetActivities calls when horizon grows, got %d total (was %d)", m.GetActivitiesCalls, firstCallCount) t.Errorf("expected additional GetActivities calls when horizon grows, got %d total (was %d)", m.GetActivitiesCalls, firstCallCount)
} }
state, err := db.GetSyncState(ctx) state, err := db.GetSyncState(ctx, userID)
if err != nil { if err != nil {
t.Fatalf("GetSyncState: %v", err) t.Fatalf("GetSyncState: %v", err)
} }
@@ -558,6 +575,7 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) { func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{ m := &mock.Client{
Activities: []garmin.Activity{ Activities: []garmin.Activity{
@@ -571,15 +589,15 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
1: {ActivityID: 1}, 2: {ActivityID: 2}, 1: {ActivityID: 1}, 2: {ActivityID: 2},
}, },
} }
svc := NewService(m, db, Config{BackfillWindowDays: 10}, svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, 10) setBackfillHorizon(t, db, userID, 10)
if err := svc.FullSync(ctx, 10); err != nil { if err := svc.FullSync(ctx, 10); err != nil {
t.Fatalf("FullSync: %v", err) t.Fatalf("FullSync: %v", err)
} }
runs, err := db.ListSyncRuns(ctx, 10) runs, err := db.ListSyncRuns(ctx, userID, 10)
if err != nil { if err != nil {
t.Fatalf("ListSyncRuns: %v", err) t.Fatalf("ListSyncRuns: %v", err)
} }
@@ -606,7 +624,7 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
t.Errorf("ActivitiesFetched = %d, want 2 (genuinely new activities, deduped across stages)", run.ActivitiesFetched) t.Errorf("ActivitiesFetched = %d, want 2 (genuinely new activities, deduped across stages)", run.ActivitiesFetched)
} }
activities, err := db.ListActivities(ctx, store.ActivityFilter{}) activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil { if err != nil {
t.Fatalf("ListActivities: %v", err) t.Fatalf("ListActivities: %v", err)
} }
@@ -618,6 +636,7 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) { func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{ m := &mock.Client{
Activities: []garmin.Activity{}, Activities: []garmin.Activity{},
@@ -634,7 +653,7 @@ func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
m.Details[i] = garmin.ActivityDetails{ActivityID: i} m.Details[i] = garmin.ActivityDetails{ActivityID: i}
} }
svc := NewService(m, db, Config{InterCallDelay: 150 * time.Millisecond}, svc := NewService(m, db, userID, Config{InterCallDelay: 150 * time.Millisecond},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil { if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err) t.Fatalf("Backfill: %v", err)
@@ -663,3 +682,47 @@ func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
t.Errorf("Progress after completion = %+v, want zero value (idle)", final) t.Errorf("Progress after completion = %+v, want zero value (idle)", final)
} }
} }
func TestService_TwoUsersSyncIndependently(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
mA := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
}}
mB := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 8000, Duration: 2400},
}}
svcA := NewService(mA, db, userA, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svcB := NewService(mB, db, userB, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svcA.Backfill(ctx); err != nil {
t.Fatalf("Backfill(a): %v", err)
}
if err := svcB.Backfill(ctx); err != nil {
t.Fatalf("Backfill(b): %v", err)
}
activitiesA, err := db.ListActivities(ctx, userA, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities(a): %v", err)
}
activitiesB, err := db.ListActivities(ctx, userB, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities(b): %v", err)
}
if len(activitiesA) != 1 || activitiesA[0].GarminActivityID != 1 {
t.Fatalf("userA's activities = %+v, want exactly garmin id 1", activitiesA)
}
if len(activitiesB) != 1 || activitiesB[0].GarminActivityID != 2 {
t.Fatalf("userB's activities = %+v, want exactly garmin id 2", activitiesB)
}
}