Files
geniusrun/docs/DATABASE.md
Christophe Vila 7d371219b7 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.
2026-07-27 18:58:21 +02:00

10 KiB

geniusrun database schema

Generated from the live schema via go run ./cmd/dumpschema -- do not hand-edit. The source of truth is backend/internal/store/schema.sql; regenerate this file after changing it.

Tables

users

CREATE TABLE users (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    oidc_sub     TEXT NOT NULL UNIQUE,
    display_name TEXT NOT NULL,
    created_at   TEXT NOT NULL DEFAULT (datetime('now'))
);

profile

CREATE TABLE profile (
    id                  INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id             INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    name                TEXT NOT NULL DEFAULT 'Default',
    garmin_email        TEXT NOT NULL DEFAULT '',
    garmin_password     TEXT NOT NULL DEFAULT '',
    -- Set once, the first time this user successfully authenticates with
    -- Garmin (handleAuthLogin/handleAuthMFA). Null means never connected --
    -- the login gate uses this (not the in-memory auth status, which
    -- resets on restart) to decide whether a returning user must
    -- reconnect before entering the app.
    garmin_connected_at TEXT,
    rolling_window_days INTEGER NOT NULL DEFAULT 90,
    -- BackfillHorizonDays bounds how far back "Sync now" reaches when
    -- walking backward from today; read fresh on every sync (not cached at
    -- server startup), so changing it here takes effect on the next click.
    backfill_horizon_days INTEGER NOT NULL DEFAULT 1095,
    max_heart_rate      REAL,
    resting_heart_rate  REAL,
    hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
    hr_zone1_max_pct REAL NOT NULL DEFAULT 60,
    hr_zone2_min_pct REAL NOT NULL DEFAULT 60,
    hr_zone2_max_pct REAL NOT NULL DEFAULT 70,
    hr_zone3_min_pct REAL NOT NULL DEFAULT 70,
    hr_zone3_max_pct REAL NOT NULL DEFAULT 80,
    hr_zone4_min_pct REAL NOT NULL DEFAULT 80,
    hr_zone4_max_pct REAL NOT NULL DEFAULT 90,
    hr_zone5_min_pct REAL NOT NULL DEFAULT 90,
    hr_zone5_max_pct REAL NOT NULL DEFAULT 100,
    -- Applies uniformly to every fixed-duration workout type's phase
    -- detection (Easy, Long, Tempo, Threshold 30'/60', MAS Test). Interval
    -- workouts detect phases from lap data directly and don't use these.
    warmup_minutes   REAL NOT NULL DEFAULT 10,
    cooldown_minutes REAL NOT NULL DEFAULT 5,
    -- Review Queue pace-chart artifact filter: a stretch of samples slower
    -- than min_representative_pace_sec_per_km is dropped unless it persists
    -- for at least min_representative_time_seconds, in which case it's a
    -- real stop/walk break rather than GPS/motion noise at recording start.
    min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720,
    min_representative_time_seconds    REAL NOT NULL DEFAULT 3,
    -- Chart colors: pace_color/heart_rate_color are each chart's "main
    -- line" color; warmup/effort/recovery/cooldown_color are the "effort
    -- kind" colors the frontend derives fills from (see
    -- ExpectedVsActualChart).
    pace_color       TEXT NOT NULL DEFAULT '#3b82f6',
    heart_rate_color TEXT NOT NULL DEFAULT '#ef4444',
    warmup_color     TEXT NOT NULL DEFAULT '#c2410c',
    effort_color     TEXT NOT NULL DEFAULT '#7c3aed',
    recovery_color   TEXT NOT NULL DEFAULT '#15803d',
    cooldown_color   TEXT NOT NULL DEFAULT '#fb923c',
    main_line_tint_pct    REAL NOT NULL DEFAULT 20,
    background_darken_pct REAL NOT NULL DEFAULT 35,
    target_brighten_pct   REAL NOT NULL DEFAULT 20,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at TEXT NOT NULL DEFAULT (datetime('now')),
    UNIQUE(user_id)
);

workout_kinds

CREATE TABLE workout_kinds (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id     INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    name        TEXT NOT NULL,
    description TEXT NOT NULL DEFAULT '',
    color       TEXT NOT NULL DEFAULT '',
    rule_json   TEXT NOT NULL,
    priority    INTEGER NOT NULL DEFAULT 0,
    is_active   INTEGER NOT NULL DEFAULT 1,
    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at  TEXT NOT NULL DEFAULT (datetime('now')),
    UNIQUE(user_id, name)
);

