store: scope GetProfile/UpdateProfile to a user_id

Part of per-user profile isolation: profile rows are no longer a global
singleton, so every read/write requires the caller's userID. This also
requires scoping ListActivities, ListWorkoutKinds, UpsertActivity,
CreateWorkoutKind, GetSyncState, UpdateSyncState, and ResetAllSyncedData
to userID, plus updating all related tests in the store package.
This commit is contained in:
2026-07-25 13:17:17 +02:00
parent b0a7462ebe
commit ef410863c6
10 changed files with 104 additions and 67 deletions

View File

@@ -52,15 +52,15 @@ type Activity struct {
// UpsertActivity inserts a new activity or updates the existing row for the
// same garmin_activity_id (idempotent, safe to call on every sync pass), and
// returns its internal id.
func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
func (db *DB) UpsertActivity(ctx context.Context, userID int64, a Activity) (int64, error) {
_, err := db.ExecContext(ctx, `
INSERT INTO activities (
garmin_activity_id, event_type_key, workout_id, start_time_utc,
user_id, garmin_activity_id, event_type_key, workout_id, start_time_utc,
duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, elevation_gain_m,
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
raw_json, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
ON CONFLICT(garmin_activity_id) DO UPDATE SET
event_type_key=excluded.event_type_key,
workout_id=excluded.workout_id,
@@ -77,18 +77,18 @@ func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
raw_json=excluded.raw_json,
updated_at=datetime('now')
`,
a.GarminActivityID, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC,
userID, a.GarminActivityID, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC,
a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
a.AvgSpeedMps, a.ElevationGainM,
a.AerobicTrainingEffect, a.AnaerobicTrainingEffect, a.VO2MaxValue,
a.RawJSON,
)
if err != nil {
return 0, fmt.Errorf("upsert activity %d: %w", a.GarminActivityID, err)
return 0, fmt.Errorf("upsert activity %d for user %d: %w", a.GarminActivityID, userID, err)
}
var id int64
if err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ?`, a.GarminActivityID).Scan(&id); err != nil {
if err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ? AND user_id = ?`, a.GarminActivityID, userID).Scan(&id); err != nil {
return 0, fmt.Errorf("fetch id for activity %d: %w", a.GarminActivityID, err)
}
return id, nil
@@ -137,10 +137,10 @@ type ActivityFilter struct {
Offset int
}
// ListActivities returns activities newest-first, optionally filtered by date range.
func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity, error) {
query := `SELECT ` + activityColumns + ` FROM activities WHERE 1=1`
var args []any
// ListActivities returns activities for userID newest-first, optionally filtered by date range.
func (db *DB) ListActivities(ctx context.Context, userID int64, f ActivityFilter) ([]Activity, error) {
query := `SELECT ` + activityColumns + ` FROM activities WHERE user_id = ?`
args := []any{userID}
if f.FromDate != "" {
query += ` AND start_time_utc >= ?`
args = append(args, f.FromDate)
@@ -157,7 +157,7 @@ func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity,
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list activities: %w", err)
return nil, fmt.Errorf("list activities for user %d: %w", userID, err)
}
defer rows.Close()

View File

@@ -81,10 +81,10 @@ const profileColumns = `
created_at, updated_at
`
// GetProfile returns the single profile row.
func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
// GetProfile returns the profile row for userID.
func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
var p Profile
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE id = 1`).Scan(
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE user_id = ?`, userID).Scan(
&p.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
@@ -96,15 +96,15 @@ func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
&p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return Profile{}, fmt.Errorf("get profile: %w", err)
return Profile{}, fmt.Errorf("get profile for user %d: %w", userID, err)
}
return p, nil
}
// UpdateProfile overwrites the single profile row. Callers should read via
// UpdateProfile overwrites userID's profile row. Callers should read via
// GetProfile first and modify the fields they intend to change, since this
// replaces every column.
func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error {
_, err := db.ExecContext(ctx, `
UPDATE profile SET
name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
@@ -116,7 +116,7 @@ func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
pace_color=?, heart_rate_color=?, warmup_color=?, effort_color=?, recovery_color=?, cooldown_color=?,
main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
updated_at=datetime('now')
WHERE id = 1`,
WHERE user_id = ?`,
p.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
@@ -125,9 +125,10 @@ func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
p.MinRepresentativePaceSecPerKm, p.MinRepresentativeTimeSeconds,
p.PaceColor, p.HeartRateColor, p.WarmupColor, p.EffortColor, p.RecoveryColor, p.CooldownColor,
p.MainLineTintPct, p.BackgroundDarkenPct, p.TargetBrightenPct,
userID,
)
if err != nil {
return fmt.Errorf("update profile: %w", err)
return fmt.Errorf("update profile for user %d: %w", userID, err)
}
return nil
}

View File

@@ -8,8 +8,12 @@ import (
func TestProfile_DefaultsThenUpdate(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Default")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
p, err := db.GetProfile(ctx)
p, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
@@ -64,11 +68,11 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
p.MinRepresentativePaceSecPerKm = 600
p.MinRepresentativeTimeSeconds = 5
if err := db.UpdateProfile(ctx, p); err != nil {
if err := db.UpdateProfile(ctx, userID, p); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
got, err := db.GetProfile(ctx)
got, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile after update: %v", err)
}

View File

@@ -5,23 +5,23 @@ import (
"fmt"
)
// ResetAllSyncedData deletes every synced activity (cascading to its laps,
// ResetAllSyncedData deletes every synced activity for userID (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 {
func (db *DB) ResetAllSyncedData(ctx context.Context, userID int64) 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, `DELETE FROM activities WHERE user_id = ?`, userID); err != nil {
return fmt.Errorf("delete activities for user %d: %w", userID, 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)
if _, err := tx.ExecContext(ctx, `UPDATE sync_state SET earliest_synced_date = NULL, backfill_complete = 0 WHERE user_id = ?`, userID); err != nil {
return fmt.Errorf("reset sync state for user %d: %w", userID, err)
}
return tx.Commit()
}

View File

@@ -8,12 +8,16 @@ import (
func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
activityID, err := db.UpsertActivity(ctx, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
activityID, err := db.UpsertActivity(ctx, userID, Activity{GarminActivityID: 1, 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})
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{Name: "Test Reset Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
@@ -26,15 +30,15 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
}); err != nil {
t.Fatalf("InsertKindAssignment: %v", err)
}
if err := db.UpdateSyncState(ctx, "2020-01-01", true); err != nil {
if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil {
t.Fatalf("UpdateSyncState: %v", err)
}
if err := db.ResetAllSyncedData(ctx); err != nil {
if err := db.ResetAllSyncedData(ctx, userID); err != nil {
t.Fatalf("ResetAllSyncedData: %v", err)
}
activities, err := db.ListActivities(ctx, ActivityFilter{})
activities, err := db.ListActivities(ctx, userID, ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
@@ -52,7 +56,7 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
t.Errorf("expected kind assignment to cascade-delete, ok=%v err=%v", ok, err)
}
state, err := db.GetSyncState(ctx)
state, err := db.GetSyncState(ctx, userID)
if err != nil {
t.Fatalf("GetSyncState: %v", err)
}

View File

@@ -41,6 +41,10 @@ func TestMigrateIsIdempotent(t *testing.T) {
func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
a := Activity{
GarminActivityID: 23554066504,
@@ -51,13 +55,13 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
RawJSON: `{"activityId":23554066504,"activityName":"Auriol - W2-5-Base Endurance","activityType":{"typeKey":"running"}}`,
}
id, err := db.UpsertActivity(ctx, a)
id, err := db.UpsertActivity(ctx, userID, a)
if err != nil {
t.Fatalf("UpsertActivity (insert): %v", err)
}
a.AvgHR = f(150) // simulate a re-sync with a corrected value
id2, err := db.UpsertActivity(ctx, a)
id2, err := db.UpsertActivity(ctx, userID, a)
if err != nil {
t.Fatalf("UpsertActivity (update): %v", err)
}
@@ -73,7 +77,7 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
t.Errorf("AvgHR = %v, want 150 (update should have applied)", *got.AvgHR)
}
all, err := db.ListActivities(ctx, ActivityFilter{})
all, err := db.ListActivities(ctx, userID, ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
@@ -85,8 +89,12 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
activityID, err := db.UpsertActivity(ctx, Activity{
activityID, err := db.UpsertActivity(ctx, userID, Activity{
GarminActivityID: 1,
StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}",
@@ -95,7 +103,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
t.Fatalf("UpsertActivity: %v", err)
}
kindID, err := db.CreateWorkoutKind(ctx, WorkoutKind{
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{
Name: "Test Assignment Custom",
RuleJSON: `{"match":"all","conditions":[]}`,
IsActive: true,
@@ -169,8 +177,12 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
func TestReplaceLapsIsIdempotent(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
activityID, err := db.UpsertActivity(ctx, Activity{
activityID, err := db.UpsertActivity(ctx, userID, Activity{
GarminActivityID: 2,
StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}",
@@ -245,8 +257,13 @@ func TestForeignKeyEnforcementPostMigration(t *testing.T) {
// migrations 0023/0024/0025) is properly scoped and re-enabled after each
// table rebuild.
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
// Insert an activity that we can reference.
activityID, err := db.UpsertActivity(ctx, Activity{
activityID, err := db.UpsertActivity(ctx, userID, Activity{
GarminActivityID: 1001,
StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}",

View File

@@ -13,25 +13,24 @@ type SyncState struct {
BackfillComplete bool // true once backfill has reached the configured horizon (or Garmin's own history start)
}
// GetSyncState returns the current backfill watermark (the singleton row,
// created by migration 0002).
func (db *DB) GetSyncState(ctx context.Context) (SyncState, error) {
// GetSyncState returns the current backfill watermark for userID.
func (db *DB) GetSyncState(ctx context.Context, userID int64) (SyncState, error) {
var s SyncState
err := db.QueryRowContext(ctx, `SELECT earliest_synced_date, backfill_complete FROM sync_state WHERE id = 1`).
err := db.QueryRowContext(ctx, `SELECT earliest_synced_date, backfill_complete FROM sync_state WHERE user_id = ?`, userID).
Scan(&s.EarliestSyncedDate, &s.BackfillComplete)
if err != nil {
return SyncState{}, fmt.Errorf("get sync state: %w", err)
return SyncState{}, fmt.Errorf("get sync state for user %d: %w", userID, err)
}
return s, nil
}
// UpdateSyncState records progress of a backfill run.
func (db *DB) UpdateSyncState(ctx context.Context, earliestSyncedDate string, complete bool) error {
// UpdateSyncState records progress of a backfill run for userID.
func (db *DB) UpdateSyncState(ctx context.Context, userID int64, earliestSyncedDate string, complete bool) error {
_, err := db.ExecContext(ctx, `
UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE id = 1`,
earliestSyncedDate, complete)
UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE user_id = ?`,
earliestSyncedDate, complete, userID)
if err != nil {
return fmt.Errorf("update sync state: %w", err)
return fmt.Errorf("update sync state for user %d: %w", userID, err)
}
return nil
}

