Compare commits

...

4 Commits

Author SHA1 Message Date
0ff6793854 store: enable WAL journal mode
Lets an external reader (sqlite3 CLI, DB Browser, DataGrip) inspect the
database file concurrently without "database is locked" errors while
geniusrund is running. Doesn't change in-process concurrency -- queries
are already fully serialized via SetMaxOpenConns(1).

auth: fix flaky tampered-cookie tests

Both tests corrupted a signed cookie by blindly overwriting its last
character with "x", which is occasionally a no-op if that character (part
of the token's signature, so effectively randomized by the embedded
timestamp) already happened to be "x" -- silently passing without having
tampered with anything. Confirmed via 15 repeated runs (3 spurious passes)
before the fix and 30 clean runs after. flipLastChar now guarantees the
byte actually changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 19:33:15 +02:00
644d87f1dc 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>
2026-07-25 19:24:19 +02:00
0f2101bce5 docs: add IDEAS.md for informal backlog, link from CLAUDE.md
A lightweight, low-friction place to jot future development ideas before
they're formalized into a spec/plan -- distinct from
docs/superpowers/specs/ and docs/superpowers/plans/, which are for once an
idea is ready to be built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 19:07:48 +02:00
15c2b6a2b6 docs: update CLAUDE.md for per-user profile isolation
Reflects the just-merged per-user-profile work: every table is now
user_id-scoped, OIDC accounts get their own isolated dataset via
resolveUser/requireProvisionedUser + POST /api/setup, Garmin sessions are
namespaced per user, and the legacy-owner upgrade bootstrap. Supersedes the
old "single shared profile" framing throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 19:05:09 +02:00
40 changed files with 707 additions and 1115 deletions

View File

