mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
merge: s10-google into release/v1.34.0
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user