mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user