Files
HealthLog/src/lib/whoop/sync-cycle.ts
T
Marc-André Bombeck 9a69e4e557 fix(whoop): a dead token no longer reports a successful sync
Port the hard-fail ledger the Fitbit transport already carries. A revoked WHOOP
grant makes every resource short-circuit and return zero rows without throwing,
so the cycle read as clean: it stamped success, un-parked the reconnect state,
reset the failure streak, and the hourly cohort hammered the dead refresh token
on every tick. Record the three dead-token null-return paths on the ledger and
fold that verdict into both drivers — the full orchestrator and the per-resource
cron path that runs most often — so a dead-token cycle refuses the success
stamp and the park holds.

Return an imported-plus-failed verdict from the orchestrator and gate the
history backfill on it: an interrupted full-history walk now throws for a
pg-boss retry instead of being stamped complete, and a connection parked at
reauth returns without burning the retry budget. Thread the same verdict into
the one-shot sleep-timeline backfill.

Also catch a write-path throw on both drivers so a persistent measurement-write
failure surfaces on the status ledger instead of leaving a stale-green pill, and
widen the cycle overlap to the seven-day re-score window the other collections
use so a late-revised day's strain and energy are re-fetched rather than
stepped past.
2026-07-24 20:05:41 +02:00

70 lines
2.3 KiB
TypeScript

/**
* WHOOP cycle (day) sync. There is NO webhook for cycles — this is poll-only,
* driven by the hourly fallback cron. Maps each scored cycle via `mapCycle`
* (DAY_STRAIN + ENERGY_EXPENDITURE_KJ) and upserts as `source = WHOOP`.
*
* Cycle id is an int64 (not a UUID); externalId = `cycle:<id>:<fieldTag>`.
*/
import { fetchCycles, mapCycle } from "./client";
import {
getValidToken,
incrementalStart,
handleCollectionFetchError,
markResourceSynced,
resolveResourceCursor,
upsertWhoopMeasurements,
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
type WhoopMeasurementUpsert,
} from "./sync-core";
import { prisma } from "@/lib/db";
export async function syncUserCycle(
userId: string,
opts: { fullSync?: boolean } = {},
): Promise<number> {
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
const connection = await prisma.whoopConnection.findUnique({
where: { userId },
select: { lastSyncedAt: true, resourceCursors: true },
});
if (!connection) return 0;
// WHOOP re-scores a cycle THROUGH the day (day strain + energy accrue and
// settle after the fact) and the collection filters on the cycle's own time
// range, not on when it updated — so a narrow 1 h overlap could step the
// cursor past a cycle still being revised and miss the final value. Ride the
// same 7-day re-score overlap workout/recovery/sleep already use; one cycle
// per day means ~7 records re-fetched per tick (one page), and the
// `cycle:<id>:<fieldTag>` upsert keeps the overwrite idempotent.
const start = incrementalStart(resolveResourceCursor(connection, "cycle"), {
fullSync: opts.fullSync,
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
});
let records: Awaited<ReturnType<typeof fetchCycles>>;
try {
records = await fetchCycles(tokenInfo.accessToken, { start });
} catch (err) {
return handleCollectionFetchError("cycle", userId, err);
}
const readings: WhoopMeasurementUpsert[] = [];
for (const c of records) {
for (const m of mapCycle(c)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: `cycle:${c.id}:${m.fieldTag}`,
});
}
}
const imported = await upsertWhoopMeasurements(userId, readings);
await markResourceSynced(userId, "cycle");
return imported;
}