Files
HealthLog/src/lib/google-health/sync-workout.ts
T
Marc-André Bombeck 5ec1a3836f feat(01-21): add fair Google sync outcomes
- finish workout and low-cost resources before dense metrics
- report bounded terminal outcomes and invalidate workout caches
2026-07-29 14:58:40 +02:00

143 lines
5.0 KiB
TypeScript

/**
* Google Health exercise-session sync (v1.27.0).
*
* 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`
* picker collapses the cross-source twin at read time per the user's source
* ladder. There is no ingest-time collapse for a server-owned source pair.
*
* A per-data-class 403 soft-skips the resource — the activity/fitness bundle is
* granted independently in the Google consent flow.
*/
import {
GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE,
GOOGLE_HEALTH_DATA_TYPES,
fetchDataPoints,
mapWorkout,
type GoogleHealthMappedWorkout,
} from "./client";
import {
getValidToken,
handleCollectionFetchError,
noteHardFailure,
type GoogleHealthResourceSyncOptions,
} from "./sync-core";
import { prisma } from "@/lib/db";
import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { annotate, getEvent } from "@/lib/logging/context";
import { resolveUserTimezone } from "@/lib/tz/resolver";
export async function syncUserWorkout(
userId: string,
opts: GoogleHealthResourceSyncOptions = {},
): Promise<number> {
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// Session start/end can arrive offset-less; anchor them against the user's
// stored zone rather than the process zone. The zone also shapes the
// incremental filter: exercise sessions filter on
// `exercise.interval.civil_start_time` with an offset-less civil bound, which
// must be the watermark's wall clock in the USER'S zone.
const tz = await resolveUserTimezone(userId);
// Cycle-wide watermark snapshotted once by `syncUserGoogleHealth`; undefined
// on a full/backfill run.
const start = opts.start;
let points: Record<string, unknown>[];
try {
points = await fetchDataPoints(
GOOGLE_HEALTH_DATA_TYPES.exercise,
tokenInfo.accessToken,
"fetchExercise",
{ start, pageSize: GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE, tz },
);
} catch (err) {
return handleCollectionFetchError("fetchExercise", userId, err);
}
let imported = 0;
for (const point of points) {
const w: GoogleHealthMappedWorkout | null = mapWorkout(point, tz);
if (!w) continue; // no usable time span — not a workout
const data = {
sportType: w.sportType,
startedAt: w.startedAt,
endedAt: w.endedAt,
durationSec: w.durationSec,
totalEnergyKcal: w.totalEnergyKcal,
totalDistanceM: w.totalDistanceM,
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({
data: {
userId,
source: "GOOGLE_HEALTH",
externalId: w.externalId,
...data,
},
skipDuplicates: true,
select: { id: true, startedAt: true },
});
if (inserted) {
void emitInsertedWorkoutArrival(
userId,
inserted,
"google-health",
).catch(() => {});
} else {
await prisma.workout.update({
where: {
userId_source_externalId: {
userId,
source: "GOOGLE_HEALTH",
externalId: w.externalId,
},
},
data,
});
}
imported++;
} catch (err) {
getEvent()?.addWarning(`google-health: failed to upsert workout: ${err}`);
// A fetched-but-unwritten session is gone once it leaves the overlap
// window — fail the cycle's verdict so the watermark holds and the next
// tick re-fetches it. No rethrow: sibling sessions keep upserting.
noteHardFailure("workout:upsert");
}
}
if (imported > 0) {
await invalidateUserMeasurements(userId);
}
// `markSynced` is owned by the orchestrator (`syncUserGoogleHealth`).
annotate({
action: { name: "googleHealth.workout.sync", details: { imported } },
});
return imported;
}