workout_type_paces

CREATE TABLE workout_type_paces (
    workout_kind_id     INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
    pace_min_sec_per_km REAL,
    pace_max_sec_per_km REAL,
    hr_min_pct_hrr      REAL,
    hr_max_pct_hrr      REAL
);

activities

CREATE TABLE activities (
    id                         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id                    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    garmin_activity_id         INTEGER NOT NULL,
    -- Derived at sync time from Garmin's own eventType.typeKey=="race" --
    -- a hard fact, not a rule the user tunes (see internal/classify's
    -- is_race metric and handleReclassifyAll's Race special-case).
    event_type_key             TEXT NOT NULL DEFAULT '',
    -- Set when the activity was recorded from a structured Garmin workout;
    -- used to resolve each lap's expected pace/HR band (see
    -- internal/sync/mapping.go's alignWorkoutTargets).
    workout_id                 INTEGER,
    start_time_utc             TEXT NOT NULL,
    duration_seconds           REAL NOT NULL,
    distance_meters            REAL NOT NULL,
    avg_hr                     REAL,
    max_hr                     REAL,
    avg_speed_mps              REAL,
    elevation_gain_m           REAL,
    aerobic_training_effect    REAL,
    anaerobic_training_effect  REAL,
    vo2max_value               REAL,
    raw_json                   TEXT NOT NULL,
    details_fetched_at         TEXT,
    details_raw_json           TEXT,
    splits_fetched_at          TEXT,
    -- 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)
);

Indexes:

CREATE INDEX idx_activities_start_time ON activities(start_time_utc);

laps

CREATE TABLE laps (
    id                      INTEGER PRIMARY KEY AUTOINCREMENT,
    activity_id             INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
    lap_index               INTEGER NOT NULL,
    avg_speed_mps           REAL,
    intensity_type          TEXT NOT NULL DEFAULT '',
    hr_drift_bpm_per_min    REAL,
    hr_recovery_bpm_per_min REAL,
    -- Expected band for this lap, resolved from the activity's structured
    -- Garmin workout when its steps line up 1:1 with the recorded laps.
    -- Null when there's no structured workout or the counts don't match.
    target_pace_low_mps  REAL,
    target_pace_high_mps REAL,
    target_hr_low_bpm    REAL,
    target_hr_high_bpm   REAL,
    raw_json                TEXT NOT NULL,
    UNIQUE(activity_id, lap_index)
);

activity_samples

CREATE TABLE activity_samples (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    activity_id     INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
    elapsed_seconds REAL NOT NULL,
    timestamp_ms    INTEGER NOT NULL,
    heart_rate      REAL,
    speed_mps       REAL,
    distance_m      REAL,
    elevation_m     REAL
);

Indexes:

CREATE INDEX idx_activity_samples_activity ON activity_samples(activity_id, elapsed_seconds);

kind_assignments

CREATE TABLE kind_assignments (
    id                   INTEGER PRIMARY KEY AUTOINCREMENT,
    activity_id          INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
    workout_kind_id      INTEGER REFERENCES workout_kinds(id),
    assignment_source    TEXT NOT NULL CHECK(assignment_source IN ('rule_engine','manual')),
    status               TEXT NOT NULL CHECK(status IN ('assigned','needs_review')),
    confidence           REAL,
    candidate_kinds_json TEXT NOT NULL DEFAULT '[]',
    created_at           TEXT NOT NULL DEFAULT (datetime('now'))
);

Indexes:

CREATE INDEX idx_kind_assignments_activity ON kind_assignments(activity_id);
CREATE INDEX idx_kind_assignments_status ON kind_assignments(status);

sync_state

CREATE TABLE sync_state (
    id                   INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id              INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    earliest_synced_date TEXT,
    backfill_complete    INTEGER NOT NULL DEFAULT 0,
    UNIQUE(user_id)
);

sync_runs

CREATE TABLE sync_runs (
    id                 INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id            INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    kind               TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
    started_at         TEXT NOT NULL,
    finished_at        TEXT,
    activities_fetched INTEGER NOT NULL DEFAULT 0,
    status             TEXT NOT NULL CHECK(status IN ('running','success','error')),
    error_message      TEXT
);

Views

current_kind_assignment

CREATE VIEW current_kind_assignment AS
SELECT a.* FROM kind_assignments a
JOIN (
    SELECT activity_id, MAX(id) AS max_id
    FROM kind_assignments GROUP BY activity_id
) latest ON a.activity_id = latest.activity_id AND a.id = latest.max_id;