View File

@@ -8,8 +8,12 @@ import (
func TestSyncState_DefaultsAndUpdate(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
state, err := db.GetSyncState(ctx)
state, err := db.GetSyncState(ctx, userID)
if err != nil {
t.Fatalf("GetSyncState: %v", err)
}
@@ -17,11 +21,11 @@ func TestSyncState_DefaultsAndUpdate(t *testing.T) {
t.Fatalf("expected fresh DB to have no watermark, got %+v", state)
}
if err := db.UpdateSyncState(ctx, "2023-01-01", true); err != nil {
if err := db.UpdateSyncState(ctx, userID, "2023-01-01", true); err != nil {
t.Fatalf("UpdateSyncState: %v", err)
}
state, err = db.GetSyncState(ctx)
state, err = db.GetSyncState(ctx, userID)
if err != nil {
t.Fatalf("GetSyncState after update: %v", err)
}

View File

@@ -29,13 +29,13 @@ func scanWorkoutKind(row interface{ Scan(...any) error }) (WorkoutKind, error) {
const workoutKindColumns = `id, name, description, color, rule_json, priority, is_active, created_at, updated_at`
// CreateWorkoutKind inserts a new workout kind and returns its id.
func (db *DB) CreateWorkoutKind(ctx context.Context, k WorkoutKind) (int64, error) {
func (db *DB) CreateWorkoutKind(ctx context.Context, userID int64, k WorkoutKind) (int64, error) {
res, err := db.ExecContext(ctx, `
INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active)
VALUES (?,?,?,?,?,?)`,
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
INSERT INTO workout_kinds (user_id, name, description, color, rule_json, priority, is_active)
VALUES (?,?,?,?,?,?,?)`,
userID, k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
if err != nil {
return 0, fmt.Errorf("create workout kind %q: %w", k.Name, err)
return 0, fmt.Errorf("create workout kind %q for user %d: %w", k.Name, userID, err)
}
return res.LastInsertId()
}
@@ -78,18 +78,18 @@ func (db *DB) GetWorkoutKindByName(ctx context.Context, name string) (WorkoutKin
return k, true, nil
}
// ListWorkoutKinds returns workout kinds. If activeOnly, soft-deleted
// ListWorkoutKinds returns workout kinds for userID. If activeOnly, soft-deleted
// (is_active=0) kinds are excluded.
func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutKind, error) {
query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds`
func (db *DB) ListWorkoutKinds(ctx context.Context, userID int64, activeOnly bool) ([]WorkoutKind, error) {
query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds WHERE user_id = ?`
if activeOnly {
query += ` WHERE is_active = 1`
query += ` AND is_active = 1`
}
query += ` ORDER BY priority DESC, name`
rows, err := db.QueryContext(ctx, query)
rows, err := db.QueryContext(ctx, query, userID)
if err != nil {
return nil, fmt.Errorf("list workout kinds: %w", err)
return nil, fmt.Errorf("list workout kinds for user %d: %w", userID, err)
}
defer rows.Close()

View File

@@ -8,8 +8,12 @@ import (
func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
kinds, err := db.ListWorkoutKinds(ctx, true)
kinds, err := db.ListWorkoutKinds(ctx, userID, true)
if err != nil {
t.Fatalf("ListWorkoutKinds: %v", err)
}
@@ -42,8 +46,12 @@ func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
func TestWorkoutTaxonomy_ListedInFixedDisplayOrder(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
kinds, err := db.ListWorkoutKinds(ctx, true)
kinds, err := db.ListWorkoutKinds(ctx, userID, true)
if err != nil {
t.Fatalf("ListWorkoutKinds: %v", err)
}