Files
HealthLog/prisma/migrations/0107_v1100_workout_hr_series/migration.sql
T
Marc-André Bombeck d247f6966a feat(workouts): add a route-independent per-workout heart-rate series
Indoor workouts had no home for their per-sample heart-rate series: it
rode on WorkoutRoute.sampleTimestamps, which only exists when a workout
carries GPS geometry. A treadmill or trainer session therefore lost the
signal the training-strain reader needs.

Add a WorkoutSamples child (workout_samples), 1:1 with the workout like
WorkoutRoute, holding a JSONB `[{ t, hr?, speedMs?, power?, cadence? }]`
array plus a denormalised sample_count. JSONB keeps the self-hosted
Postgres extension-free, mirroring the route table's decision. Migration
0107.

Also retire the stale cross-source workout-dedup marker on the Workout
model: that work landed as the write-time dedupeWorkoutBatch() picker
plus the read-time pickCanonicalWorkoutRows() picker, both resolving the
user's source-priority ladder. The comment now documents the two layers
instead of tracking them as open.
2026-06-02 22:02:46 +02:00

45 lines
2.2 KiB
SQL

-- v1.10.0 — per-workout heart-rate series (route-independent).
--
-- A companion to `workout_routes` that holds the per-sample HR series
-- WITHOUT requiring GPS geometry. The route table only exists when a
-- workout has a GeoJSON LineString, so an INDOOR session (treadmill,
-- trainer, strength) had nowhere to store its heart-rate samples even
-- though HealthKit ships them. That series feeds the training-strain
-- engine (TRIMP / time-in-HR-zone), so decoupling it from the route is
-- what unblocks indoor strain.
--
-- Shape rationale:
-- * `samples` JSONB — `[{ t: ISO string, hr?: int, speedMs?, power?,
-- cadence? }]`, one entry per recorded sample. Same JSONB decision
-- as `workout_routes.geometry` / `sample_timestamps`: no PostGIS
-- extension dependency on a self-hosted Postgres install, and the
-- blob mirrors HealthKit's per-sample shape directly.
-- * `sample_count` — denormalised `samples.length` so the strain
-- reader + observability can size the series without deserialising
-- the JSONB blob. The application caps the array length on write
-- (MAX_WORKOUT_HR_SAMPLES in src/lib/validations/workout.ts) so the
-- per-workout payload — and the largest new workout-side write
-- stream — stays bounded.
--
-- 1:1 with the workout: `workout_id` is UNIQUE with an ON DELETE CASCADE
-- FK, exactly like `workout_routes`. A re-submitted batch that wins the
-- workout-row race but loses the sample-row race is an idempotent no-op
-- (the ingest path uses `skipDuplicates`), not a hard error.
--
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
--
-- Reversibility:
-- DROP TABLE IF EXISTS "workout_samples";
-- A roll-back loses the captured per-workout HR series; the workout row,
-- its aggregates (avg/max/min HR), and the route survive untouched.
CREATE TABLE IF NOT EXISTS "workout_samples" (
"id" TEXT PRIMARY KEY,
"workout_id" TEXT NOT NULL UNIQUE REFERENCES "workouts"("id") ON DELETE CASCADE,
-- JSONB array: [{ t: ISO string, hr?: int, speedMs?, power?, cadence? }]
"samples" JSONB NOT NULL,
-- Denormalised samples.length — sized without parsing the blob.
"sample_count" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);