@@ -6,9 +6,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
geniusrun (Go module `geniusrun/backend`) is a personal web app that pulls running activities from Garmin Connect (via the `mcp-garmin` MCP server), classifies each run into one of a fixed set of running-specific "workout kinds" (Easy, Long, 60' Threshold, 30' Threshold, Tempo, Intervals, MAS Test, Race), and charts progression over time per kind. Ambiguous runs (matching zero, multiple, or only weakly one kind) go to a manual review queue instead of being silently misclassified.
All Garmin credentials and every tunable analysis-engine parameter (HR zones, phase-detection minutes, pace-artifact filtering, chart colors, etc.) live in a single-row `profile` table, edited from the frontend's Profile screen — there is no multi-profile support, and `internal/config`'s env vars are limited to process-level plumbing (listen addr, DB path, mcp-garmin subprocess paths).
All Garmin credentials and every tunable analysis-engine parameter (HR zones, phase-detection minutes, pace-artifact filtering, chart colors, etc.) live in a `profile` row scoped to the logged-in user, edited from the frontend's Profile screen. Every table holding synced/tunable data (`profile`, `workout_kinds`/`workout_type_paces`, `activities` and everything under it, `sync_state`/`sync_runs`) is scoped to a `user_id`, so each OIDC account gets its own fully isolated dataset — see Authentication below. There's no admin UI or profile switcher, and `internal/config`'s env vars are limited to process-level plumbing (listen addr, DB path, mcp-garmin subprocess paths).
The "Training plan" tab (`frontend/src/pages/Plan.tsx`) is an intentional empty stub — a future training-recommendation engine (analyzing training-effect balance across kinds, and a pace/HR-zone "delta" between declared targets and workout-derived reality) is deferred; see `docs/superpowers/specs/` and `docs/superpowers/plans/` for the design history behind what's already built and what's still open.
The "Training plan" tab (`frontend/src/pages/Plan.tsx`) is an intentional empty stub — a future training-recommendation engine (analyzing training-effect balance across kinds, and a pace/HR-zone "delta" between declared targets and workout-derived reality) is deferred; see `docs/superpowers/specs/` and `docs/superpowers/plans/` for the design history behind what's already built and what's still open, and `docs/IDEAS.md` for informal, not-yet-formalized ideas for what's next — check it at the start of a session for context on where to pick up.
## Commands
@@ -20,7 +20,7 @@ Backend (from `backend/`):
- Single package: `go test ./internal/store/...`
- Single test: `go test ./internal/store/... -run TestProfile -v`
- Seed sample data (no live Garmin account needed): `go run ./cmd/seedsample -db /tmp/sample.db`, then point `geniusrund` at that DB via `GENIUSRUN_DB_PATH`.
- Migrations live in `internal/store/migrations/`; add a new numbered file, never edit an already-applied one (the runner tracks applied filenames in a `schema_migrations` table).
- The schema lives in one file, `internal/store/schema.sql`, applied in full on every `Open()` (idempotent -- skipped if the `users` table already exists). There is no migration history: this is a pre-production app with no compatibility obligation to older database files, so edit `schema.sql` directly rather than appending a migration. After changing it, regenerate the schema doc: `go run ./cmd/dumpschema` (writes `docs/DATABASE.md` from the live schema, so it can't drift out of sync).
Frontend (from `frontend/`):
- Run dev server: `./start.sh` (puts Homebrew's `node@22` on `PATH` and runs `npm install` if `node_modules` is missing) or `npm run dev` directly. Set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`.
@@ -28,11 +28,15 @@ Frontend (from `frontend/`):
- Lint: `npm run lint` (oxlint)
- No frontend test suite exists yet.
## Authentication
## Authentication & per-user data model
geniusrun requires login via an existing Keycloak realm (OIDC Authorization Code flow, backend-driven -- the browser never sees a Keycloak token, only geniusrun's own signed session cookie). This is an access gate only: every authenticated+authorized user reaches the same single profile/dataset described above, there is no per-user data scoping. Access additionally requires a specific realm role (`GENIUSRUN_OIDC_REQUIRED_ROLE`, default `geniusrun-user`) -- a successful Keycloak login alone is not sufficient if the realm is shared with other apps/users.
geniusrun requires login via an existing Keycloak realm (OIDC Authorization Code flow, backend-driven -- the browser never sees a Keycloak token, only geniusrun's own signed session cookie). Access additionally requires a specific realm role (`GENIUSRUN_OIDC_REQUIRED_ROLE`, default `geniusrun-user`) -- a successful Keycloak login alone is not sufficient if the realm is shared with other apps/users.
Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_PUBLIC_BASE_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_OIDC_REQUIRED_ROLE`, `GENIUSRUN_SESSION_DURATION` (default `720h`). See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the full design and `internal/auth` for the implementation.
Beyond the login gate, every authenticated+authorized OIDC subject maps 1:1 to its own geniusrun `users` row and its own fully isolated dataset — there is no shared profile/dataset across accounts, no admin UI, and no profile switcher. `internal/api/usercontext.go`'s `resolveUser` middleware resolves the session's OIDC subject to a `store.User` (never blocking by itself); `requireProvisionedUser` 403s every data route until one exists. Every handler derives `userID` **only** from `userIDFromContext(r.Context())` — never a URL param, query string, or request body field; this is the entire security boundary the isolation depends on. See `docs/superpowers/plans/2026-07-25-per-user-profile.md` for the full design/implementation history, including the store/API-layer scoping pattern and the bugs it took to get right (a mid-transaction SQLite `PRAGMA foreign_keys` no-op, and a chi middleware-ordering panic).
A brand-new OIDC subject with no `users` row yet is routed to a "Create your profile" screen (`frontend/src/CreateProfile.tsx`) instead of the app. `POST /api/setup` (display name only) provisions it — a `users` row, a default `profile` row, the 8-kind workout taxonomy, and an initial `sync_state` row, all in one transaction (`store.ProvisionUser`). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install.
Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_PUBLIC_BASE_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_OIDC_REQUIRED_ROLE`, `GENIUSRUN_SESSION_DURATION` (default `720h`). See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer).
## Repo layout
@@ -40,16 +44,18 @@ Required env vars (`config.Load()` fails fast if any are unset, same pattern as
backend/
cmd/geniusrund/ main server entrypoint
cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds
cmd/dumpschema/ regenerates docs/DATABASE.md from the live schema -- run after editing schema.sql
cmd/mcpspike/ throwaway MCP-client spike, safe to delete
internal/garmin/ MCP client wrapper (auth, get_activities, get_activity_splits, get_activity_details, get_workout_by_id)
internal/garmin/mock/ fake Client for tests
internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery)
internal/store/ SQLite layer + embedded migrations
internal/sync/ orchestrates fetch -> store -> classify
internal/api/ HTTP handlers (chi router)
internal/store/ SQLite layer; schema.sql is the whole schema (no migration history), users.go owns account provisioning
internal/sync/ orchestrates fetch -> store -> classify; one Service instance per user
internal/api/ HTTP handlers (chi router); usercontext.go/setup.go own the per-user access boundary
internal/config/ env var config loading (process-level only, not user-tunable params)
frontend/
src/pages/ ReviewQueue ("Activities" tab), Dashboard ("Progression" tab), Plan (stub), Profile (reached via the profile-name button, not a tab)
src/CreateProfile.tsx "Create your profile" screen shown to a brand-new, unprovisioned OIDC session
src/components/charts/ Recharts wrappers (ExpectedVsActualChart, ProgressionChart)
src/components/ ColorField, PaceField, NullableNumberField, RawDataModal, TrainingTypesCard
src/api/client.ts thin typed fetch client
@@ -86,33 +92,37 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
- **`authenticate()`/`complete_mfa()` return plain strings**, not structured JSON (`"Authenticated successfully."`, `"MFA required. ..."`, `"Authentication failed: ..."`). `internal/garmin/client.go`'s `parseAuthResult` pattern-matches these.
- **The 10s "MFA required" timeout in mcp-garmin's `authenticate()` is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (mcp-garmin now logs this to stderr for exactly this reason).
- **mcp-garmin persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var (default `~/.garth`) passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing.
- **mcp-garmin persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun became multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` now defaults it to a `garmin-tokenstores` directory next to the DB file (logged at startup) rather than leaving per-user namespacing silently skipped.
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
- **`get_activity_details()` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries, despite what its docstring used to say. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
- **`get_activity_splits()`** returns the actual lap/split summaries (`lapDTOs`).
- **`get_workout_by_id()`** returns a structured Garmin workout's flattened steps (target pace/HR per step); `internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each lap's expected pace/HR band.
- Garmin credentials are set via `garmin.Client.UpdateCredentials` when the profile is saved, which resets the client's "started" state and terminates any already-spawned subprocess so the next call respawns fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow.
- Each user has their own lazily-built, cached `garmin.Client` (`api.Server.garminFor`, double-checked-locked, keyed by `userID`). Saving that user's profile calls `UpdateCredentials` on their own cached client instance, which resets only that client's "started" state and terminates only that user's already-spawned subprocess so the next call respawns fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow, and no risk of one user's credential change touching another's session.
## Data model conventions
See `docs/DATABASE.md` for the full, always-current schema (every table/column/index, regenerated via `go run ./cmd/dumpschema` -- see Commands above). The conventions below explain the *why* behind it; the doc itself is the ground truth for the *what*.
- **Every table is scoped to a `user_id`, one way or another.** `users` (keyed by OIDC subject) anchors it. `profile`, `workout_kinds`, `activities`, `sync_state`, `sync_runs` each carry their own `user_id` column and are queried with an explicit `WHERE user_id = ?`. `laps`, `activity_samples`, `kind_assignments`, and `workout_type_paces` have **no `user_id` column of their own** — they're always accessed through a specific owning row (an activity or a workout kind) via a `JOIN`/subquery back to that row's `user_id`, since they're never queried except through that owner. Every `internal/store` method that touches any of this takes an explicit `userID` parameter used in a real `WHERE`/`JOIN` clause — accepting the parameter without using it to filter would be a real cross-user leak, not a style nit.
- `kind_assignments` is **append-only** — always INSERT, never UPDATE. Re-classifying after a rule edit, or a manual override, keeps full history; `current_kind_assignment` (a view) picks the latest row per activity by `id`. `assignment_source` distinguishes `manual` overrides (locked against future global reclassifies) from rule-engine assignments.
- **Raw-JSON is the source of truth for anything not actively computed on.** `activities.raw_json`/`details_raw_json`/`workout_raw_json` store the full original Garmin JSON. Fields that are a pure untransformed copy of something already in `raw_json` (e.g. `activity_name`, `activity_type`, lap `duration_seconds`/`avg_hr`) were dropped from their own columns entirely (migration `0013_dedup_activity_lap_columns.sql`) and are instead decoded fresh at API-response time by `internal/api/display_fields.go` (`decodeActivityDisplayFields`/`decodeLapDisplayFields`) — don't reintroduce a stored column for something derivable from `raw_json` alone.
- `garmin_activity_id` is the natural idempotency key for `UpsertActivity` (`ON CONFLICT ... DO UPDATE`), safe to re-run on every sync pass.
- `sync_state` (singleton row) tracks a **backfill watermark** (`earliest_synced_date`, `backfill_complete`) — since Garmin history is immutable once recorded, `Service.Backfill` uses this to resume from where it left off (or no-op entirely if the configured horizon is already fully covered). `Backfill` reads `Profile.BackfillHorizonDays` fresh on every call (not a fixed `Config` field), so widening it in the Profile page takes effect on the very next sync, no restart needed. "Sync now" (`POST /api/sync/run`) calls `Backfill` then `IncrementalSync` then `FillPendingDetails` in one pass — there's no separate "full backfill" trigger. "Reset all" (`POST /api/sync/reset`) deletes every activity (cascading to laps/samples/kind_assignments) and rewinds the watermark — the only way to get already-synced activities re-processed against newer schema fields, since `FillPendingDetails` only ever touches activities whose details were never fetched.
- Live sync progress is exposed via `Service.Progress()` (in-memory, mutex-guarded `Done`/`Total` counters, reset to zero when idle) and surfaced through `GET /api/sync/status`. The frontend's `GarminConnection` banner polls this and shows "syncing: N/M activities" live.
- The 8-type taxonomy (`workout_kinds`) is a fixed, closed set with a fixed display order (`priority DESC, name`) — there's no create/delete UI, only rule/pace/HR-zone/color editing per type. `workout_type_paces` holds each type's target pace range + expected HR zone, informational only (never read by the classification engine) — no history, overwritten in place, since the synced activity log is the history.
- **Raw-JSON is the source of truth for anything not actively computed on.** `activities.raw_json`/`details_raw_json`/`workout_raw_json` store the full original Garmin JSON. Fields that are a pure untransformed copy of something already in `raw_json` (e.g. `activity_name`, `activity_type`, lap `duration_seconds`/`avg_hr`) are deliberately not modeled as their own columns and are instead decoded fresh at API-response time by `internal/api/display_fields.go` (`decodeActivityDisplayFields`/`decodeLapDisplayFields`) — don't reintroduce a stored column for something derivable from `raw_json` alone.
- `(user_id, garmin_activity_id)` is the natural idempotency key for `UpsertActivity` (`ON CONFLICT ... DO UPDATE`), safe to re-run on every sync pass — scoped by user so two different users' Garmin accounts can never collide even if their activity IDs coincided.
- `sync_state` (one row per user) tracks a **backfill watermark** (`earliest_synced_date`, `backfill_complete`) — since Garmin history is immutable once recorded, `Service.Backfill` uses this to resume from where it left off (or no-op entirely if the configured horizon is already fully covered). `Backfill` reads `Profile.BackfillHorizonDays` fresh on every call (not a fixed `Config` field), so widening it in the Profile page takes effect on the very next sync, no restart needed. "Sync now" (`POST /api/sync/run`) calls `Backfill` then `IncrementalSync` then `FillPendingDetails` in one pass — there's no separate "full backfill" trigger. "Reset all" (`POST /api/sync/reset`) deletes every activity **belonging to that user** (cascading to laps/samples/kind_assignments) and rewinds their watermark — the only way to get already-synced activities re-processed against newer schema fields, since `FillPendingDetails` only ever touches activities whose details were never fetched.
- Live sync progress is exposed via `Service.Progress()` (in-memory, mutex-guarded `Done`/`Total` counters, reset to zero when idle) and surfaced through `GET /api/sync/status`. Since `sync.Service` is one instance per user, this is naturally per-user too. The frontend's `GarminConnection` banner polls this and shows "syncing: N/M activities" live.
- The 8-type taxonomy (`workout_kinds`) is a fixed, closed set with a fixed display order (`priority DESC, name`) — there's no create/delete UI, only rule/pace/HR-zone/color editing per type. Each user gets their own independently-tunable copy of the same 8 kinds, seeded by `store.ProvisionUser` on signup (`UNIQUE(user_id, name)`, not a global `UNIQUE(name)` — the same kind name is expected across different users). `workout_type_paces` holds each type's target pace range + expected HR zone, informational only (never read by the classification engine) — no history, overwritten in place, since the synced activity log is the history.
- Chart colors (pace/HR line colors, warmup/effort/recovery/cooldown phase colors) are user-editable `profile` columns, not hardcoded, consumed by `ExpectedVsActualChart`.
- Pace-artifact filtering (`profile.min_representative_pace_sec_per_km`/`min_representative_time_seconds`) drops brief slow-pace blips (GPS/motion settling at recording start) from the Review Queue chart unless they persist long enough to be a real stop/walk break.
## Dev workflow
- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess.
- No live Garmin account needed for frontend/UI work: `cmd/seedsample` seeds realistic activities/laps/kinds through the real classification engine.
- No live Garmin account needed for frontend/UI work: `cmd/seedsample` provisions one fixed `"seedsample-user"` account (via `store.ProvisionUser`) then seeds realistic activities/laps/kinds under it through the real classification engine.
## Testing conventions
- Table-driven Go tests throughout; test cases are inline, no separate fixture files.
- `internal/classify` tests are pure (no DB/network): construct a `MetricContext` + `RuleKind`s directly.
- `internal/store` and `internal/sync` tests open a real temp-file SQLite DB (`store.Open` against `t.TempDir()`) — deliberate, not mocked, since migration/SQL correctness is exactly what needs catching.
- `internal/api` tests use `httptest` against a `Server` wired to a temp DB + `mock.Client`.
- `internal/store` and `internal/sync` tests open a real temp-file SQLite DB (`store.Open` against `t.TempDir()`) — deliberate, not mocked, since schema/SQL correctness is exactly what needs catching. Since every table is `user_id`-scoped, tests provision a user first (`db.ProvisionUser`) and thread that `userID` into every call.
- `internal/api` tests use `httptest` against a `Server` wired to a temp DB + `mock.Client`; `newTestServer` auto-provisions a `"test-user"` account matching the session cookie `doJSON` mints, so most handler tests don't need to think about provisioning at all. Tests that specifically need an *unprovisioned* session (e.g. the setup flow, or `resolveUser`'s not-found path) build a bare `NewServer` directly instead of using `newTestServer`.
- **Cross-user isolation is tested adversarially, not just in parallel** — `internal/store/isolation_test.go` and `internal/api/isolation_test.go` provision two real users and attempt real reads/writes against the *other* user's real row IDs, asserting on not-found/403/empty-result rather than merely checking two separately-created rows don't collide. Any new per-user-scoped feature should get the same treatment, not just a "two users each see their own data" happy-path test.
- The MCP/Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via `cmd/mcpspike` or the real `geniusrund` auth endpoints.

View File

@@ -0,0 +1,101 @@
// Command dumpschema regenerates docs/DATABASE.md from the real, live
// database schema -- it opens a fresh temp database through store.Open
// (the exact same code path geniusrund itself uses) and introspects
// sqlite_master, so the generated documentation can never drift from what
// the app actually creates. Run it after any change to
// internal/store/schema.sql:
//
// go run ./cmd/dumpschema
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"geniusrun/backend/internal/store"
)
type schemaEntry struct {
typ string // "table" or "view"
name string
tblName string
sql string
}
func main() {
tmpDir, err := os.MkdirTemp("", "geniusrun-dumpschema")
must(err)
defer os.RemoveAll(tmpDir)
db, err := store.Open(filepath.Join(tmpDir, "schema-check.db"))
must(err)
defer db.Close()
rows, err := db.Query(`
SELECT type, name, tbl_name, sql FROM sqlite_master
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'
ORDER BY rowid`)
must(err)
defer rows.Close()
var tables, views []schemaEntry
indexesByTable := map[string][]schemaEntry{}
for rows.Next() {
var e schemaEntry
must(rows.Scan(&e.typ, &e.name, &e.tblName, &e.sql))
switch e.typ {
case "table":
tables = append(tables, e)
case "view":
views = append(views, e)
case "index":
indexesByTable[e.tblName] = append(indexesByTable[e.tblName], e)
}
}
must(rows.Err())
var b strings.Builder
b.WriteString("# geniusrun database schema\n\n")
b.WriteString("Generated from the live schema via `go run ./cmd/dumpschema` -- do not hand-edit. ")
b.WriteString("The source of truth is `backend/internal/store/schema.sql`; regenerate this file after changing it.\n\n")
b.WriteString("## Tables\n\n")
for _, t := range tables {
fmt.Fprintf(&b, "- [`%s`](#%s)\n", t.name, t.name)
}
b.WriteString("\n")
for _, t := range tables {
fmt.Fprintf(&b, "## `%s`\n\n", t.name)
fmt.Fprintf(&b, "```sql\n%s;\n```\n\n", t.sql)
if idxs := indexesByTable[t.name]; len(idxs) > 0 {
b.WriteString("Indexes:\n\n```sql\n")
for _, idx := range idxs {
fmt.Fprintf(&b, "%s;\n", idx.sql)
}
b.WriteString("```\n\n")
}
}
if len(views) > 0 {
b.WriteString("## Views\n\n")
for _, v := range views {
fmt.Fprintf(&b, "### `%s`\n\n", v.name)
fmt.Fprintf(&b, "```sql\n%s;\n```\n\n", v.sql)
}
}
outPath := "../docs/DATABASE.md"
must(os.WriteFile(outPath, []byte(b.String()), 0644))
log.Printf("wrote %s", outPath)
}
func must(err error) {
if err != nil {
log.Fatal(err)
}
}

View File

@@ -31,12 +31,6 @@ func main() {
}
defer db.Close()
if cfg.LegacyOwnerOIDCSub != "" {
if err := db.ClaimLegacyOwner(context.Background(), cfg.LegacyOwnerOIDCSub); err != nil {
log.Fatalf("claim legacy owner: %v", err)
}
}
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
IssuerURL: cfg.OIDCIssuerURL,
ClientID: cfg.OIDCClientID,

View File

@@ -63,7 +63,7 @@ func TestRequireSession_TamperedCookie(t *testing.T) {
if err != nil {
t.Fatalf("mint: %v", err)
}
cookie.Value = cookie.Value[:len(cookie.Value)-1] + "x"
cookie.Value = flipLastChar(cookie.Value)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()

View File

@@ -41,12 +41,27 @@ func TestParseSessionCookie_Tampered(t *testing.T) {
if err != nil {
t.Fatalf("mint: %v", err)
}
cookie.Value = cookie.Value[:len(cookie.Value)-1] + "x"
cookie.Value = flipLastChar(cookie.Value)
if _, err := ParseSessionCookie(cookie, []byte(testSecret)); err == nil {
t.Fatal("expected error for tampered cookie")
}
}
// flipLastChar corrupts a signed token for tamper tests, guaranteeing the
// last character actually changes -- blindly overwriting it with a fixed
// character (e.g. "x") would occasionally be a no-op if that character
// already happened to be there (it's derived from the token's embedded
// timestamp, so this isn't as rare as it sounds), silently passing the
// test without having tampered with anything.
func flipLastChar(s string) string {
last := s[len(s)-1]
replacement := byte('x')
if last == replacement {
replacement = 'y'
}
return s[:len(s)-1] + string(replacement)
}
func TestParseSessionCookie_WrongSecret(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false)
if err != nil {

View File

@@ -52,13 +52,6 @@ type Config struct {
SessionSecret []byte
SessionDuration time.Duration
SessionSecure bool
// LegacyOwnerOIDCSub, if set, is used exactly once at startup (via
// store.ClaimLegacyOwner) to bind this deployment's pre-existing
// single-tenant data to one named OIDC subject after upgrading to
// per-user profiles. Safe to leave set indefinitely -- ClaimLegacyOwner
// no-ops once any user already exists.
LegacyOwnerOIDCSub string
}
// Load reads configuration from environment variables, applying defaults
@@ -78,7 +71,6 @@ func Load() (Config, error) {
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour),
LegacyOwnerOIDCSub: os.Getenv("GENIUSRUN_LEGACY_OWNER_OIDC_SUB"),
}
if cfg.GarminTokenStoreRoot == "" {

View File

@@ -4,27 +4,32 @@ package store
import (
"database/sql"
"embed"
_ "embed"
"fmt"
"io/fs"
"sort"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
//go:embed schema.sql
var schemaSQL string
// DB wraps a *sql.DB opened against a geniusrun SQLite database file, with
// migrations already applied.
// the schema already applied.
type DB struct {
*sql.DB
}
// Open opens (creating if needed) the SQLite database at path and applies
// any migrations that haven't run yet.
// schema.sql if it hasn't been applied yet. There is no migration history --
// this is a pre-production app with no compatibility obligation to older
// database files. Edit schema.sql directly to change the schema.
func Open(path string) (*DB, error) {
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)")
// WAL mode lets an external reader (sqlite3 CLI, DB Browser, DataGrip)
// inspect the file concurrently without "database is locked" errors
// while geniusrund is running -- the app's own queries are already
// fully serialized via SetMaxOpenConns(1) below, so this doesn't change
// in-process concurrency, only cross-process access to the same file.
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, fmt.Errorf("open sqlite database: %w", err)
}
@@ -33,110 +38,32 @@ func Open(path string) (*DB, error) {
sqlDB.SetMaxOpenConns(1)
db := &DB{DB: sqlDB}
if err := db.migrate(); err != nil {
if err := db.applySchema(); err != nil {
sqlDB.Close()
return nil, err
}
return db, nil
}
func (db *DB) migrate() error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create schema_migrations table: %w", err)
}
applied := make(map[string]bool)
rows, err := db.Query(`SELECT filename FROM schema_migrations`)
if err != nil {
return fmt.Errorf("query applied migrations: %w", err)
}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
rows.Close()
return fmt.Errorf("scan applied migration: %w", err)
}
applied[name] = true
}
rows.Close()
entries, err := fs.Glob(migrationsFS, "migrations/*.sql")
if err != nil {
return fmt.Errorf("glob migrations: %w", err)
}
sort.Strings(entries)
for _, entry := range entries {
name := entry[len("migrations/"):]
if applied[name] {
continue
}
content, err := migrationsFS.ReadFile(entry)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
// Migrations 0023, 0024, 0025, and 0026 rebuild tables that have
// incoming foreign keys (kind_assignments/workout_type_paces
// reference workout_kinds; sync_state is referenced by
// activities/sync_runs; laps/activity_samples/kind_assignments
// reference activities). These require temporary FK disable in
// autocommit mode (before the transaction begins) so the DROP
// TABLE succeeds; a mid-transaction PRAGMA is a no-op with
// modernc.org/sqlite.
tableRebuildMigrations := map[string]bool{
"0023_profile_user_scoped.sql": true,
"0024_workout_kinds_user_scoped.sql": true,
"0025_sync_state_user_scoped.sql": true,
"0026_activities_unique_constraint.sql": true,
}
needsFKToggle := tableRebuildMigrations[name]
if needsFKToggle {
if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
return fmt.Errorf("disable foreign keys before migration %s: %w", name, err)
// applySchema runs schema.sql once, the first time this database file is
// opened -- detected by checking whether the users table already exists.
func (db *DB) applySchema() error {
var alreadyApplied int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'`).Scan(&alreadyApplied); err != nil {
return fmt.Errorf("check existing schema: %w", err)
}
if alreadyApplied > 0 {
return nil
}
tx, err := db.Begin()
if err != nil {
if needsFKToggle {
db.Exec(`PRAGMA foreign_keys = ON`)
}
return fmt.Errorf("begin migration tx for %s: %w", name, err)
return fmt.Errorf("begin schema tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(string(content)); err != nil {
tx.Rollback()
if needsFKToggle {
db.Exec(`PRAGMA foreign_keys = ON`)
if _, err := tx.Exec(schemaSQL); err != nil {
return fmt.Errorf("apply schema: %w", err)
}
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
tx.Rollback()
if needsFKToggle {
db.Exec(`PRAGMA foreign_keys = ON`)
}
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
if needsFKToggle {
db.Exec(`PRAGMA foreign_keys = ON`)
}
return fmt.Errorf("commit migration %s: %w", name, err)
}
if needsFKToggle {
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
return fmt.Errorf("enable foreign keys after migration %s: %w", name, err)
}
}
}
return nil
return tx.Commit()
}

View File

@@ -1,54 +0,0 @@
package store
import (
"context"
"fmt"
)
// ClaimLegacyOwner is a one-time upgrade step, not a normal runtime
// operation: it binds every pre-multi-tenancy row (user_id IS NULL, left
// that way by migrations that can't take runtime parameters -- see
// docs/superpowers/specs/2026-07-25-per-user-profile-design.md) to a single
// new user identified by oidcSub. Safe to call on every startup: once the
// users table is non-empty, it's a no-op, so leaving
// GENIUSRUN_LEGACY_OWNER_OIDC_SUB set after the first successful run causes
// no harm.
func (db *DB) ClaimLegacyOwner(ctx context.Context, oidcSub string) error {
var userCount int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
return fmt.Errorf("count users: %w", err)
}
if userCount > 0 {
return nil // already bootstrapped (either claimed already, or real signups exist)
}
var displayName string
err := db.QueryRowContext(ctx, `SELECT name FROM profile WHERE user_id IS NULL LIMIT 1`).Scan(&displayName)
if err != nil {
return fmt.Errorf("find legacy profile: %w", err)
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin claim legacy owner tx: %w", err)
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName)
if err != nil {
return fmt.Errorf("create legacy owner user: %w", err)
}
userID, err := res.LastInsertId()
if err != nil {
return err
}
// table is always one of the fixed literals below, never user input.
for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state", "sync_runs"} {
if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET user_id = ? WHERE user_id IS NULL`, userID); err != nil {
return fmt.Errorf("claim legacy %s rows: %w", table, err)
}
}
return tx.Commit()
}

View File

@@ -1,97 +0,0 @@
package store
import (
"context"
"testing"
)
func TestClaimLegacyOwner_BindsExistingSingletonRowsToOneNewUser(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
// Simulate the pre-migration state: a fresh DB already has one
// migration-seeded profile row (id=1, user_id=NULL) and 8 workout_kinds
// rows (user_id=NULL) -- exactly what a real upgraded deployment looks
// like right after Task 1's migrations run, before any user exists.
if _, err := db.ExecContext(ctx, `UPDATE profile SET name = 'Kriss', garmin_email = 'kriss@example.com' WHERE user_id IS NULL`); err != nil {
t.Fatalf("seed legacy profile: %v", err)
}
if _, err := db.ExecContext(ctx, `INSERT INTO activities (user_id, garmin_activity_id, start_time_utc, duration_seconds, distance_meters, raw_json) VALUES (NULL, 1, '2026-01-01 00:00:00', 1800, 5000, '{}')`); err != nil {
t.Fatalf("seed legacy activity: %v", err)
}
if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil {
t.Fatalf("ClaimLegacyOwner: %v", err)
}
u, found, err := db.GetUserBySub(ctx, "kriss-sub")
if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
}
if u.DisplayName != "Kriss" {
t.Errorf("DisplayName = %q, want %q (from the legacy profile's name column)", u.DisplayName, "Kriss")
}
profile, err := db.GetProfile(ctx, u.ID)
if err != nil {
t.Fatalf("GetProfile(claimed user): %v", err)
}
if profile.GarminEmail != "kriss@example.com" {
t.Errorf("claimed profile GarminEmail = %q, want kriss@example.com", profile.GarminEmail)
}
kinds, err := db.ListWorkoutKinds(ctx, u.ID, false)
if err != nil {
t.Fatalf("ListWorkoutKinds(claimed user): %v", err)
}
if len(kinds) != 8 {
t.Fatalf("expected the 8 legacy workout_kinds rows to be claimed, got %d", len(kinds))
}
activities, err := db.ListActivities(ctx, u.ID, ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities(claimed user): %v", err)
}
if len(activities) != 1 {
t.Fatalf("expected the 1 legacy activity to be claimed, got %d", len(activities))
}
var remainingNullUserIDRows int
for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state"} {
var n int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` WHERE user_id IS NULL`).Scan(&n); err != nil {
t.Fatalf("count NULL user_id in %s: %v", table, err)
}
remainingNullUserIDRows += n
}
if remainingNullUserIDRows != 0 {
t.Errorf("expected every legacy row to be claimed, %d rows still have NULL user_id", remainingNullUserIDRows)
}
}
func TestClaimLegacyOwner_NoOpOnceAUserAlreadyExists(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
firstUserID, err := db.ProvisionUser(ctx, "already-here", "Someone")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
// A second call (simulating a later restart with the env var still set)
// must not create a second user or touch anything.
if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil {
t.Fatalf("ClaimLegacyOwner (should be a no-op): %v", err)
}
if _, found, err := db.GetUserBySub(ctx, "kriss-sub"); err != nil || found {
t.Fatalf("expected no user created for kriss-sub, found=%v err=%v", found, err)
}
users, err := db.ListUsers(ctx)
if err != nil {
t.Fatalf("ListUsers: %v", err)
}
if len(users) != 1 || users[0].ID != firstUserID {
t.Fatalf("expected exactly the 1 pre-existing user to remain, got %+v", users)
}
}

View File

@@ -1,109 +0,0 @@
CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
garmin_activity_id INTEGER NOT NULL UNIQUE,
activity_name TEXT NOT NULL DEFAULT '',
activity_type TEXT NOT NULL,
start_time_utc TEXT NOT NULL,
begin_timestamp_ms INTEGER NOT NULL,
duration_seconds REAL NOT NULL,
distance_meters REAL NOT NULL,
avg_hr REAL,
max_hr REAL,
avg_speed_mps REAL,
max_speed_mps REAL,
elevation_gain_m REAL,
elevation_loss_m REAL,
calories REAL,
lap_count INTEGER NOT NULL DEFAULT 0,
aerobic_training_effect REAL,
anaerobic_training_effect REAL,
training_effect_label TEXT NOT NULL DEFAULT '',
vo2max_value REAL,
hr_time_in_zone_1 REAL,
hr_time_in_zone_2 REAL,
hr_time_in_zone_3 REAL,
hr_time_in_zone_4 REAL,
hr_time_in_zone_5 REAL,
raw_json TEXT NOT NULL,
details_fetched_at TEXT,
details_raw_json TEXT,
splits_fetched_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
CREATE INDEX idx_activities_activity_type ON activities(activity_type);
CREATE TABLE laps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
lap_index INTEGER NOT NULL,
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,
max_speed_mps REAL,
elevation_gain_m REAL,
elevation_loss_m REAL,
intensity_type TEXT NOT NULL DEFAULT '',
hr_drift_bpm_per_min REAL,
hr_recovery_bpm_per_min REAL,
raw_json TEXT NOT NULL,
UNIQUE(activity_id, lap_index)
);
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
);
CREATE INDEX idx_activity_samples_activity ON activity_samples(activity_id, elapsed_seconds);
CREATE TABLE workout_kinds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
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'))
);
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'))
);
CREATE INDEX idx_kind_assignments_activity ON kind_assignments(activity_id);
CREATE INDEX idx_kind_assignments_status ON kind_assignments(status);
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;
CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental')),
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
);

View File

@@ -1,11 +0,0 @@
-- Tracks how far back a full backfill has already reached. Garmin activity
-- history is immutable once recorded, so once we've backfilled a historical
-- window there is no need to ever re-fetch get_activities() for it again --
-- this is the "cache" that lets repeated backfills be cheap/fast instead of
-- re-walking years of history against Garmin's API every time.
CREATE TABLE sync_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
earliest_synced_date TEXT,
backfill_complete INTEGER NOT NULL DEFAULT 0
);
INSERT INTO sync_state (id, earliest_synced_date, backfill_complete) VALUES (1, NULL, 0);

View File

@@ -1,37 +0,0 @@
-- Single-profile settings: Garmin credentials (replacing env-var-only
-- config) and every tunable engine parameter, in one editable row.
CREATE TABLE profile (
id INTEGER PRIMARY KEY CHECK (id = 1),
garmin_email TEXT NOT NULL DEFAULT '',
garmin_password TEXT NOT NULL DEFAULT '',
rolling_window_days INTEGER NOT NULL DEFAULT 90,
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,
easy_warmup_minutes REAL NOT NULL DEFAULT 10,
easy_cooldown_minutes REAL NOT NULL DEFAULT 5,
long_warmup_minutes REAL NOT NULL DEFAULT 10,
long_cooldown_minutes REAL NOT NULL DEFAULT 5,
tempo_warmup_minutes REAL NOT NULL DEFAULT 15,
tempo_cooldown_minutes REAL NOT NULL DEFAULT 10,
threshold30_warmup_minutes REAL NOT NULL DEFAULT 15,
threshold30_cooldown_minutes REAL NOT NULL DEFAULT 10,
threshold60_warmup_minutes REAL NOT NULL DEFAULT 15,
threshold60_cooldown_minutes REAL NOT NULL DEFAULT 10,
mas_test_warmup_minutes REAL NOT NULL DEFAULT 15,
mas_test_cooldown_minutes REAL NOT NULL DEFAULT 5,
interval_warmup_minutes REAL NOT NULL DEFAULT 0,
interval_cooldown_minutes REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO profile (id) VALUES (1);

View File

@@ -1,17 +0,0 @@
-- The taxonomy is now a fixed, closed set (no more user-created "kinds").
-- Any prior arbitrary kinds and their classification history are reset:
-- the concept they classified against no longer exists.
DELETE FROM kind_assignments;
DELETE FROM workout_kinds;
-- Placeholder rule: distance is never negative, so this never matches.
-- Every activity starts in needs_review for every type until real rules
-- are tuned (a follow-up plan, not this migration).
INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active) VALUES
('Easy Run', '', '#22c55e', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
('Long Run', '', '#3b82f6', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
('Threshold 30''', '', '#f59e0b', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
('Threshold 60''', '', '#f59e0b', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
('Tempo', '', '#eab308', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
('Interval', '', '#ef4444', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
('MAS Test', '', '#a855f7', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1);

View File

@@ -1,13 +0,0 @@
-- Per-workout-type target pace range and expected HR zone. Informational
-- only: never read by the classification rule engine (see spec section 2).
-- No history -- overwritten in place when the user updates a value; the
-- synced activity log is the historical record.
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,
expected_hr_zone INTEGER CHECK (expected_hr_zone IS NULL OR expected_hr_zone BETWEEN 1 AND 5)
);
INSERT INTO workout_type_paces (workout_kind_id)
SELECT id FROM workout_kinds;

View File

@@ -1,22 +0,0 @@
-- Phase-detection warm-up/cool-down was originally one setting per workout
-- type (14 columns: 6 types x 2, plus an unused Interval pair -- Interval
-- detects phases from lap data directly, never from a fixed duration). That
-- turned out to be more granularity than wanted: replaced with a single
-- global warm-up/cool-down pair applied to every fixed-duration workout type.
ALTER TABLE profile ADD COLUMN warmup_minutes REAL NOT NULL DEFAULT 10;
ALTER TABLE profile ADD COLUMN cooldown_minutes REAL NOT NULL DEFAULT 5;
ALTER TABLE profile DROP COLUMN easy_warmup_minutes;
ALTER TABLE profile DROP COLUMN easy_cooldown_minutes;
ALTER TABLE profile DROP COLUMN long_warmup_minutes;
ALTER TABLE profile DROP COLUMN long_cooldown_minutes;
ALTER TABLE profile DROP COLUMN tempo_warmup_minutes;
ALTER TABLE profile DROP COLUMN tempo_cooldown_minutes;
ALTER TABLE profile DROP COLUMN threshold30_warmup_minutes;
ALTER TABLE profile DROP COLUMN threshold30_cooldown_minutes;
ALTER TABLE profile DROP COLUMN threshold60_warmup_minutes;
ALTER TABLE profile DROP COLUMN threshold60_cooldown_minutes;
ALTER TABLE profile DROP COLUMN mas_test_warmup_minutes;
ALTER TABLE profile DROP COLUMN mas_test_cooldown_minutes;
ALTER TABLE profile DROP COLUMN interval_warmup_minutes;
ALTER TABLE profile DROP COLUMN interval_cooldown_minutes;

View File

@@ -1,12 +0,0 @@
-- "Race" is an 8th fixed workout kind. Unlike the other 7 (seeded with a
-- never-matching placeholder rule pending manual tuning), it gets a real
-- rule from day one: Garmin Connect lets a user manually tag an activity's
-- event type as "Race", and that value round-trips through get_activities()
-- as eventType.typeKey -- a genuine, deterministic signal, not a guess.
ALTER TABLE activities ADD COLUMN event_type_key TEXT NOT NULL DEFAULT '';
INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active) VALUES
('Race', '', '#dc2626', '{"match":"all","conditions":[{"metric":"is_race","op":"==","value":true}]}', 0, 1);
INSERT INTO workout_type_paces (workout_kind_id)
SELECT id FROM workout_kinds WHERE name = 'Race';

View File

@@ -1,12 +0,0 @@
-- Structured Garmin workouts (created in Garmin Connect or a training plan
-- tool) attach a per-step target pace/HR zone. An activity recorded from one
-- carries that workout's id; when its laps line up 1:1 with the workout's
-- flattened steps (see internal/sync's alignWorkoutTargets), the expected
-- band is resolved and stored per lap so the Review Queue can plot expected
-- vs actual pace/HR without a live Garmin call on every page view.
ALTER TABLE activities ADD COLUMN workout_id INTEGER;
ALTER TABLE laps ADD COLUMN target_pace_low_mps REAL;
ALTER TABLE laps ADD COLUMN target_pace_high_mps REAL;
ALTER TABLE laps ADD COLUMN target_hr_low_bpm REAL;
ALTER TABLE laps ADD COLUMN target_hr_high_bpm REAL;

View File

@@ -1,6 +0,0 @@
-- Backfill horizon used to be a startup-time env var
-- (GENIUSRUN_BACKFILL_HORIZON_DAYS) with no UI at all -- exactly the kind of
-- "tunable analysis-engine parameter" this profile table exists for.
-- Default matches the old env var's default (3 years) so existing
-- deployments keep their current behavior until the user changes it.
ALTER TABLE profile ADD COLUMN backfill_horizon_days INTEGER NOT NULL DEFAULT 1095;

View File

@@ -1,9 +0,0 @@
-- Filters brief pace "artifacts" (e.g. GPS/motion still settling right as
-- recording starts, before the run itself begins) out of the Review
-- Queue's pace chart: 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 treated as a
-- real stop or walk break, not noise. Defaults match the values used to
-- design this feature (12:00/km, 3 seconds).
ALTER TABLE profile ADD COLUMN min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720;
ALTER TABLE profile ADD COLUMN min_representative_time_seconds REAL NOT NULL DEFAULT 3;

View File

@@ -1,3 +0,0 @@
-- Names the profile so a future multi-profile setup can show which one is
-- active. Only one profile row exists today (id=1), so this is just a label.
ALTER TABLE profile ADD COLUMN name TEXT NOT NULL DEFAULT 'Default';

View File

@@ -1,6 +0,0 @@
-- Replaces the named-zone-only expected_hr_zone with a custom %HRR range
-- per training type (e.g. "easy runs at 70-80% heart rate reserve"),
-- matching how pace range is already modeled. expected_hr_zone is left in
-- place but unused going forward -- nothing reads or writes it anymore.
ALTER TABLE workout_type_paces ADD COLUMN hr_min_pct_hrr REAL;
ALTER TABLE workout_type_paces ADD COLUMN hr_max_pct_hrr REAL;

View File

@@ -1,35 +0,0 @@
-- Phase 1 of the raw-JSON duplication cleanup: drops every Activity/Lap
-- column that was a pure untransformed copy of a value already present in
-- that row's own raw_json, with zero SQL/classify/functional consumer
-- anywhere in the app (see the 2026-07 field-by-field duplication audit).
-- Unlike this project's usual additive-only migrations, dropping these
-- columns outright is the whole point of this one -- leaving them inert
-- would keep the exact duplication being removed. activity_name/
-- activity_type (Activity) and duration_seconds/avg_hr (laps) also go here
-- even though the frontend still displays them: they're now decoded from
-- raw_json at API-response time instead of stored separately (see
-- internal/api's decodeActivityDisplayFields/decodeLapDisplayFields).
DROP INDEX idx_activities_activity_type;
ALTER TABLE activities DROP COLUMN activity_name;
ALTER TABLE activities DROP COLUMN activity_type;
ALTER TABLE activities DROP COLUMN begin_timestamp_ms;
ALTER TABLE activities DROP COLUMN max_speed_mps;
ALTER TABLE activities DROP COLUMN elevation_loss_m;
ALTER TABLE activities DROP COLUMN calories;
ALTER TABLE activities DROP COLUMN lap_count;
ALTER TABLE activities DROP COLUMN training_effect_label;
ALTER TABLE activities DROP COLUMN hr_time_in_zone_1;
ALTER TABLE activities DROP COLUMN hr_time_in_zone_2;
ALTER TABLE activities DROP COLUMN hr_time_in_zone_3;
ALTER TABLE activities DROP COLUMN hr_time_in_zone_4;
ALTER TABLE activities DROP COLUMN hr_time_in_zone_5;
ALTER TABLE laps DROP COLUMN start_time_utc;
ALTER TABLE laps DROP COLUMN duration_seconds;
ALTER TABLE laps DROP COLUMN distance_meters;
ALTER TABLE laps DROP COLUMN avg_hr;
ALTER TABLE laps DROP COLUMN max_hr;
ALTER TABLE laps DROP COLUMN max_speed_mps;
ALTER TABLE laps DROP COLUMN elevation_gain_m;
ALTER TABLE laps DROP COLUMN elevation_loss_m;

View File

@@ -1,10 +0,0 @@
-- User-configurable chart colors: 2 "main line" colors (one per metric) and
-- 4 "effort kind" colors (one per workout phase), used together to derive
-- the pace/HR chart's line, under-the-line fill, and phase-background fill
-- colors (see frontend's ExpectedVsActualChart).
ALTER TABLE profile ADD COLUMN pace_color TEXT NOT NULL DEFAULT '#3b82f6';
ALTER TABLE profile ADD COLUMN heart_rate_color TEXT NOT NULL DEFAULT '#ef4444';
ALTER TABLE profile ADD COLUMN warmup_color TEXT NOT NULL DEFAULT '#c2410c';
ALTER TABLE profile ADD COLUMN effort_color TEXT NOT NULL DEFAULT '#7c3aed';
ALTER TABLE profile ADD COLUMN recovery_color TEXT NOT NULL DEFAULT '#15803d';
ALTER TABLE profile ADD COLUMN cooldown_color TEXT NOT NULL DEFAULT '#fb923c';

View File

@@ -1,18 +0,0 @@
-- Renames the default training-type taxonomy to a fixed display convention
-- (drop the redundant "Run" suffix, numeral before "Threshold", "Intervals"
-- not "Interval") and assigns explicit priorities so kinds always list in
-- this exact order wherever they're shown (Activities filters, Progression's
-- kind picker, Profile's Training types card) -- existing ORDER BY priority
-- DESC, name already does the sorting, no query changes needed:
-- Easy, Long, 60' Threshold, 30' Threshold, Tempo, Intervals, MAS Test, Race.
--
-- Each UPDATE matches on the original seeded name, so a kind the user has
-- already renamed themselves (no longer matching) is left untouched.
UPDATE workout_kinds SET name = 'Easy', priority = 80 WHERE name = 'Easy Run';
UPDATE workout_kinds SET name = 'Long', priority = 70 WHERE name = 'Long Run';
UPDATE workout_kinds SET name = '60'' Threshold', priority = 60 WHERE name = 'Threshold 60''';
UPDATE workout_kinds SET name = '30'' Threshold', priority = 50 WHERE name = 'Threshold 30''';
UPDATE workout_kinds SET priority = 40 WHERE name = 'Tempo';
UPDATE workout_kinds SET name = 'Intervals', priority = 30 WHERE name = 'Interval';
UPDATE workout_kinds SET priority = 20 WHERE name = 'MAS Test';
UPDATE workout_kinds SET priority = 10 WHERE name = 'Race';

View File

@@ -1,5 +0,0 @@
-- How strongly the main line color (blue/red) tints the effort-kind fill
-- under the line, as a percentage (0-100) mixed in -- see frontend's
-- ExpectedVsActualChart mixColor(). The phase background above the line is
-- never tinted by the main line color regardless of this setting.
ALTER TABLE profile ADD COLUMN main_line_tint_pct REAL NOT NULL DEFAULT 20;

View File

@@ -1,5 +0,0 @@
-- How strongly (0-100) the effort-kind color is darkened for the phase
-- background above the line -- see frontend's ExpectedVsActualChart
-- darken(). Never mixed with the main line color, unlike the fill below the
-- line (see main_line_tint_pct).
ALTER TABLE profile ADD COLUMN background_darken_pct REAL NOT NULL DEFAULT 35;

View File

@@ -1,5 +0,0 @@
-- How strongly (0-100) a chart's main line color (and everything tinted
-- from it) is brightened when that chart actually has a structured-workout
-- target range to show -- a color-based cue that a target is present,
-- replacing a text label -- see frontend's ExpectedVsActualChart brighten().
ALTER TABLE profile ADD COLUMN target_brighten_pct REAL NOT NULL DEFAULT 20;

View File

@@ -1,5 +0,0 @@
-- Genuine raw JSON of the activity's structured Garmin workout (get_workout_by_id),
-- the source used to compute each lap's TargetPaceLowMps/HighMps and
-- TargetHRLowBpm/HighBpm (see internal/sync/mapping.go's alignWorkoutTargets).
-- Null for activities with no WorkoutID, or synced before this column existed.
ALTER TABLE activities ADD COLUMN workout_raw_json TEXT;

View File

@@ -1,19 +0,0 @@
-- Widens sync_runs.kind's CHECK constraint to also allow 'full' (a manual
-- "Sync now" pass recorded as one combined run instead of separate
-- backfill/incremental rows -- see internal/sync.Service.FullSync). SQLite
-- has no ALTER TABLE for CHECK constraints, so the table is rebuilt.
CREATE TABLE sync_runs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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
);
INSERT INTO sync_runs_new (id, kind, started_at, finished_at, activities_fetched, status, error_message)
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message FROM sync_runs;
DROP TABLE sync_runs;
ALTER TABLE sync_runs_new RENAME TO sync_runs;

View File

@@ -1,6 +0,0 @@
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'))
);

View File

@@ -1,15 +0,0 @@
-- user_id is nullable here even though every row will eventually need one:
-- migrations can't take runtime parameters, so the actual owner isn't known
-- yet. A one-time Go bootstrap step (store.ClaimLegacyOwner, run at
-- geniusrund startup) backfills every existing row to one user once given
-- that user's OIDC subject; from then on every store method requires a
-- non-nil userID and this column is never NULL again in practice.
ALTER TABLE activities ADD COLUMN user_id INTEGER REFERENCES users(id);
ALTER TABLE sync_runs ADD COLUMN user_id INTEGER REFERENCES users(id);
-- Used by UpsertActivity's ON CONFLICT target going forward. The original
-- UNIQUE(garmin_activity_id) constraint (migration 0001) stays in place too
-- -- Garmin's own activity ids are already globally unique in practice, so
-- the stricter constraint is harmless, and SQLite can't drop a column-level
-- constraint without a full table rebuild, which isn't worth the risk here.
CREATE UNIQUE INDEX ux_activities_user_garmin_id ON activities(user_id, garmin_activity_id);

View File

@@ -1,76 +0,0 @@
-- profile.id's CHECK(id = 1) singleton constraint (migration 0003) can't be
-- dropped via ALTER TABLE -- SQLite has no DROP CONSTRAINT -- so this
-- rebuilds the table via SQLite's documented rename/recreate/copy/drop
-- pattern instead. Always create the replacement under a different name and
-- RENAME it into place at the end (never rename the live table away first)
-- -- verified directly against SQLite that this ordering is what keeps
-- other tables' foreign keys intact when they exist (not the case for
-- profile, but kept consistent with migrations 0024/0025 for the same
-- pattern). user_id is nullable for the same not-yet-known-owner reason as
-- migration 0022 -- see its comment.
CREATE TABLE profile_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER 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,
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,
warmup_minutes REAL NOT NULL DEFAULT 10,
cooldown_minutes REAL NOT NULL DEFAULT 5,
min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720,
min_representative_time_seconds REAL NOT NULL DEFAULT 3,
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)
);
INSERT INTO profile_new (
id, user_id, name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days,
max_heart_rate, resting_heart_rate,
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
hr_zone5_min_pct, hr_zone5_max_pct,
warmup_minutes, cooldown_minutes,
min_representative_pace_sec_per_km, min_representative_time_seconds,
pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color,
main_line_tint_pct, background_darken_pct, target_brighten_pct,
created_at, updated_at
)
SELECT
id, NULL, name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days,
max_heart_rate, resting_heart_rate,
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
hr_zone5_min_pct, hr_zone5_max_pct,
warmup_minutes, cooldown_minutes,
min_representative_pace_sec_per_km, min_representative_time_seconds,
pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color,
main_line_tint_pct, background_darken_pct, target_brighten_pct,
created_at, updated_at
FROM profile;
DROP TABLE profile;
ALTER TABLE profile_new RENAME TO profile;

View File

@@ -1,31 +0,0 @@
-- workout_kinds.name's UNIQUE(name) constraint (migration 0001) must become
-- per-user (UNIQUE(user_id, name)) now that every user gets their own copy
-- of the same 8-kind taxonomy -- otherwise a second user could never be
-- provisioned (inserting the same seeded name would collide). kind_assignments
-- and workout_type_paces hold foreign keys into this table
-- (workout_kind_id REFERENCES workout_kinds(id)) -- the create-new-name/
-- copy/drop-old/rename-into-place order below (verified against a real
-- SQLite database) leaves those foreign keys' schema text untouched
-- throughout, so they resolve correctly again the instant the final
-- `ALTER TABLE workout_kinds_new RENAME TO workout_kinds` completes.
CREATE TABLE workout_kinds_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER 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)
);
INSERT INTO workout_kinds_new (id, user_id, name, description, color, rule_json, priority, is_active, created_at, updated_at)
SELECT id, NULL, name, description, color, rule_json, priority, is_active, created_at, updated_at
FROM workout_kinds;
DROP TABLE workout_kinds;
ALTER TABLE workout_kinds_new RENAME TO workout_kinds;

View File

@@ -1,16 +0,0 @@
-- Same CHECK(id = 1) rebuild reasoning as migration 0023's profile rebuild.
CREATE TABLE sync_state_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id),
earliest_synced_date TEXT,
backfill_complete INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id)
);
INSERT INTO sync_state_new (id, user_id, earliest_synced_date, backfill_complete)
SELECT id, NULL, earliest_synced_date, backfill_complete
FROM sync_state;
DROP TABLE sync_state;
ALTER TABLE sync_state_new RENAME TO sync_state;

View File

@@ -1,55 +0,0 @@
-- activities.garmin_activity_id's UNIQUE constraint (migration 0001) must become
-- per-user (UNIQUE(user_id, garmin_activity_id)) now that the per-user-profile
-- design allows multiple users to share the same Garmin activity ID. The old
-- constraint alone was intentionally left in place by migration 0022 as a
-- belts-and-suspenders safeguard (Garmin IDs are globally unique in practice),
-- but it must be removed now to allow Task 6's cross-user test to pass.
--
-- The FK enforcement toggle needed for this rebuild (DROP TABLE on a table
-- with real inbound foreign keys) happens in Go code around this migration's
-- execution (db.go's tableRebuildMigrations map), in autocommit mode before
-- the transaction begins -- a mid-transaction PRAGMA foreign_keys statement
-- is a documented no-op with modernc.org/sqlite, so it must not appear here.
--
-- event_type_key keeps the DEFAULT '' that migration 0007 originally gave
-- it (ALTER TABLE ... ADD COLUMN event_type_key TEXT NOT NULL DEFAULT '');
-- dropping the default here (as an earlier draft of this migration did)
-- broke every INSERT that omits event_type_key and relies on that default,
-- which several existing tests (e.g. TestClaimLegacyOwner_*) do.
CREATE TABLE activities_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id),
garmin_activity_id INTEGER NOT NULL,
event_type_key TEXT NOT NULL DEFAULT '',
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,
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)
);
INSERT INTO activities_new (id, 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, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, created_at, updated_at)
SELECT id, 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, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, created_at, updated_at
FROM activities;
DROP TABLE activities;
ALTER TABLE activities_new RENAME TO activities;
CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
CREATE INDEX idx_activities_user_garmin_id ON activities(user_id, garmin_activity_id);

View File

@@ -0,0 +1,239 @@
-- geniusrun's complete SQLite schema, applied in full on every Open() (see
-- db.go). There is no migration history: this file is regenerated in place
-- whenever the schema changes, and is the single source of truth for both
-- the app and its documentation (see docs/DATABASE.md, generated from this
-- file's live effect via cmd/dumpschema -- regenerate it after editing this
-- file). This is a pre-production app with no compatibility obligation to
-- older database files; if you need to change a column, edit it directly
-- here rather than appending an ALTER TABLE migration.
-- One geniusrun account per OIDC subject. Every other table below is scoped
-- to a user_id, directly (profile/workout_kinds/activities/sync_state/
-- sync_runs) or transitively through a JOIN to the owning row (laps/
-- activity_samples/kind_assignments/workout_type_paces, which have no
-- user_id column of their own since they're never queried except through a
-- specific activity or workout kind).
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'))
);
-- One row per user: Garmin credentials plus every tunable analysis-engine
-- parameter (HR zones, phase-detection minutes, pace-artifact filtering,
-- chart colors). store.ProvisionUser creates this (and everything else
-- below) in one transaction when a new account signs up.
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)
);
-- The fixed, closed 8-type taxonomy (Easy, Long, 60' Threshold,
-- 30' Threshold, Tempo, Intervals, MAS Test, Race) -- one independently
-- tunable copy per user, seeded by store.ProvisionUser. rule_json holds the
-- recursive AND/OR condition tree evaluated by internal/classify.
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)
);
-- Each workout kind's user-declared target pace range + HR range (percent
-- of heart rate reserve). Informational only -- never read by the
-- classification rule engine. No history: overwritten in place, since the
-- synced activity log is the history. No user_id column of its own --
-- ownership is checked via a JOIN to workout_kinds.
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
);
-- One row per synced Garmin activity. garmin_activity_id is the natural
-- idempotency key for UpsertActivity's ON CONFLICT, scoped per user so two
-- different users' Garmin accounts can never collide even if their
-- activity IDs coincided. raw_json/details_raw_json/workout_raw_json store
-- the full original Garmin JSON -- fields that are a pure untransformed
-- copy of something already in raw_json (activity_name, activity_type,
-- etc.) are deliberately NOT modeled as their own columns; internal/api's
-- display_fields.go decodes them fresh from raw_json at response time
-- instead of storing a redundant copy.
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)
);
CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
-- One row per lap/split (from get_activity_splits), plus HR drift/recovery
-- derived from activity_samples. No user_id column -- always accessed
-- through a specific owning activity.
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)
);
-- One row per ~1-second telemetry sample (from get_activity_details). No
-- user_id column -- always accessed through a specific owning activity.
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
);
CREATE INDEX idx_activity_samples_activity ON activity_samples(activity_id, elapsed_seconds);
-- Append-only classification history -- always INSERT, never UPDATE.
-- Re-classifying after a rule edit, or a manual override, keeps full
-- history; current_kind_assignment (below) picks the latest row per
-- activity. assignment_source distinguishes manual overrides (locked
-- against future global reclassifies) from rule-engine assignments. No
-- user_id column -- always accessed through the owning activity.
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'))
);
CREATE INDEX idx_kind_assignments_activity ON kind_assignments(activity_id);
CREATE INDEX idx_kind_assignments_status ON kind_assignments(status);
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;
-- One row per user: tracks a backfill watermark (earliest_synced_date,
-- backfill_complete). Since Garmin history is immutable once recorded,
-- Service.Backfill uses this to resume from where it left off instead of
-- re-walking years of already-known history on every call.
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)
);
-- One row per backfill/incremental/full sync attempt, for the "last sync"
-- status the frontend polls.
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
);

View File

@@ -2,10 +2,7 @@ package store
import (
"context"
"database/sql"
"io/fs"
"path/filepath"
"sort"
"strings"
"testing"
)
@@ -23,7 +20,7 @@ func openTestDB(t *testing.T) *DB {
func f(v float64) *float64 { return &v }
func TestMigrateIsIdempotent(t *testing.T) {
func TestOpenIsIdempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "geniusrun_test.db")
db1, err := Open(path)
if err != nil {
@@ -33,7 +30,7 @@ func TestMigrateIsIdempotent(t *testing.T) {
db2, err := Open(path)
if err != nil {
t.Fatalf("second Open (re-applying migrations): %v", err)
t.Fatalf("second Open (schema already applied): %v", err)
}
db2.Close()
}
@@ -242,22 +239,10 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
}
}
func TestUsersAndOwnershipSchema_AppliesCleanly(t *testing.T) {
func TestSchema_PerUserUniqueConstraints(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
// A fresh DB has no legacy singleton rows, so every user_id column
// should already be backfilled to nothing (fresh install, no rows at
// all yet in these tables besides the migration-seeded workout_kinds --
// which do have NULL user_id until a real user is provisioned).
var nullableCount int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM workout_kinds WHERE user_id IS NULL`).Scan(&nullableCount); err != nil {
t.Fatalf("count workout_kinds: %v", err)
}
if nullableCount != 8 {
t.Fatalf("expected 8 migration-seeded workout_kinds with NULL user_id on a fresh DB, got %d", nullableCount)
}
// UNIQUE(user_id, name) allows the same name across two different users.
if _, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES ('sub-a', 'A'), ('sub-b', 'B')`); err != nil {
t.Fatalf("insert users: %v", err)
@@ -278,14 +263,12 @@ func TestUsersAndOwnershipSchema_AppliesCleanly(t *testing.T) {
}
}
func TestForeignKeyEnforcementPostMigration(t *testing.T) {
func TestForeignKeyEnforcement(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
// Verify that FK enforcement is correctly active after migrations complete.
// This tests that the PRAGMA foreign_keys toggle in db.migrate() (for
// migrations 0023/0024/0025) is properly scoped and re-enabled after each
// table rebuild.
// Verify FK enforcement is genuinely active: a valid reference succeeds,
// a bogus one is rejected, not silently accepted.
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
if err != nil {
@@ -302,7 +285,7 @@ func TestForeignKeyEnforcementPostMigration(t *testing.T) {
t.Fatalf("UpsertActivity: %v", err)
}
// Get the ID of one of the 8 migration-seeded workout_kinds.
// Get the ID of one of the 8 workout_kinds ProvisionUser seeded.
var seedKindID int64
if err := db.QueryRowContext(ctx, `SELECT id FROM workout_kinds LIMIT 1`).Scan(&seedKindID); err != nil {
t.Fatalf("query seeded workout_kind: %v", err)
@@ -341,233 +324,6 @@ func TestForeignKeyEnforcementPostMigration(t *testing.T) {
}
}
// TestRebuildMigrationsPreserveForeignKeyReferences proves the specific
// property none of the other migration tests cover: every other test opens
// a fresh DB via store.Open, which runs migrations 0001-0026 in one
// uninterrupted pass over an empty database, so migrations 0023/0024/0025/0026's
// table-rebuild (create-new/copy/drop-old/rename-into-place) never has any
// real pre-existing rows to carry across. A real single-tenant deployment
// upgrading to this schema version has months of synced activities, laps,
// activity_samples, and kind_assignments rows referencing real activities/
// workout_kinds rows by foreign key (per this repo's CLAUDE.md: one profile,
// real Garmin history, a review queue with manual overrides already
// recorded). This test manually applies migrations up through 0022, inserts
// rows simulating that pre-existing install, then applies 0023/0024/0025/0026
// and verifies every FK-referencing row still resolves correctly -- and that
// FK enforcement is genuinely back on afterward -- rather than just checking
// that migrations apply to an empty DB without erroring. This is the
// regression guard for a real bug: migration 0026 (activities table rebuild)
// originally issued its own mid-transaction `PRAGMA foreign_keys = OFF/ON`,
// which SQLite documents as a no-op once a transaction is open, so
// `DROP TABLE activities` silently cascade-deleted every laps/
// activity_samples/kind_assignments row for every activity via
// `ON DELETE CASCADE` -- with no error at all. It was masked because every
// other test runs migrations back-to-back on an empty database with no
// pre-existing child rows.
func TestRebuildMigrationsPreserveForeignKeyReferences(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "geniusrun_rebuild_test.db")
// Deliberately not store.Open: that always applies every migration in
// one uninterrupted pass with no way to stop partway through. The sqlite
// driver itself is already registered via db.go's blank import.
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer sqlDB.Close()
if _, err := sqlDB.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`); err != nil {
t.Fatalf("create schema_migrations table: %v", err)
}
rebuildMigrations := map[string]bool{
"0023_profile_user_scoped.sql": true,
"0024_workout_kinds_user_scoped.sql": true,
"0025_sync_state_user_scoped.sql": true,
"0026_activities_unique_constraint.sql": true,
}
// Mirrors db.go's migrate() method: FK toggle in autocommit mode around
// the transaction, only for the four table-rebuild migrations.
applyMigration := func(name string) {
t.Helper()
content, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
t.Fatalf("read migration %s: %v", name, err)
}
needsFKToggle := rebuildMigrations[name]
if needsFKToggle {
if _, err := sqlDB.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
t.Fatalf("disable foreign keys before migration %s: %v", name, err)
}
}
tx, err := sqlDB.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin migration tx for %s: %v", name, err)
}
if _, err := tx.ExecContext(ctx, string(content)); err != nil {
tx.Rollback()
t.Fatalf("apply migration %s: %v", name, err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
tx.Rollback()
t.Fatalf("record migration %s: %v", name, err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit migration %s: %v", name, err)
}
if needsFKToggle {
if _, err := sqlDB.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil {
t.Fatalf("enable foreign keys after migration %s: %v", name, err)
}
}
}
entries, err := fs.Glob(migrationsFS, "migrations/*.sql")
if err != nil {
t.Fatalf("glob migrations: %v", err)
}
sort.Strings(entries)
// Apply every migration up to (but not including) the three rebuilds.
for _, entry := range entries {
name := entry[len("migrations/"):]
if rebuildMigrations[name] {
continue
}
applyMigration(name)
}
// Simulate a real pre-existing single-tenant install at this point in
// schema history: a workout kind, a synced activity, and a
// kind_assignment referencing both by foreign key.
res, err := sqlDB.ExecContext(ctx, `INSERT INTO workout_kinds (name, rule_json) VALUES ('Pre-Existing Custom Kind', '{}')`)
if err != nil {
t.Fatalf("insert pre-existing workout_kinds row: %v", err)
}
kindID, err := res.LastInsertId()
if err != nil {
t.Fatalf("workout_kinds LastInsertId: %v", err)
}
res, err = sqlDB.ExecContext(ctx, `
INSERT INTO activities (garmin_activity_id, start_time_utc, duration_seconds, distance_meters, raw_json)
VALUES (123456789, '2026-01-01 06:00:00', 1800, 5000, '{}')`)
if err != nil {
t.Fatalf("insert pre-existing activities row: %v", err)
}
activityID, err := res.LastInsertId()
if err != nil {
t.Fatalf("activities LastInsertId: %v", err)
}
res, err = sqlDB.ExecContext(ctx, `
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status)
VALUES (?, ?, 'rule_engine', 'assigned')`, activityID, kindID)
if err != nil {
t.Fatalf("insert pre-existing kind_assignments row: %v", err)
}
assignmentID, err := res.LastInsertId()
if err != nil {
t.Fatalf("kind_assignments LastInsertId: %v", err)
}
res, err = sqlDB.ExecContext(ctx, `
INSERT INTO laps (activity_id, lap_index, raw_json)
VALUES (?, 0, '{}')`, activityID)
if err != nil {
t.Fatalf("insert pre-existing laps row: %v", err)
}
lapID, err := res.LastInsertId()
if err != nil {
t.Fatalf("laps LastInsertId: %v", err)
}
res, err = sqlDB.ExecContext(ctx, `
INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate)
VALUES (?, 60, 1735711260000, 150)`, activityID)
if err != nil {
t.Fatalf("insert pre-existing activity_samples row: %v", err)
}
sampleID, err := res.LastInsertId()
if err != nil {
t.Fatalf("activity_samples LastInsertId: %v", err)
}
// Now apply the four rebuild migrations that drop/recreate profile,
// workout_kinds, sync_state, and (0026) activities itself.
for _, name := range []string{
"0023_profile_user_scoped.sql",
"0024_workout_kinds_user_scoped.sql",
"0025_sync_state_user_scoped.sql",
"0026_activities_unique_constraint.sql",
} {
applyMigration(name)
}
// The pre-existing kind_assignment row must still resolve to the same
// workout_kind, by the same name, across the drop/recreate/rename.
var resolvedName string
if err := sqlDB.QueryRowContext(ctx, `
SELECT wk.name FROM kind_assignments ka
JOIN workout_kinds wk ON wk.id = ka.workout_kind_id
WHERE ka.id = ?`, assignmentID).Scan(&resolvedName); err != nil {
t.Fatalf("join kind_assignments -> workout_kinds after rebuild: %v", err)
}
if resolvedName != "Pre-Existing Custom Kind" {
t.Fatalf("expected pre-existing kind_assignment to still resolve to 'Pre-Existing Custom Kind' after rebuild, got %q", resolvedName)
}
// The pre-existing laps row must still exist and still reference the
// same activity -- this is the exact regression guard for migration
// 0026's DROP TABLE activities silently cascade-deleting laps via
// ON DELETE CASCADE when FK enforcement wasn't actually disabled.
var lapActivityID int64
if err := sqlDB.QueryRowContext(ctx, `
SELECT activity_id FROM laps WHERE id = ?`, lapID).Scan(&lapActivityID); err != nil {
t.Fatalf("query pre-existing laps row after rebuild: %v", err)
}
if lapActivityID != activityID {
t.Fatalf("expected pre-existing laps row to still reference activity %d after rebuild, got %d", activityID, lapActivityID)
}
// Same guard for activity_samples.
var sampleActivityID int64
if err := sqlDB.QueryRowContext(ctx, `
SELECT activity_id FROM activity_samples WHERE id = ?`, sampleID).Scan(&sampleActivityID); err != nil {
t.Fatalf("query pre-existing activity_samples row after rebuild: %v", err)
}
if sampleActivityID != activityID {
t.Fatalf("expected pre-existing activity_samples row to still reference activity %d after rebuild, got %d", activityID, sampleActivityID)
}
// FK enforcement must be genuinely active again post-migration: a bogus
// workout_kind_id, activity_id (laps), and activity_id (activity_samples)
// must all be rejected, not silently accepted.
if _, err := sqlDB.ExecContext(ctx, `
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status)
VALUES (?, 999999, 'rule_engine', 'assigned')`, activityID); err == nil {
t.Fatal("expected FK constraint violation for nonexistent workout_kind_id after rebuild, but insert succeeded")
}
if _, err := sqlDB.ExecContext(ctx, `
INSERT INTO laps (activity_id, lap_index, raw_json)
VALUES (999999, 0, '{}')`); err == nil {
t.Fatal("expected FK constraint violation for nonexistent activity_id in laps after rebuild, but insert succeeded")
}
if _, err := sqlDB.ExecContext(ctx, `
INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate)
VALUES (999999, 60, 1735711260000, 150)`); err == nil {
t.Fatal("expected FK constraint violation for nonexistent activity_id in activity_samples after rebuild, but insert succeeded")
}
}
func TestCurrentAssignment_ScopedToOwningUser(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()

View File

@@ -17,20 +17,8 @@ type User struct {
CreatedAt string
}
// CreateUser inserts a bare users row. Most callers want ProvisionUser
// instead, which also seeds the profile/taxonomy/sync-state a fresh account
// needs; CreateUser alone exists for ClaimLegacyOwner (Task 3), which
// attaches an *existing* profile/taxonomy rather than seeding new ones.
func (db *DB) CreateUser(ctx context.Context, oidcSub, displayName string) (int64, error) {
res, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName)
if err != nil {
return 0, fmt.Errorf("create user %q: %w", oidcSub, err)
}
return res.LastInsertId()
}
// GetUserBySub looks up a user by their OIDC subject -- the only lookup key
// the session-resolution middleware (Task 11) ever uses.
// the session-resolution middleware ever uses.
func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) {
var u User
err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users WHERE oidc_sub = ?`, oidcSub).
@@ -45,7 +33,7 @@ func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, err
}
// ListUsers returns every provisioned user, for the background incremental
// sync loop (Task 13) to iterate.
// sync loop to iterate.
func (db *DB) ListUsers(ctx context.Context) ([]User, error) {
rows, err := db.QueryContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users ORDER BY id`)
if err != nil {
@@ -64,9 +52,9 @@ func (db *DB) ListUsers(ctx context.Context) ([]User, error) {
return users, rows.Err()
}
// neverMatchRule is the same placeholder every fresh install's rule-engine
// kinds start with (migration 0004) -- every activity lands in needs_review
// until the user tunes real rules.
// neverMatchRule is the placeholder every fresh install's rule-engine kinds
// start with -- every activity lands in needs_review until the user tunes
// real rules.
const neverMatchRule = `{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}`
// defaultWorkoutKindSeeds mirrors the 8 kinds migrations 0004/0007 seed for

264
docs/DATABASE.md Normal file
View 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;
```

15
docs/IDEAS.md Normal file
View File

@@ -0,0 +1,15 @@
# Ideas for future development
Informal running list of things worth doing eventually. Not specs, not plans —
just enough to jog memory at the start of a future session. When one of these
is actually ready to be built, run it through the normal brainstorming ->
writing-plans flow (see `docs/superpowers/specs/` and `docs/superpowers/plans/`
for that history) and remove it from here once a spec exists.
## Backlog
- (add ideas here)
## Someday / maybe
- (lower-priority or vaguer ideas here)