Files
HealthLog/src/lib/whoop/sync.ts
T
Marc-André Bombeck 002d120b69 Reach every cached reader from the integration and context write paths
Integration syncs (Fitbit, Oura, Polar, WHOOP, Strava, Nightscout) now
mark the per-user analytics cells stale and sweep the workouts cache at
their sync tails — imported rows used to stay invisible to the cached
dashboard, analytics and workout readers until the TTL lapsed. The WHOOP
per-resource webhook/cron driver and the Polar workouts leg carry the
same call, matching the posture Google Health and the workout batch
route already had.

Illness, cycle and encounter write routes hard-evict the analytics
bucket through the new invalidateUserHealthContext helper: Rest Mode,
the cycle context and the Today-rail appointment feed all read from
cached cells under that bucket, and an interactive write must be true on
the very next read.
2026-08-13 18:33:46 +02:00

115 lines
4.5 KiB
TypeScript

/**
* WHOOP sync orchestrator.
*
* Shared token, cursor, upsert, status, and failure helpers live in
* `sync-core.ts`. Resource leaves depend only on that core; this parent owns
* the static leaf graph and the full per-user sequencing policy.
*/
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { getEvent } from "@/lib/logging/context";
import {
isReauthRequired,
isSyncFailureRecorded,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { syncUserRecovery } from "./sync-recovery";
import { syncUserSleep } from "./sync-sleep";
import { syncUserCycle } from "./sync-cycle";
import { syncUserWorkout } from "./sync-workout";
import { syncUserBody } from "./sync-body";
import {
hardFailStorage,
recordWhoopSyncFailure,
runWithWhoopSoftSkipTracking,
type HardFailTracker,
} from "./sync-core";
/**
* Full per-user sync across every WHOOP resource. Webhook-driven syncs enqueue
* a single per-resource job; this drives the hourly poll catch-all and the
* manual `/api/whoop/sync` trigger.
*
* Parks immediately when the connection is at `error_reauth` (the user must
* reconnect first) — returns `{ imported: 0, failed: true }`, matching the
* landed Fitbit verdict contract so the backfill caller can gate its marker.
*/
export async function syncUserWhoop(
userId: string,
opts: { fullSync?: boolean } = {},
): Promise<{ imported: number; failed: boolean }> {
if (await isReauthRequired(userId, "whoop")) {
getEvent()?.addWarning(
`whoop sync skipped for ${userId}: parked at error_reauth`,
);
return { imported: 0, failed: true };
}
const resources = [
syncUserRecovery,
syncUserSleep,
syncUserCycle,
syncUserWorkout,
syncUserBody,
];
let anyFailed = false;
// Nest the hard-fail ledger inside the soft-skip tracker (manual nest, not a
// combined wrapper) so a dead token noted by `getValidToken` — or a marked
// fetch hard-fail — fails the verdict just like a thrown resource error.
const hardFailTracker: HardFailTracker = { failures: [] };
const { result: total, softSkipCount } = await runWithWhoopSoftSkipTracking(
() =>
hardFailStorage.run(hardFailTracker, async () => {
let imported = 0;
for (const fn of resources) {
try {
imported += await fn(userId, opts);
} catch (err) {
anyFailed = true;
// WH-4: a write-path throw (`MeasurementReconciliationError` out of
// `upsertWhoopMeasurements`) reaches no ledger otherwise — a
// stale-green pill during a persistent write failure. Fetch
// hard-fails already recorded + marked in `handleCollectionFetchError`,
// so record only the UNMARKED escapes here (no double-record).
if (!isSyncFailureRecorded(err)) {
await recordWhoopSyncFailure(userId, err);
}
getEvent()?.addWarning(
`whoop ${fn.name} failed for ${userId}: ${err}`,
);
}
}
return imported;
}),
);
// A dead token / vanished connection notes the hard-fail ledger without
// throwing (each leaf short-circuits `if (!tokenInfo) return 0`); fold that
// into the verdict so an all-zeros dead-token cycle does not stamp success.
anyFailed = anyFailed || hardFailTracker.failures.length > 0;
// A genuine grant-revoke 403s EVERY collection: each resource soft-skips
// (returns 0, records no failure), so `anyFailed` stays false and `total` is
// 0 — yet the connection is dead until the token-refresh path next catches
// the 401 (up to ~1 h later). Don't stamp success when the whole cycle was
// soft-skipped and nothing imported; leave the status as-is so the
// "looks-healthy" window closes. A partial cycle (some rows imported, or at
// least one resource that did not soft-skip) stamps success as normal.
const allSoftSkipped = softSkipCount >= resources.length && total === 0;
if (!anyFailed && !allSoftSkipped) {
await recordSyncSuccess(userId, "whoop");
}
// Background-sync posture: mark the analytics cells stale (never hard-evict
// from a poll) and sweep the workouts cache so the imported rows reach the
// cached readers. Fires on a partial failure too — rows that DID land must
// not stay invisible for the rest of the TTL window. The per-resource
// webhook/cron jobs run their own call in `runWhoopResourceSync`.
if (total > 0) {
invalidateUserMeasurements(userId);
}
return { imported: total, failed: anyFailed || allSoftSkipped };
}