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:
@@ -52,15 +52,15 @@ type Activity struct {
|
|||||||
// UpsertActivity inserts a new activity or updates the existing row for the
|
// 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
|
// same garmin_activity_id (idempotent, safe to call on every sync pass), and
|
||||||
// returns its internal id.
|
// 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, `
|
_, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO activities (
|
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,
|
duration_seconds, distance_meters, avg_hr, max_hr,
|
||||||
avg_speed_mps, elevation_gain_m,
|
avg_speed_mps, elevation_gain_m,
|
||||||
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
|
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
|
||||||
raw_json, updated_at
|
raw_json, updated_at
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
|
||||||
ON CONFLICT(garmin_activity_id) DO UPDATE SET
|
ON CONFLICT(garmin_activity_id) DO UPDATE SET
|
||||||
event_type_key=excluded.event_type_key,
|
event_type_key=excluded.event_type_key,
|
||||||
workout_id=excluded.workout_id,
|
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,
|
raw_json=excluded.raw_json,
|
||||||
updated_at=datetime('now')
|
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.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
|
||||||
a.AvgSpeedMps, a.ElevationGainM,
|
a.AvgSpeedMps, a.ElevationGainM,
|
||||||
a.AerobicTrainingEffect, a.AnaerobicTrainingEffect, a.VO2MaxValue,
|
a.AerobicTrainingEffect, a.AnaerobicTrainingEffect, a.VO2MaxValue,
|
||||||
a.RawJSON,
|
a.RawJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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
|
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 0, fmt.Errorf("fetch id for activity %d: %w", a.GarminActivityID, err)
|
||||||
}
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
@@ -137,10 +137,10 @@ type ActivityFilter struct {
|
|||||||
Offset int
|
Offset int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListActivities returns activities newest-first, optionally filtered by date range.
|
// ListActivities returns activities for userID newest-first, optionally filtered by date range.
|
||||||
func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity, error) {
|
func (db *DB) ListActivities(ctx context.Context, userID int64, f ActivityFilter) ([]Activity, error) {
|
||||||
query := `SELECT ` + activityColumns + ` FROM activities WHERE 1=1`
|
query := `SELECT ` + activityColumns + ` FROM activities WHERE user_id = ?`
|
||||||
var args []any
|
args := []any{userID}
|
||||||
if f.FromDate != "" {
|
if f.FromDate != "" {
|
||||||
query += ` AND start_time_utc >= ?`
|
query += ` AND start_time_utc >= ?`
|
||||||
args = append(args, f.FromDate)
|
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...)
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -81,10 +81,10 @@ const profileColumns = `
|
|||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
// GetProfile returns the single profile row.
|
// GetProfile returns the profile row for userID.
|
||||||
func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
|
func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
|
||||||
var p Profile
|
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.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
|
||||||
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
|
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
|
||||||
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
|
&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,
|
&p.CreatedAt, &p.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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
|
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
|
// GetProfile first and modify the fields they intend to change, since this
|
||||||
// replaces every column.
|
// 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, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE profile SET
|
UPDATE profile SET
|
||||||
name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
|
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=?,
|
pace_color=?, heart_rate_color=?, warmup_color=?, effort_color=?, recovery_color=?, cooldown_color=?,
|
||||||
main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
|
main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
|
||||||
updated_at=datetime('now')
|
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.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
|
||||||
p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
|
p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
|
||||||
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
|
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.MinRepresentativePaceSecPerKm, p.MinRepresentativeTimeSeconds,
|
||||||
p.PaceColor, p.HeartRateColor, p.WarmupColor, p.EffortColor, p.RecoveryColor, p.CooldownColor,
|
p.PaceColor, p.HeartRateColor, p.WarmupColor, p.EffortColor, p.RecoveryColor, p.CooldownColor,
|
||||||
p.MainLineTintPct, p.BackgroundDarkenPct, p.TargetBrightenPct,
|
p.MainLineTintPct, p.BackgroundDarkenPct, p.TargetBrightenPct,
|
||||||
|
userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update profile: %w", err)
|
return fmt.Errorf("update profile for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ import (
|
|||||||
func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GetProfile: %v", err)
|
t.Fatalf("GetProfile: %v", err)
|
||||||
}
|
}
|
||||||
@@ -64,11 +68,11 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
|||||||
p.MinRepresentativePaceSecPerKm = 600
|
p.MinRepresentativePaceSecPerKm = 600
|
||||||
p.MinRepresentativeTimeSeconds = 5
|
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)
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := db.GetProfile(ctx)
|
got, err := db.GetProfile(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetProfile after update: %v", err)
|
t.Fatalf("GetProfile after update: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,23 @@ import (
|
|||||||
"fmt"
|
"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
|
// activity_samples, and kind_assignments via ON DELETE CASCADE) and rewinds
|
||||||
// the backfill watermark to its initial state, so a subsequent Backfill
|
// the backfill watermark to its initial state, so a subsequent Backfill
|
||||||
// starts a genuinely fresh pull instead of thinking history is already
|
// starts a genuinely fresh pull instead of thinking history is already
|
||||||
// covered. Workout kinds (the user's taxonomy) are left untouched.
|
// 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)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin reset tx: %w", err)
|
return fmt.Errorf("begin reset tx: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx, `DELETE FROM activities`); err != nil {
|
if _, err := tx.ExecContext(ctx, `DELETE FROM activities WHERE user_id = ?`, userID); err != nil {
|
||||||
return fmt.Errorf("delete activities: %w", err)
|
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 {
|
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: %w", err)
|
return fmt.Errorf("reset sync state for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import (
|
|||||||
func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *testing.T) {
|
func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||||
}
|
}
|
||||||
@@ -26,15 +30,15 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("InsertKindAssignment: %v", err)
|
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)
|
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)
|
t.Fatalf("ResetAllSyncedData: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
activities, err := db.ListActivities(ctx, ActivityFilter{})
|
activities, err := db.ListActivities(ctx, userID, ActivityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GetSyncState: %v", err)
|
t.Fatalf("GetSyncState: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ func TestMigrateIsIdempotent(t *testing.T) {
|
|||||||
func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
a := Activity{
|
a := Activity{
|
||||||
GarminActivityID: 23554066504,
|
GarminActivityID: 23554066504,
|
||||||
@@ -51,13 +55,13 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
|||||||
RawJSON: `{"activityId":23554066504,"activityName":"Auriol - W2-5-Base Endurance","activityType":{"typeKey":"running"}}`,
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity (insert): %v", err)
|
t.Fatalf("UpsertActivity (insert): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
a.AvgHR = f(150) // simulate a re-sync with a corrected value
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity (update): %v", err)
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
t.Fatalf("ListActivities: %v", err)
|
||||||
}
|
}
|
||||||
@@ -85,8 +89,12 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
|||||||
func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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,
|
GarminActivityID: 1,
|
||||||
StartTimeUTC: "2026-07-11 05:00:00",
|
StartTimeUTC: "2026-07-11 05:00:00",
|
||||||
RawJSON: "{}",
|
RawJSON: "{}",
|
||||||
@@ -95,7 +103,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
t.Fatalf("UpsertActivity: %v", err)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
kindID, err := db.CreateWorkoutKind(ctx, WorkoutKind{
|
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{
|
||||||
Name: "Test Assignment Custom",
|
Name: "Test Assignment Custom",
|
||||||
RuleJSON: `{"match":"all","conditions":[]}`,
|
RuleJSON: `{"match":"all","conditions":[]}`,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
@@ -169,8 +177,12 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
func TestReplaceLapsIsIdempotent(t *testing.T) {
|
func TestReplaceLapsIsIdempotent(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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,
|
GarminActivityID: 2,
|
||||||
StartTimeUTC: "2026-07-11 05:00:00",
|
StartTimeUTC: "2026-07-11 05:00:00",
|
||||||
RawJSON: "{}",
|
RawJSON: "{}",
|
||||||
@@ -245,8 +257,13 @@ func TestForeignKeyEnforcementPostMigration(t *testing.T) {
|
|||||||
// migrations 0023/0024/0025) is properly scoped and re-enabled after each
|
// migrations 0023/0024/0025) is properly scoped and re-enabled after each
|
||||||
// table rebuild.
|
// 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.
|
// Insert an activity that we can reference.
|
||||||
activityID, err := db.UpsertActivity(ctx, Activity{
|
activityID, err := db.UpsertActivity(ctx, userID, Activity{
|
||||||
GarminActivityID: 1001,
|
GarminActivityID: 1001,
|
||||||
StartTimeUTC: "2026-07-11 05:00:00",
|
StartTimeUTC: "2026-07-11 05:00:00",
|
||||||
RawJSON: "{}",
|
RawJSON: "{}",
|
||||||
|
|||||||
@@ -13,25 +13,24 @@ type SyncState struct {
|
|||||||
BackfillComplete bool // true once backfill has reached the configured horizon (or Garmin's own history start)
|
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,
|
// GetSyncState returns the current backfill watermark for userID.
|
||||||
// created by migration 0002).
|
func (db *DB) GetSyncState(ctx context.Context, userID int64) (SyncState, error) {
|
||||||
func (db *DB) GetSyncState(ctx context.Context) (SyncState, error) {
|
|
||||||
var s SyncState
|
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)
|
Scan(&s.EarliestSyncedDate, &s.BackfillComplete)
|
||||||
if err != nil {
|
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
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSyncState records progress of a backfill run.
|
// UpdateSyncState records progress of a backfill run for userID.
|
||||||
func (db *DB) UpdateSyncState(ctx context.Context, earliestSyncedDate string, complete bool) error {
|
func (db *DB) UpdateSyncState(ctx context.Context, userID int64, earliestSyncedDate string, complete bool) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE id = 1`,
|
UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE user_id = ?`,
|
||||||
earliestSyncedDate, complete)
|
earliestSyncedDate, complete, userID)
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ import (
|
|||||||
func TestSyncState_DefaultsAndUpdate(t *testing.T) {
|
func TestSyncState_DefaultsAndUpdate(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GetSyncState: %v", err)
|
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)
|
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)
|
t.Fatalf("UpdateSyncState: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err = db.GetSyncState(ctx)
|
state, err = db.GetSyncState(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetSyncState after update: %v", err)
|
t.Fatalf("GetSyncState after update: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
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.
|
// 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, `
|
res, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active)
|
INSERT INTO workout_kinds (user_id, name, description, color, rule_json, priority, is_active)
|
||||||
VALUES (?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?)`,
|
||||||
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
|
userID, k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
|
||||||
if err != nil {
|
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()
|
return res.LastInsertId()
|
||||||
}
|
}
|
||||||
@@ -78,18 +78,18 @@ func (db *DB) GetWorkoutKindByName(ctx context.Context, name string) (WorkoutKin
|
|||||||
return k, true, nil
|
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.
|
// (is_active=0) kinds are excluded.
|
||||||
func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutKind, error) {
|
func (db *DB) ListWorkoutKinds(ctx context.Context, userID int64, activeOnly bool) ([]WorkoutKind, error) {
|
||||||
query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds`
|
query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds WHERE user_id = ?`
|
||||||
if activeOnly {
|
if activeOnly {
|
||||||
query += ` WHERE is_active = 1`
|
query += ` AND is_active = 1`
|
||||||
}
|
}
|
||||||
query += ` ORDER BY priority DESC, name`
|
query += ` ORDER BY priority DESC, name`
|
||||||
|
|
||||||
rows, err := db.QueryContext(ctx, query)
|
rows, err := db.QueryContext(ctx, query, userID)
|
||||||
if err != nil {
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ import (
|
|||||||
func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
|
func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ListWorkoutKinds: %v", err)
|
t.Fatalf("ListWorkoutKinds: %v", err)
|
||||||
}
|
}
|
||||||
@@ -42,8 +46,12 @@ func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
|
|||||||
func TestWorkoutTaxonomy_ListedInFixedDisplayOrder(t *testing.T) {
|
func TestWorkoutTaxonomy_ListedInFixedDisplayOrder(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ListWorkoutKinds: %v", err)
|
t.Fatalf("ListWorkoutKinds: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user