store: collapse migrations into a single schema.sql, drop legacy-owner path
Pre-production app, no need to preserve incremental migration history: replace the 26 migration files with one current-state schema.sql (the schema.sql comments are now the living documentation), simplify db.go to apply it once instead of tracking/rebuilding through schema_migrations, and make user_id NOT NULL everywhere now that there's no staged migration to accommodate a nullable backfill window. This removes the reason ClaimLegacyOwner/GENIUSRUN_LEGACY_OWNER_OIDC_SUB existed (binding a pre-existing singleton-schema database to one account across a staged migration), so that whole path is gone too -- the existing dev database was wiped and reseeded fresh under the new schema. Add cmd/dumpschema, which regenerates docs/DATABASE.md straight from the live schema (via store.Open + sqlite_master introspection) so the database documentation can never drift out of sync with reality. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
264
docs/DATABASE.md
Normal file
264
docs/DATABASE.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# 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`](#users)
|
||||
- [`profile`](#profile)
|
||||
- [`workout_kinds`](#workout_kinds)
|
||||
- [`workout_type_paces`](#workout_type_paces)
|
||||
- [`activities`](#activities)
|
||||
- [`laps`](#laps)
|
||||
- [`activity_samples`](#activity_samples)
|
||||
- [`kind_assignments`](#kind_assignments)
|
||||
- [`sync_state`](#sync_state)
|
||||
- [`sync_runs`](#sync_runs)
|
||||
|
||||
## `users`
|
||||
|
||||
```sql
|
||||
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`
|
||||
|
||||
```sql
|
||||
CREATE TABLE profile (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL DEFAULT 'Default',
|
||||
garmin_email TEXT NOT NULL DEFAULT '',
|
||||
garmin_password TEXT NOT NULL DEFAULT '',
|
||||
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`
|
||||
|
||||
```sql
|
||||
CREATE TABLE workout_kinds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
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`
|
||||
|
||||
```sql
|
||||
CREATE TABLE workout_type_paces (
|
||||
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id),
|
||||
pace_min_sec_per_km REAL,
|
||||
pace_max_sec_per_km REAL,
|
||||
hr_min_pct_hrr REAL,
|
||||
hr_max_pct_hrr REAL
|
||||
);
|
||||
```
|
||||
|
||||
## `activities`
|
||||
|
||||
```sql
|
||||
CREATE TABLE activities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
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,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, garmin_activity_id)
|
||||
);
|
||||
```
|
||||
|
||||
Indexes:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
|
||||
```
|
||||
|
||||
## `laps`
|
||||
|
||||
```sql
|
||||
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`
|
||||
|
||||
```sql
|
||||
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:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_activity_samples_activity ON activity_samples(activity_id, elapsed_seconds);
|
||||
```
|
||||
|
||||
## `kind_assignments`
|
||||
|
||||
```sql
|
||||
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:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_kind_assignments_activity ON kind_assignments(activity_id);
|
||||
CREATE INDEX idx_kind_assignments_status ON kind_assignments(status);
|
||||
```
|
||||
|
||||
## `sync_state`
|
||||
|
||||
```sql
|
||||
CREATE TABLE sync_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
earliest_synced_date TEXT,
|
||||
backfill_complete INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
```
|
||||
|
||||
## `sync_runs`
|
||||
|
||||
```sql
|
||||
CREATE TABLE sync_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
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`
|
||||
|
||||
```sql
|
||||
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;
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user