Files
HealthLog/prisma/migrations/0053_workout_and_route/migration.sql
T
Marc-André Bombeck 5d6f0c3110 feat(schema): Workout + WorkoutRoute tables for HKWorkout passthrough (Migration 0053)
Locks the workout-ingest contract before the v1.5 iOS-Swift session.
v1.4.25 ships no UI surface for workouts beyond the VO2-max tile
landing in W8d.5; the table layout is in place so the iOS DTO can
be generated against it and the v1.4.26 XML / Strava workers can
target the same shape.

Schema highlights (full rationale in
.planning/research/w8d-implementation-outline.md §4):

  - Typed columns for every hot-path metric (sport, duration, energy,
    distance, HR avg/max/min, steps, elevation, pause). HKWorkout-
    shaped 1:1, so the iOS DTO needs zero glue. Withings v2/measure
    getworkouts maps cleanly modulo `hr_zone_0..3` (-> metadata).
  - Open `metadata Json?` blob for the long tail (HK metadata bag,
    Withings HR-zone breakdown, device bundle id).
  - `sportType` is TEXT, not a Postgres enum — Apple alone carries
    70+ HKWorkoutActivityType values and the tail only grows. Adding
    a new sport is a Zod refinement, never a Postgres migration.
  - Composite unique `(userId, source, externalId)` for ingest dedup;
    Postgres NULL-distinct semantics keep manual entries non-colliding.
  - WorkoutRoute is 1:1 with Workout, FK on workoutId with cascade
    delete. Geometry is GeoJSON LineString in JSONB (no PostGIS
    dependency — v1.4.20 set the same precedent for correlations).
    Per-sample timestamps + speed/HR ride in `sample_timestamps` and
    stay parallel to the coordinate array.

New module src/lib/validations/workout.ts:
  - workoutSportTypeEnum: 20-member roster covering ≥98% of typical
    workouts in the open-source corpora referenced in the outline.
  - geoJsonLineStringSchema: RFC 7946 §3.1.4 with optional altitude
    component; ≥2 points, lon ∈ [-180, 180], lat ∈ [-90, 90].
  - workoutRouteSamplesSchema: parallel-array timestamp + speed + HR.
  - createWorkoutSchema: full HKWorkout-shaped payload Zod for the
    eventual POST /api/measurements/batch?kind=workout endpoint.

13 unit tests cover the happy path (Apple Health full payload), the
minimal-payload defaults, sport-type rejection, GeoJSON validation
(Point rejected, single-point LineString rejected, OOB coordinates
rejected), and route-sample edge cases. No ingest route ships in
v1.4.25 — the schema is the contract.
2026-05-14 15:57:39 +02:00

70 lines
3.3 KiB
SQL

-- v1.4.25 W8d — Workout + WorkoutRoute schema.
--
-- Locks the workout-ingest contract before the v1.5 iOS-Swift session
-- so the iOS DTO can be generated against the table layout from day
-- one. v1.4.25 ships no UI surface for workouts beyond the VO2-max
-- tile (Migration 0052 + W8d.5); the ingest endpoint + chart row
-- land in a later release.
--
-- Shape rationale (see .planning/research/w8d-implementation-outline.md
-- §4):
-- * HKWorkout-aligned typed columns for every metric the analytics
-- / doctor-PDF / Coach evidence pipeline reads on the hot path.
-- * `metadata` JSONB blob for the long-tail (HK metadata key/value
-- pairs, Withings hr_zone_0..3, device bundle id) — keeps the
-- table thin while preserving the source representation.
-- * `sport_type` as TEXT (not a Postgres enum) — Apple alone has
-- 70+ HKWorkoutActivityType values; managing them as a Postgres
-- enum would force a migration for every new sport. The Zod
-- boundary in src/lib/validations/* keeps the surface tight.
-- * Dedup on (user_id, source, external_id) — Postgres treats NULL
-- external_id as distinct so manual entries never collide.
--
-- Route geometry uses GeoJSON LineString stored as JSONB rather than
-- Postgres `point[]` or PostGIS `geometry`. v1.4.20 set the same
-- precedent for correlations: no extension dependency on every
-- self-hosted Postgres install. JSONB also matches the GPX-import
-- target shape and Leaflet / MapLibre consume it directly.
CREATE TABLE "workouts" (
"id" TEXT PRIMARY KEY,
"user_id" TEXT NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"sport_type" TEXT NOT NULL,
"started_at" TIMESTAMP(3) NOT NULL,
"ended_at" TIMESTAMP(3) NOT NULL,
"duration_sec" INTEGER NOT NULL,
"total_energy_kcal" DOUBLE PRECISION,
"total_distance_m" DOUBLE PRECISION,
"avg_heart_rate" INTEGER,
"max_heart_rate" INTEGER,
"min_heart_rate" INTEGER,
"step_count" INTEGER,
"elevation_m" DOUBLE PRECISION,
"pause_duration_sec" INTEGER,
"source" "measurement_source" NOT NULL DEFAULT 'MANUAL',
"external_id" TEXT,
"external_source_version" TEXT,
"metadata" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
CREATE UNIQUE INDEX "workouts_user_source_external_key"
ON "workouts" ("user_id", "source", "external_id");
CREATE INDEX "workouts_user_started_idx"
ON "workouts" ("user_id", "started_at");
CREATE INDEX "workouts_user_sport_started_idx"
ON "workouts" ("user_id", "sport_type", "started_at");
CREATE TABLE "workout_routes" (
"id" TEXT PRIMARY KEY,
"workout_id" TEXT NOT NULL UNIQUE REFERENCES "workouts"("id") ON DELETE CASCADE,
-- GeoJSON LineString: { type: "LineString", coordinates: [[lon, lat, alt?], ...] }
"geometry" JSONB NOT NULL,
-- Per-sample ISO timestamps + optional speeds / HR, one entry per
-- coordinate in `geometry`. NULL when the source only ships static
-- GPX (e.g. Withings).
"sample_timestamps" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);