mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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.
104 lines
4.0 KiB
TypeScript
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 };
|
|
}
|