merge: s10-google into release/v1.34.0

This commit is contained in:
Marc-André Bombeck committed 2026-07-27 23:41:34 +02:00
commit 66017a8b06
6 files changed
+394 -3

No files matched your search

@@ -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<string>();
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<string, string> = {
// 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]);
});
});
@@ -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");
});
@@ -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);
});
});
+21 -3
View File
@@ -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<string, string> = {
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,
+12
View File
@@ -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
+13
View File
@@ -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({