feat(validations): nested route + batch shape on workout schema

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 18:06:20 +02:00
1 parent bff13e76ed
commit 62e4b1d281
2 files changed
+192 -28

No files matched your search

@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import {
createBatchWorkoutSchema,
createWorkoutSchema,
geoJsonLineStringSchema,
MAX_ROUTE_POINTS,
MAX_WORKOUTS_PER_BATCH,
workoutRouteSamplesSchema,
workoutSportTypeEnum,
type CreateWorkoutInput,
@@ -167,4 +170,107 @@ describe("createWorkoutSchema", () => {
createWorkoutSchema.parse({ ...minimalRun, maxHeartRate: 5 }),
).toThrow();
});
it("rejects a route geometry that exceeds the per-route point cap", () => {
// One past the cap — schema MUST reject. The cap exists so a single
// pathological workout can't blow the 5 MB request-body ceiling on
// its own; a desync between the schema cap and the route route's
// expected-size accounting would silently degrade ingest.
const coords: [number, number][] = [];
for (let i = 0; i < MAX_ROUTE_POINTS + 1; i++) {
coords.push([11 + i * 1e-6, 49 + i * 1e-6]);
}
expect(() =>
createWorkoutSchema.parse({
...minimalRun,
route: {
geometry: { type: "LineString", coordinates: coords },
},
}),
).toThrow(/20000-point cap/);
});
it("rejects a route whose sampleTimestamps length mismatches coordinates", () => {
// Cross-field invariant: per-sample HR / speed must line up against
// the parallel coordinate index, so a desynced pair silently
// poisons downstream analytics. Hard-fail at parse time so the iOS
// mapper surfaces the problem immediately.
expect(() =>
createWorkoutSchema.parse({
...minimalRun,
route: {
geometry: {
type: "LineString",
coordinates: [
[11.077, 49.452],
[11.078, 49.453],
[11.079, 49.454],
],
},
sampleTimestamps: [
{ t: "2026-05-14T06:30:00.000Z" },
{ t: "2026-05-14T06:30:05.000Z" },
// missing third entry — desync the parser MUST catch
],
},
}),
).toThrow(/sampleTimestamps length must match/);
});
it("accepts a route when sampleTimestamps length equals coordinates length", () => {
const parsed = createWorkoutSchema.parse({
...minimalRun,
route: {
geometry: {
type: "LineString",
coordinates: [
[11.077, 49.452],
[11.078, 49.453],
],
},
sampleTimestamps: [
{ t: "2026-05-14T06:30:00.000Z", speedMs: 3.2, hr: 142 },
{ t: "2026-05-14T06:30:05.000Z", speedMs: 3.3, hr: 144 },
],
},
});
expect(parsed.route?.sampleTimestamps).toHaveLength(2);
});
});
describe("createBatchWorkoutSchema", () => {
const minimalRun = (id: string) => ({
sportType: "running",
startedAt: "2026-05-14T06:30:00.000Z",
endedAt: "2026-05-14T07:15:00.000Z",
externalId: id,
});
it("accepts a single-workout batch", () => {
const parsed = createBatchWorkoutSchema.parse({
workouts: [minimalRun("hk-uuid-001")],
});
expect(parsed.workouts).toHaveLength(1);
});
it("accepts a batch at the cap (100 workouts)", () => {
const workouts = Array.from(
{ length: MAX_WORKOUTS_PER_BATCH },
(_, i) => minimalRun(`hk-uuid-${i}`),
);
const parsed = createBatchWorkoutSchema.parse({ workouts });
expect(parsed.workouts).toHaveLength(MAX_WORKOUTS_PER_BATCH);
});
it("rejects a batch above the cap", () => {
const workouts = Array.from(
{ length: MAX_WORKOUTS_PER_BATCH + 1 },
(_, i) => minimalRun(`hk-uuid-${i}`),
);
expect(() => createBatchWorkoutSchema.parse({ workouts })).toThrow();
});
it("rejects an empty batch", () => {
expect(() => createBatchWorkoutSchema.parse({ workouts: [] })).toThrow();
});
});
+86 -28
View File
@@ -43,6 +43,16 @@ export const workoutSportTypeEnum = z.enum([
export type WorkoutSportType = z.infer<typeof workoutSportTypeEnum>;
/**
* Upper bound on points per route LineString. A 24h ultra at 1Hz lands
* at 86 400 points; 20 000 covers any normal run / ride / hike while
* still rejecting pathological payloads (an attacker shipping a 5 MB
* route per workout * 100 workouts/batch * unbounded retries). The
* route geometry is the largest tail in the batch payload — keeping it
* bounded keeps the total request body bounded with it.
*/
export const MAX_ROUTE_POINTS = 20_000;
/**
* GeoJSON LineString — the WorkoutRoute.geometry shape. RFC 7946 §3.1.4
* with one HealthLog-specific allowance: the altitude component on
@@ -57,7 +67,11 @@ export const geoJsonLineStringSchema = z.object({
.tuple([z.number().min(-180).max(180), z.number().min(-90).max(90)])
.rest(z.number()),
)
.min(2, "LineString requires at least 2 points"),
.min(2, "LineString requires at least 2 points")
.max(
MAX_ROUTE_POINTS,
`LineString exceeds the ${MAX_ROUTE_POINTS}-point cap`,
),
});
export type GeoJsonLineString = z.infer<typeof geoJsonLineStringSchema>;
@@ -78,33 +92,77 @@ export const workoutRouteSamplesSchema = z.array(
export type WorkoutRouteSamples = z.infer<typeof workoutRouteSamplesSchema>;
/**
* Workout insert payload — the shape `POST /api/measurements/batch?kind=workout`
* will accept once that endpoint lands (v1.5). Defined here so the iOS
* Swift session can generate its DTO against the locked contract and
* the v1.4.26 XML import worker has the same target.
* Workout insert payload — the shape `POST /api/workouts/batch` accepts.
* Defined here so the iOS Swift session can generate its DTO against the
* locked contract and the v1.4.26 XML import worker has the same target.
*
* Cross-field invariants enforced via `.superRefine()`:
* - When `route.sampleTimestamps` is present, its length MUST equal
* `route.geometry.coordinates.length`. A desynced pair silently
* degrades downstream analytics (per-sample HR / speed would line
* up against the wrong coordinate), so we hard-fail at parse time.
*/
export const createWorkoutSchema = z.object({
sportType: workoutSportTypeEnum,
startedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
endedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
totalEnergyKcal: z.number().min(0).max(50000).optional(),
totalDistanceM: z.number().min(0).max(1_000_000).optional(),
avgHeartRate: z.number().int().min(20).max(300).optional(),
maxHeartRate: z.number().int().min(20).max(300).optional(),
minHeartRate: z.number().int().min(20).max(300).optional(),
stepCount: z.number().int().min(0).max(200_000).optional(),
elevationM: z.number().min(-500).max(10_000).optional(),
pauseDurationSec: z.number().int().min(0).max(86_400).optional(),
source: measurementSourceEnum.optional().default("MANUAL"),
externalId: z.string().max(128).optional(),
externalSourceVersion: z.string().max(64).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
route: z
.object({
geometry: geoJsonLineStringSchema,
sampleTimestamps: workoutRouteSamplesSchema.optional(),
})
.optional(),
});
export const createWorkoutSchema = z
.object({
sportType: workoutSportTypeEnum,
startedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
endedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
totalEnergyKcal: z.number().min(0).max(50000).optional(),
totalDistanceM: z.number().min(0).max(1_000_000).optional(),
avgHeartRate: z.number().int().min(20).max(300).optional(),
maxHeartRate: z.number().int().min(20).max(300).optional(),
minHeartRate: z.number().int().min(20).max(300).optional(),
stepCount: z.number().int().min(0).max(200_000).optional(),
elevationM: z.number().min(-500).max(10_000).optional(),
pauseDurationSec: z.number().int().min(0).max(86_400).optional(),
source: measurementSourceEnum.optional().default("MANUAL"),
externalId: z.string().max(128).optional(),
externalSourceVersion: z.string().max(64).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
route: z
.object({
geometry: geoJsonLineStringSchema,
sampleTimestamps: workoutRouteSamplesSchema.optional(),
})
.optional(),
})
.superRefine((value, ctx) => {
const samples = value.route?.sampleTimestamps;
const coords = value.route?.geometry.coordinates;
if (samples && coords && samples.length !== coords.length) {
ctx.addIssue({
code: "custom",
path: ["route", "sampleTimestamps"],
message:
"sampleTimestamps length must match geometry.coordinates length",
});
}
});
export type CreateWorkoutInput = z.infer<typeof createWorkoutSchema>;
/**
* Upper bound on workouts per batch ingest call. Workouts are heavier
* than measurements (each may carry a 20 000-point route geometry), so
* the cap is an order of magnitude tighter than the 500-entry
* measurements batch. A cold-start iOS HealthKit backfill is "every
* workout I've ever recorded" — 100 covers a healthy multi-year history
* and rejects pathological cases without forcing pagination on the
* happy path.
*/
export const MAX_WORKOUTS_PER_BATCH = 100;
/**
* Batch ingest payload — `{ workouts: [createWorkoutSchema, ...] }`.
* Mirrors `createBatchMeasurementSchema` shape so the iOS sync engine
* can re-use the same retry / cursor plumbing. Each entry is a typed
* workout; an optional nested `route` carries the GeoJSON LineString.
*/
export const createBatchWorkoutSchema = z.object({
workouts: z
.array(createWorkoutSchema)
.min(1)
.max(MAX_WORKOUTS_PER_BATCH),
});
export type CreateBatchWorkoutInput = z.infer<typeof createBatchWorkoutSchema>;