Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesWorkout ResultsWorkout results — data model

Workout results — data model

Schema in libs/db/src/lib/schema/workouts.ts.

workout_results

ColumnTypeMeaning
iduuid PK
snapshot_workout_iduuid FK → workouts.id, not nullThe frozen-per-athlete copy the member performed (FIT-152). Library row when no assignment.
library_workout_iduuid FK → workouts.id, not nullDenormalized = snapshot.forked_from_id ?? snapshot.id. Keeps “everyone’s Fran scores” indexed.
organization_iduuid FK → organizations.id, not nullDenormalized for org-scoped reads / future RLS.
user_iduuid FK → users.id, not null
assignment_iduuid FK → workout_assignments.id nullableOptional — ad-hoc results have null.
score_numericnumeric(14,4) nullableCanonical numeric. See encoding in behavior.md.
score_unitvarchar(50) nullableOptional display hint (e.g., kg, s).
rxboolean default falseRx (prescribed) flag.
scaledboolean default falseScaled flag (mutually exclusive in UX, not in DB).
notestext nullable
performed_attimestamptz default nowAthlete-supplied or now().
created_at / updated_at / deleted_attimestampsSoft delete.

Indexes (all WHERE deleted_at IS NULL unless noted):

  • workout_results_snapshot_idx on (snapshot_workout_id, performed_at DESC) — leaderboard.
  • workout_results_library_user_idx on (library_workout_id, user_id, performed_at DESC) — “my Fran history”.
  • workout_results_user_idx on (user_id, performed_at DESC) — my feed.
  • workout_results_org_idx on (organization_id, performed_at DESC) — note: NOT partial (org-scoped analytics may include deleted).
  • workout_results_assignment_idx on assignment_id WHERE not null — “results for this assignment”.

workout_set_results

ColumnTypeMeaning
iduuid PK
workout_result_iduuid FK → workout_results.id ON DELETE CASCADE
workout_movement_iduuid FK → workout_movements.id nullableSet may correspond to a specific movement on the snapshot. Null when ad-hoc.
exercise_iduuid FK → exercises.id, not null
set_numberinteger, not null1-based.
repsinteger nullable
weight_kgnumeric(8,3) nullableCanonical kg.
weight_display_unitvarchar(8) nullablekg / lb / lbs — the unit the athlete entered.
distance_mnumeric(10,3) nullableCanonical metres.
distance_display_unitvarchar(8) nullablem / km / mi / ft.
duration_secondsinteger nullable
rpeinteger nullableCHECK 1..10.
notestext nullable
created_at / updated_at / deleted_attimestamps

Indexes:

  • workout_set_results_result_idx on (workout_result_id, set_number).
  • workout_set_results_exercise_idx on exercise_id.
  • workout_set_results_movement_idx on workout_movement_id WHERE not null — per-movement aggregation.

CHECK: workout_set_results_rpe_range_chkrpe IS NULL OR (rpe BETWEEN 1 AND 10).

personal_records

ColumnTypeMeaning
iduuid PK
organization_iduuid FK → organizations.id, not null
user_iduuid FK → users.id, not null
exercise_iduuid FK → exercises.id nullableSet ⇔ workout PR.
library_workout_iduuid FK → workouts.id nullableSet ⇔ exercise PR. CHECK enforces exactly one.
value_numericnumeric(14,4) not null
unitvarchar(50) not null
rep_schemevarchar(50) nullablee.g. 1RM, 5RM (used by some manual PRs).
achieved_attimestamptz default now
workout_result_iduuid FK → workout_results.id nullableSet when PR auto-detected; null for manual PRs.
created_at / updated_at / deleted_attimestampsSoft delete.

Indexes:

  • personal_records_user_exercise_unique UNIQUE on (user_id, exercise_id) WHERE exercise_id IS NOT NULL AND deleted_at IS NULL.
  • personal_records_user_workout_unique UNIQUE on (user_id, library_workout_id) WHERE library_workout_id IS NOT NULL AND deleted_at IS NULL.
  • personal_records_workout_result_idx on workout_result_id WHERE not null.
  • personal_records_org_idx on organization_id.
  • personal_records_org_achieved_at_idx on (organization_id, achieved_at) — analytics range scans.

CHECK: personal_records_target_exclusive_chk(exercise_id IS NOT NULL)::int + (library_workout_id IS NOT NULL)::int = 1.

Row lifecycle

EventDB effect
POST /workouts/:id/resultsInsert workout_results (with snapshot/library/org/user denorms) + N workout_set_results. Auto-completes assignment (UPDATE matches status='assigned'). Runs PR check → optional personal_records upsert.
PATCH /workouts/:id/results/:ridUPDATE header (score, notes, rx, scaled, performedAt). Does not touch sets.
DELETE /workouts/:id/results/:ridSoft delete (deleted_at = now()). Sets are physically CASCADE-deletable but the soft-delete pattern leaves them in place; reads filter.
POST /personal-records/meUpsert by partial unique index. workout_result_id = null.
Lazy-fork-on-resultCalls forkSnapshotIfNeeded in WorkoutAssignmentsService; if pointer-state, deep-copies the workout.

Soft-delete behavior

  • workout_results, workout_set_results, personal_records all carry deleted_at.
  • workout_set_results has ON DELETE CASCADE on its FK to workout_results; a physical delete of a result would also drop sets — but the service uses soft delete only.

Multi-org isolation

  • workout_results.organization_id denormalized — all org-scoped reads filter on it directly.
  • workout_results.snapshot_workout_id resolves through workouts.organizationId — service-level checks reject cross-org reads via requireWorkout(orgId).
  • personal_records.organization_id denormalized for analytics queries.
  • Member ↔ PR access: findMemberPersonalRecords accepts any member of the org (no role gate). findMemberResults requires staff. TODO: verify whether PR privacy should also be staff-only.