Files
HealthLog/src/lib/whoop/sync.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

104 lines
4.0 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 { 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");
}
return { imported: total, failed: anyFailed || allSoftSkipped };
}