diff --git a/src/__tests__/workout-sport-provenance-guard.test.ts b/src/__tests__/workout-sport-provenance-guard.test.ts new file mode 100644 index 000000000..ea9eca33f --- /dev/null +++ b/src/__tests__/workout-sport-provenance-guard.test.ts @@ -0,0 +1,252 @@ +/** + * Structural guards on workout sport provenance. + * + * Every provider labels a workout in its own vocabulary, and every ingest + * collapses that label onto the canonical `WorkoutSportType` set. The + * collapse is lossy at both ends: an unmapped label lands on `"other"`, and + * `"other"` read back cannot be told apart from a label that genuinely meant + * "other". The rule that keeps it recoverable is one line long — a + * server-side ingest that maps a raw sport label writes that label verbatim + * to `Workout.metadata`. + * + * Google Health broke that rule silently for three releases while the + * comment above its type map asserted the opposite, and a user's workout + * showed as "Other" with the original nowhere in the database. Three sibling + * providers had carried the provenance write the whole time. Nothing + * connected them, so nothing noticed. + * + * These are tripwires, not proofs. A grep cannot show that a metadata key + * holds the right value — only that a registered ingest still writes one, and + * that a new ingest cannot appear without someone editing this file. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join, sep } from "node:path"; +import { globSync } from "node:fs"; + +const SRC = join(process.cwd(), "src"); + +/** Every non-test `.ts` / `.tsx` under `src/`, minus the generated client. */ +function sourceFiles(): string[] { + return globSync("**/*.{ts,tsx}", { cwd: SRC }) + .filter( + (p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"), + ) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .map((p) => p.split(sep).join("/")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +interface ProviderIngest { + /** Human name, for the failure message. */ + provider: string; + /** The exported function that resolves the upstream label. */ + mapper: string; + /** File declaring the mapper. */ + mapperFile: string; + /** File where the raw label is captured alongside the mapped one. */ + captureFile: string; + /** Identifier or literal that carries the raw label out of the capture. */ + captureField: string; + /** File that builds the `Workout` row written to Postgres. */ + writeFile: string; + /** The `metadata` key the raw label is stored under. */ + metadataKey: string; +} + +/** + * Every server-side workout ingest that maps a raw sport label onto the + * canonical enum, with the provenance write that makes the mapping + * reversible. Adding a provider means adding a row here. + */ +const PROVIDER_INGESTS: ProviderIngest[] = [ + { + provider: "Google Health", + mapper: "mapGoogleHealthSportType", + mapperFile: "lib/google-health/mappers.ts", + captureFile: "lib/google-health/mappers.ts", + captureField: "sportTypeRaw", + writeFile: "lib/google-health/sync-workout.ts", + metadataKey: "googleExerciseType", + }, + { + provider: "WHOOP", + mapper: "mapWhoopSportType", + mapperFile: "lib/whoop/sport-map.ts", + captureFile: "lib/whoop/sync-workout.ts", + captureField: "sport_name", + writeFile: "lib/whoop/sync-workout.ts", + metadataKey: "whoopSportName", + }, + { + provider: "Polar", + mapper: "mapPolarSportType", + mapperFile: "lib/polar/sport-map.ts", + captureFile: "lib/polar/client.ts", + captureField: "detailed_sport_info", + writeFile: "lib/polar/sync-workouts.ts", + metadataKey: "polarSport", + }, + { + provider: "Strava", + mapper: "mapStravaSportType", + mapperFile: "lib/strava/sport-map.ts", + captureFile: "lib/strava/client.ts", + captureField: "rawSportType", + writeFile: "lib/strava/sync.ts", + metadataKey: "stravaType", + }, + { + provider: "Apple Health export", + mapper: "resolveHkWorkoutSportType", + mapperFile: "lib/measurements/hk-workout-activity-type-map.ts", + captureFile: "lib/measurements/import-apple-health-export.ts", + captureField: "workoutActivityType", + writeFile: "lib/measurements/import-apple-health-export.ts", + metadataKey: "activityType", + }, +]; + +/** + * Fitbit carries the identical defect Google Health just had: an unmapped + * `activityName` collapses to `"other"` and the original is discarded. It is + * excluded here deliberately, not overlooked. The standing rule for this + * repository is that Fitbit changes ship as small isolated releases with + * parity tests against a provider that already works, so it follows this one + * rather than riding along inside it — the two providers share no code, so + * deferring leaves nothing half-wired. + * + * The exception is written as an assertion rather than an omission: when + * Fitbit gains the provenance write, this test fails and forces the entry to + * move up into `PROVIDER_INGESTS`. An exception nobody has to retire is how + * the original gap survived. + */ +const FITBIT_EXCEPTION = { + provider: "Fitbit", + mapper: "mapFitbitSportType", + mapperFile: "lib/fitbit/client.ts", + writeFile: "lib/fitbit/sync-workout.ts", +} as const; + +/** + * Exported helpers whose name ends in `SportType` but which never see an + * upstream label — they narrow a value that is already canonical, at read + * time, and have no provenance to preserve. + */ +const READ_TIME_NARROWERS = [ + "closedSportType", // lib/ai/coach/workout-evidence.ts — evidence payload + "narrowSportType", // lib/workouts/insight-evidence.ts — insight claims +]; + +describe("T1 — the set of sport-label mappers is frozen", () => { + it("every exported sport-type resolver is registered", () => { + const found = new Set(); + for (const rel of sourceFiles()) { + for (const m of read(rel).matchAll( + /export function (\w*(?:SportType|WorkoutSportType))\s*\(/g, + )) { + found.add(m[1]); + } + } + + const registered = [ + ...PROVIDER_INGESTS.map((p) => p.mapper), + FITBIT_EXCEPTION.mapper, + ...READ_TIME_NARROWERS, + ].sort(); + + // A new name here means a new provider vocabulary, which means a new + // provenance obligation. It belongs in a reviewed diff. + expect([...found].sort()).toEqual(registered); + }); + + it("each registered mapper lives where the registry says it does", () => { + for (const p of [...PROVIDER_INGESTS, FITBIT_EXCEPTION]) { + expect( + read(p.mapperFile).includes(`export function ${p.mapper}`), + `${p.provider}: ${p.mapper} is no longer declared in ${p.mapperFile}`, + ).toBe(true); + } + }); +}); + +describe("T2 — the set of files writing Workout rows is frozen", () => { + /** + * Non-mapping writers: they persist a `sportType` that some other layer + * already resolved, so the provenance rule does not reach them. + */ + const NON_MAPPING_WRITERS: Record = { + // The iOS / client batch endpoint. The client sends an enum value and MAY + // send its own `metadata`; no server-side label mapping happens here. + "app/api/workouts/batch/route.ts": "client-supplied enum", + // Backup restore replays rows this app previously wrote, provenance and + // all. + "app/api/admin/backups/[id]/restore/route.ts": "restore replay", + }; + + it("only the registered ingests and the declared non-mappers create Workout rows", () => { + const writers = sourceFiles().filter((rel) => + /(?:prisma|tx)\.workout\.(?:create|createMany|createManyAndReturn|upsert)\(/.test( + read(rel), + ), + ); + + const expected = [ + ...PROVIDER_INGESTS.map((p) => p.writeFile), + FITBIT_EXCEPTION.writeFile, + ...Object.keys(NON_MAPPING_WRITERS), + ].sort(); + + expect(writers.sort()).toEqual(expected); + }); +}); + +describe("T3 — every mapping ingest preserves the raw label", () => { + it.each(PROVIDER_INGESTS)( + "$provider writes metadata.$metadataKey", + ({ provider, captureFile, captureField, writeFile, metadataKey }) => { + expect( + read(captureFile).includes(captureField), + `${provider}: the raw label (${captureField}) is no longer read in ${captureFile}`, + ).toBe(true); + + const writer = read(writeFile); + expect( + /metadata[,:]/.test(writer), + `${provider}: ${writeFile} writes no metadata onto the Workout row`, + ).toBe(true); + + // The key itself is declared where the metadata object is built, which + // for two providers is the capture file rather than the writer. + expect( + writer.includes(metadataKey) || read(captureFile).includes(metadataKey), + `${provider}: nothing writes the raw label under metadata.${metadataKey}`, + ).toBe(true); + }, + ); +}); + +describe("T4 — Fitbit is the one declared exception", () => { + it("still discards its raw activity name, and says so when it stops", () => { + const writer = read(FITBIT_EXCEPTION.writeFile); + expect( + /metadata[,:]/.test(writer), + "Fitbit now writes workout metadata — move it into PROVIDER_INGESTS " + + "with its provenance key and delete FITBIT_EXCEPTION.", + ).toBe(false); + }); + + it("is the only ingest allowed to be non-compliant", () => { + const nonCompliant = [ + ...PROVIDER_INGESTS.map((p) => p.writeFile), + FITBIT_EXCEPTION.writeFile, + ].filter((rel) => !/metadata[,:]/.test(read(rel))); + + expect(nonCompliant).toEqual([FITBIT_EXCEPTION.writeFile]); + }); +}); diff --git a/src/lib/google-health/__tests__/mappers.test.ts b/src/lib/google-health/__tests__/mappers.test.ts index 252653e3b..2eacc6d0a 100644 --- a/src/lib/google-health/__tests__/mappers.test.ts +++ b/src/lib/google-health/__tests__/mappers.test.ts @@ -533,6 +533,8 @@ describe("mapWorkout — exercise session", () => { }); expect(w!.startedAt.toISOString()).toBe("2026-06-03T07:00:00.000Z"); expect(w!.endedAt.toISOString()).toBe("2026-06-03T07:45:00.000Z"); + // Provenance rides along even when the mapping succeeded. + expect(w!.sportTypeRaw).toBe("RUNNING"); }); it("falls back to a start-anchored externalId when the resource name is absent", () => { @@ -563,12 +565,56 @@ describe("mapWorkout — exercise session", () => { ).toBeNull(); }); + it("keeps the raw exerciseType when the sport falls back to 'other'", () => { + // The whole point of the provenance field: `sportType` alone cannot say + // whether Google reported a sport the map has never heard of or a genuine + // OTHER_WORKOUT, and the answer is not recoverable from anywhere else. + const w = mapWorkout({ + name: "users/me/dataTypes/exercise/dataPoints/kite1", + exercise: { + exerciseType: " KITESURFING ", + interval: { + startTime: "2026-06-04T10:00:00.000Z", + endTime: "2026-06-04T11:00:00.000Z", + }, + }, + }); + expect(w!.sportType).toBe("other"); + expect(w!.sportTypeRaw).toBe("KITESURFING"); + }); + + it("reports a missing or blank exerciseType as null, not an empty string", () => { + const noType = mapWorkout({ + exercise: { + interval: { + startTime: "2026-06-04T12:00:00.000Z", + endTime: "2026-06-04T12:30:00.000Z", + }, + }, + }); + expect(noType!.sportType).toBe("other"); + expect(noType!.sportTypeRaw).toBeNull(); + + const blankType = mapWorkout({ + exercise: { + exerciseType: " ", + interval: { + startTime: "2026-06-04T13:00:00.000Z", + endTime: "2026-06-04T13:30:00.000Z", + }, + }, + }); + expect(blankType!.sportTypeRaw).toBeNull(); + }); + it("resolves the UPPERCASE ExerciseType enum, with a generic fallback", () => { expect(mapGoogleHealthSportType("STRENGTH_TRAINING")).toBe("strength"); expect(mapGoogleHealthSportType("HIGH_INTENSITY_INTERVAL_TRAINING")).toBe( "hiit", ); expect(mapGoogleHealthSportType("CYCLING")).toBe("cycling"); + // Lossy by design — `Workout.metadata.googleExerciseType` is what carries + // the original (see the mapWorkout provenance cases above). expect(mapGoogleHealthSportType("KITESURFING")).toBe("other"); expect(mapGoogleHealthSportType("")).toBe("other"); }); diff --git a/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts b/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts index e09c5fdfe..a0a61a56c 100644 --- a/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts +++ b/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts @@ -47,6 +47,7 @@ const startedAt = new Date("2026-07-19T08:00:00.000Z"); const mapped = { externalId: "google-1", sportType: "running", + sportTypeRaw: "RUNNING", startedAt, endedAt: new Date("2026-07-19T09:00:00.000Z"), durationSec: 3600, @@ -124,3 +125,52 @@ describe("syncUserWorkout — exact inserted identity", () => { await expect(syncUserWorkout("user-1")).resolves.toBe(1); }); }); + +describe("syncUserWorkout — raw activity-type provenance", () => { + it("writes the raw exerciseType to metadata on insert", async () => { + createManyAndReturn.mockResolvedValue([{ id: "new-workout", startedAt }]); + mapWorkout.mockReturnValue({ ...mapped, sportTypeRaw: "KITESURFING" }); + + await expect(syncUserWorkout("user-1")).resolves.toBe(1); + + expect(createManyAndReturn).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + metadata: { googleExerciseType: "KITESURFING" }, + }), + }), + ); + }); + + it("repairs an existing row in place, because the update reuses the payload", async () => { + // A row synced before the provenance write existed has no metadata; the + // update arm is what a full re-sync goes through, so it has to carry the + // key too or the repair path advertised to users does nothing. + createManyAndReturn.mockResolvedValue([]); + mapWorkout.mockReturnValue({ ...mapped, sportTypeRaw: "KITESURFING" }); + + await expect(syncUserWorkout("user-1")).resolves.toBe(1); + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + metadata: { googleExerciseType: "KITESURFING" }, + }), + }), + ); + }); + + it("omits the key entirely when the session carried no type", async () => { + // Omitted, not null: an update writing `metadata: null` would wipe what an + // earlier sync recorded. + createManyAndReturn.mockResolvedValue([]); + mapWorkout.mockReturnValue({ ...mapped, sportTypeRaw: null }); + + await expect(syncUserWorkout("user-1")).resolves.toBe(1); + + const insertData = createManyAndReturn.mock.calls[0][0].data; + const updateData = update.mock.calls[0][0].data; + expect("metadata" in insertData).toBe(false); + expect("metadata" in updateData).toBe(false); + }); +}); diff --git a/src/lib/google-health/mappers.ts b/src/lib/google-health/mappers.ts index 28ec45e71..df5de3049 100644 --- a/src/lib/google-health/mappers.ts +++ b/src/lib/google-health/mappers.ts @@ -955,9 +955,13 @@ export function mapSleepSession( * Google Health `Exercise.exerciseType` → HealthLog `WorkoutSportType`. The * enum arrives UPPERCASE (`RUNNING`, `STRENGTH_TRAINING`, …); the resolver * lowercases + underscores before the lookup, so the keys here are the - * normalised forms. Unknown types fall through to a generic label; the column - * is free-text so an unmapped type still persists (just not under a canonical - * sport bucket). + * normalised forms. An unmapped type resolves to the literal `"other"`: the + * upstream label does NOT survive in `sportType`, and the column read back + * cannot tell a genuine `OTHER_WORKOUT` from a sport this map has never heard + * of. What preserves it is `GoogleHealthMappedWorkout.sportTypeRaw`, which the + * ingest writes verbatim to `Workout.metadata.googleExerciseType` + * (`sync-workout.ts`) — so a session that shows as "other" can still be traced + * back to what Google sent, without re-fetching it. */ const GOOGLE_HEALTH_EXERCISE_TYPE_MAP: Record = { walk: "walking", @@ -1055,6 +1059,12 @@ export function mapGoogleHealthSportType(raw: unknown): string { export interface GoogleHealthMappedWorkout { externalId: string; sportType: string; + /** + * The upstream `exercise.exerciseType` verbatim (trimmed), or null when the + * session carried none. Provenance only — never the canonical `sportType`, + * which `mapGoogleHealthSportType()` owns. + */ + sportTypeRaw: string | null; startedAt: Date; endedAt: Date; durationSec: number; @@ -1113,6 +1123,9 @@ function readInstant( * the start instant is the fallback. metricsSummary carries no max/min HR → * null. * + * `exerciseType` leaves here twice: once mapped onto a canonical sport bucket, + * once verbatim as `sportTypeRaw` so the ingest can keep the provenance. + * * Session start/end can arrive OFFSET-LESS (local wall clock); `tz` anchors * them to the correct UTC instant rather than the process zone. */ @@ -1136,6 +1149,10 @@ export function mapWorkout( : `exercise:${startedAt.toISOString()}`; const sportRaw = readPath(point, `${k}.exerciseType`); + const sportTypeRaw = + typeof sportRaw === "string" && sportRaw.trim() !== "" + ? sportRaw.trim() + : null; const energyKcal = readNumber(point, [`${k}.metricsSummary.caloriesKcal`]); const distanceMm = readNumber(point, [ @@ -1148,6 +1165,7 @@ export function mapWorkout( return { externalId, sportType: mapGoogleHealthSportType(sportRaw), + sportTypeRaw, startedAt, endedAt, durationSec, diff --git a/src/lib/google-health/mapping.md b/src/lib/google-health/mapping.md index 1e8615101..48e46beb3 100644 --- a/src/lib/google-health/mapping.md +++ b/src/lib/google-health/mapping.md @@ -208,6 +208,18 @@ same run via Apple Health / WHOOP stay distinct rows; the read-time `pickCanonicalWorkoutRows` picker collapses the cross-source twin per the user's source ladder. +**Sport provenance.** The `other` fallback is lossy: `sportType` alone cannot +say whether Google reported `OTHER_WORKOUT` or a sport the map does not know. +So the ingest also writes the label verbatim to +`Workout.metadata.googleExerciseType` (`GoogleHealthMappedWorkout.sportTypeRaw` +→ `sync-workout.ts`), matching Polar's `polarSport`, Strava's `stravaType` and +WHOOP's `whoopSportName`. Provenance only — nothing reads it back as the +canonical sport. The key is omitted, never nulled, when a session carries no +type, so a re-sync cannot clear what an earlier run recorded. Rows written +before this existed carry no key; a full sync (`{fullSync:true}`) re-fetches the +session under the same externalId and fills it in, since insert and update share +one payload. + ## Idempotency `(userId, type, source: "GOOGLE_HEALTH", externalId)` unique. Spot/daily metric diff --git a/src/lib/google-health/sync-workout.ts b/src/lib/google-health/sync-workout.ts index c46b51616..80e1c252a 100644 --- a/src/lib/google-health/sync-workout.ts +++ b/src/lib/google-health/sync-workout.ts @@ -4,6 +4,9 @@ * Reads exercise sessions from the `activity_and_fitness.readonly` Restricted * bundle and upserts each into the `Workout` table as `source = GOOGLE_HEALTH`, * keyed on `(userId, source, externalId)` so a re-fetch overwrites in place. + * Insert and update share one payload object, so a full sync repairs rows an + * older build wrote — including the raw activity type, which earlier releases + * discarded. * * A Google Health run and the same run via Apple Health (or WHOOP) stay distinct * `Workout` rows (different `source`); the read-time `pickCanonicalWorkoutRows` @@ -76,6 +79,16 @@ export async function syncUserWorkout( avgHeartRate: w.avgHeartRate, maxHeartRate: w.maxHeartRate, minHeartRate: w.minHeartRate, + // Raw pre-mapping label, kept for provenance once + // `mapGoogleHealthSportType()` has resolved `sportType` to a canonical + // bucket — every unmapped `exerciseType` collapses to `"other"`, so + // without this the activity Google reported is gone after ingest. Never + // read back as the canonical `sportType`. Absent (rather than null) when + // the session carried no type, so a re-sync never clears metadata a + // previous run wrote. + ...(w.sportTypeRaw + ? { metadata: { googleExerciseType: w.sportTypeRaw } } + : {}), }; try { const [inserted] = await prisma.workout.createManyAndReturn({