After the graceful collection-403 soft-skip, a genuine grant revoke 403s every
data class: each resource soft-skips (returns 0, records no failure), so the
orchestrator sees no error and stamps recordSyncSuccess — leaving the dead
connection looking healthy until the token-refresh path catches the 401 up to
~1 h later. Track the soft-skip count across the resource loop via an
AsyncLocalStorage tracker scoped to the cycle; when every resource soft-skipped
and nothing imported, leave the status as-is instead of stamping success. A
partial cycle (any import, or any resource that did not soft-skip) stamps as
normal.
Extract handleCollectionFetchError so the five resource syncs share one
soft-skip-vs-rethrow path instead of five copies. Behaviour is unchanged:
a per-resource 403 returns 0 and keeps the connection connected, anything
else records a classified failure and rethrows.
The body-measurement endpoint was fetched by a function nobody called, so WHOOP
weight, max heart rate, and height never reached the database despite being
documented in the mapping table.
Add sync-body.ts and wire it into the per-user sync loop (and, through it, the
backfill):
- weight_kilogram → a WEIGHT Measurement (source = WHOOP) on the stable
externalId whoop:body:weight with overwrite semantics. A body measurement is
a single self-reported profile value, not a time series, so a re-sync updates
the same row in place rather than accumulating one row per poll. measuredAt is
the fetch time; the source-priority picker ranks a real scale above WHOOP.
- max_heart_rate → WhoopConnection.maxHeartRate (a profile constant).
- height_meter → User.heightCm (m→cm), written ONLY when the user has no height
yet — a user-set height is never overwritten and height is never minted as a
Measurement.
Every write is field-by-field and idempotent across reruns. Document the body
mapping and the watch-only blood-pressure stance in mapping.md.
A 403 on a single WHOOP collection endpoint was classified reauth_required,
which parked the whole connection at error_reauth and short-circuited every
future sync. A lower-tier user missing one gated data class would brick the
entire integration.
Treat a per-resource collection 403 as a soft skip of that data class: log,
return 0, and continue so sibling resources still sync. Reserve connection-wide
reauth for a 401 (token rejected) and for a 403 on the token-refresh / profile
path, which run outside the per-resource catch blocks and remain unchanged.
Add isCollectionForbidden as the shared gate and cover both branches in tests.
Build the WHOOP sync layer on the W2 client, mirroring the Withings
queues. A per-resource sync (recovery/sleep/cycle/workout) pulls since
the last cursor with a 24h overlap on recovery/sleep to absorb WHOOP's
after-the-fact re-scoring, maps via the W2 mappers, and upserts
Measurements with source=WHOOP keyed on (userId,type,source,externalId)
so a re-post overwrites in place. Workouts upsert into Workout with
per-workout strain + HR zones in metadata and kJ->kcal energy.
Token refresh persists BOTH rotated tokens (WHOOP invalidates the prior
access and refresh token on every refresh). A self-converging boot
backfill enqueues one full-history sync per un-backfilled connection and
stamps backfillCompletedAt; the discovery query drops completed accounts,
idempotent across reboots.
Every new queue is registered in the worker's allQueues + cron schedule
(recovery/sleep/workout poll catch-all, cycle poll-only, backfill,
oauth-state sweep). IntegrationStatus gains the whoop key + label.
Tests: rotating-refresh persistence, idempotent measurement upsert,
incremental-window overlap, backfill self-convergence, and a
queue-registration inventory guard.