A workout with `endedAt <= startedAt` produced a non-positive
`durationSec` that downstream consumers treated as a real zero —
worst case a fastest-5km PR of zero seconds, which would lock in
until manual cleanup. Add a `.superRefine` clause on
`createWorkoutSchema` so the ingest gate refuses the row.
The PR detector stays defensive against any pre-gate row or future
code path that bypasses the schema: MIN-direction slots now always
filter `durationSec > 0` regardless of which field carries the PR
value. A MIN slot sorts ascending, so the smallest value wins — a
zero-duration row would always be picked first.
Fix-M / code-M2.
Lock the W16b ingest contract on the W8d-authored workout schema:
- Cap route geometries at 20 000 points (covers any normal run /
ride / hike; rejects pathological payloads that would otherwise
blow the 5 MB request-body ceiling on a single workout).
- Enforce the parallel-array invariant via .superRefine() — when
route.sampleTimestamps is present its length must equal
route.geometry.coordinates.length. A desynced pair silently
poisons downstream analytics (per-sample HR / speed against the
wrong coordinate), so we hard-fail at parse time.
- Add createBatchWorkoutSchema mirroring the createBatchMeasurement
shape: { workouts: createWorkoutSchema.array().min(1).max(100) }.
Cap is an order of magnitude tighter than the 500-entry
measurements batch because each workout may carry a 20 000-point
route geometry; 100 covers a cold-start HealthKit backfill on a
healthy multi-year history.
Schema tests grow from 16 → 20 cases. The /api/workouts/batch
endpoint and Swift DTO generator both consume this contract.
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.