diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49772f0..c662b0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,5 +27,8 @@ jobs: - name: Typecheck run: npm run typecheck + - name: Test WHOOP + run: npm run test:whoop + - name: Test run: npm test diff --git a/docs/APPLE_SHORTCUTS.md b/docs/APPLE_SHORTCUTS.md index 0b55840..cba5ebc 100644 --- a/docs/APPLE_SHORTCUTS.md +++ b/docs/APPLE_SHORTCUTS.md @@ -20,7 +20,7 @@ A practical guide to creating iOS Shortcuts that export your Apple Health data t - Apple Watch (recommended for comprehensive data) - iOS Shortcuts app (pre-installed) - Your API endpoint: `https://api.anuragd.me` -- Your API token: `c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2` +- Your API token: `${API_TOKEN}` ### Enable Health Data Access @@ -122,7 +122,7 @@ A practical guide to creating iOS Shortcuts that export your Apple Health data t - Method: POST - Headers: * Content-Type: application/json - * Authorization: Bearer c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2 + * Authorization: Bearer ${API_TOKEN} - Request Body: JSON - Body: HealthPayload → Variable: Response @@ -209,7 +209,7 @@ A practical guide to creating iOS Shortcuts that export your Apple Health data t - Method: POST - Headers: * Content-Type: application/json - * Authorization: Bearer c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2 + * Authorization: Bearer ${API_TOKEN} - Request Body: JSON - Body: SleepPayload @@ -292,7 +292,7 @@ A practical guide to creating iOS Shortcuts that export your Apple Health data t - Method: POST - Headers: * Content-Type: application/json - * Authorization: Bearer c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2 + * Authorization: Bearer ${API_TOKEN} - Request Body: JSON - Body: WorkoutPayload @@ -525,7 +525,7 @@ A practical guide to creating iOS Shortcuts that export your Apple Health data t - Method: **POST** - Headers: Tap "Add new field" - Key: `Content-Type`, Value: `application/json` - - Key: `Authorization`, Value: `Bearer c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2` + - Key: `Authorization`, Value: `Bearer ${API_TOKEN}` - Request Body: **JSON** - Body: Tap and select **HealthPayload** variable @@ -713,7 +713,7 @@ Calculate Statistics **Solution:** - Verify Authorization header is set correctly: - Key: `Authorization` - - Value: `Bearer c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2` + - Value: `Bearer ${API_TOKEN}` - Check for typos in the token --- diff --git a/docs/WHOOP_HEALTH_SOURCE.md b/docs/WHOOP_HEALTH_SOURCE.md new file mode 100644 index 0000000..b2c9597 --- /dev/null +++ b/docs/WHOOP_HEALTH_SOURCE.md @@ -0,0 +1,84 @@ +# WHOOP Health Source + +WHOOP is the sole ongoing wearable source for this API. Existing Apple Health rows are retained as legacy history, with a no-new-ingestion policy after cutover. The legacy Apple mutation routes remain operational in this release, so immutability is not enforced by the API. Custom workout plans and logs remain independent under `/v1/custom/*`. + +## Route contract + +The protected WHOOP management and health-read routes listed below under `/v1` require the existing API bearer token. + +### Connection management + +- `GET /v1/integrations/whoop` returns connection health, current checkpoint progress, and recent reconciliation runs. Operational states are exactly `queued`, `running`, `retrying`, `complete`, and `error`. +- `POST /v1/integrations/whoop/connect` creates a one-use OAuth state and returns the WHOOP authorization URL. +- `POST /v1/integrations/whoop/sync` requests asynchronous reconciliation. +- `DELETE /v1/integrations/whoop` revokes and disconnects the active WHOOP grant. +- `DELETE /v1/integrations/whoop/data` removes locally stored WHOOP data after disconnect. + +The following provider-facing routes are intentionally public because WHOOP cannot send the personal API bearer token: + +- `GET /integrations/whoop/callback` validates and consumes the one-use OAuth state before exchanging the authorization code. +- `POST /integrations/whoop/webhook` verifies WHOOP's raw-body HMAC and timestamp before queueing work. + +### Health reads + +- `GET /v1/health/whoop/overview` +- `GET /v1/health/whoop/profile` +- `GET /v1/health/whoop/cycles` +- `GET /v1/health/whoop/recoveries` +- `GET /v1/health/whoop/sleeps` +- `GET /v1/health/whoop/workouts` +- `GET /v1/health/whoop/workouts/{workoutId}` + +Collection routes accept validated `start`, `end`, `limit`, and opaque `cursor` query parameters. Normal health reads exclude tombstones. Missing or pending WHOOP scores remain `null`, never zero. Score state is normalized separately from the recovery calibration flag. + +`GET /v1/export` includes explicit projections of the six WHOOP source resources: profiles, body measurements, cycles, recoveries, sleeps, and workouts. It includes `deleted_at` so a personal export preserves source deletion history. It does not export provider payload JSON, OAuth state, connections, token ciphertext/nonces, webhook events/signatures, synchronization checkpoints/runs, or operational errors. + +## Source fields and units + +The database retains validated WHOOP source records and their upstream/synchronization timestamps. Public DTOs name units explicitly: + +- energy: kilojoules (`kilojoules`); `energy_kcal_estimate` is derived as `kilojoules / 4.184` +- heart rate: beats per minute +- HRV: RMSSD milliseconds +- sleep and heart-rate-zone durations: source milliseconds in storage, seconds in typed health responses +- sleep detail includes in-bed/no-data durations, baseline/debt/recent-strain/recent-nap need components, and cycle/disturbance counts +- height, distance, and elevation gain: meters +- weight: kilograms +- skin temperature: degrees Celsius +- SpO2, efficiency, consistency, performance, and recorded coverage: percentages +- timestamps: ISO 8601 instants; timezone offsets are retained separately where WHOOP supplies them + +WHOOP deletion webhooks write `deleted_at` tombstones instead of hard-deleting source rows. Normal reads exclude these rows. Authoritative reconciliation can confirm a current upstream record without allowing older or unordered webhook delivery to resurrect deleted data. + +## Public API limitations + +WHOOP Developer API v2 does not expose continuous heart-rate samples, raw sensor data, steps, VO2 max, Stress Monitor, Healthspan, WHOOP Age, Pace of Aging, or device-specific WHOOP Peak fields. These metrics must not be inferred or represented as collected. The API stores the complete provider response internally as `raw_json` for supported resources, but never returns that payload through typed health reads or the personal export. + +## OAuth and synchronization ownership + +The Worker owns the complete OAuth lifecycle: exact scope request, fixed redirect URI, one-use hashed state, authorization-code exchange, AES-256-GCM token storage, rotating refresh-token handling, and revocation. Tokens and authorization codes must never be logged, returned, exported, committed, or placed in fixtures. + +The queue performs initial pagination, webhook fetches, and reconciliation at concurrency one. Webhook bodies are notifications, not trusted source records; the consumer fetches authoritative data from WHOOP. Initial backfill is complete only after every provider page has been exhausted. + +Every reconciliation has a lifecycle-fenced run created before queue publication. Counters are derived from durable checkpoints so redelivery cannot double-count them. Durable reconciliation and webhook results update the exact current connection's sanitized success/failure health; work from a replaced connection cannot update it. + +The independent scheduled retention job deletes at most 100 eligible rows per operational table per invocation. It uses one day for expired/consumed OAuth states, abandoned reconciliation seen rows, and nonterminal checkpoint/run rows proven to belong to an older connection lifecycle or reconciliation generation. It uses 30 days for superseded terminal checkpoints/runs and processed update-webhook receipts. Current-lifecycle nonterminal work, nonterminal webhook receipts, every deletion-webhook receipt, and the latest useful checkpoint/run projection are preserved. + +## Apple legacy history + +The existing `/v1/health*` Apple Health routes and `apple_health_*` rows are retained unchanged as legacy history. That includes the existing Apple POST, PUT, PATCH, and DELETE routes: they remain operational in this release even though the post-cutover policy is to stop new Apple Shortcut ingestion. WHOOP is the ongoing wearable source after production cutover. This document does not authorize using those legacy mutations for new ingestion, deleting the Apple tables, rewriting history, or converting Apple rows into WHOOP rows. + +## External rollout gates + +Local implementation is not a live connection. Production rollout requires separate, explicit authorization for each external change: + +1. create the Cloudflare queue and dead-letter queue; +2. apply the D1 migration remotely; +3. set the WHOOP client ID, client secret, token-encryption key, redirect URI, and fixed OS base URL as Worker configuration/secrets; +4. configure the exact callback and webhook URLs in the WHOOP developer dashboard; +5. deploy the Worker and OS releases; +6. rotate the bearer credential previously exposed in Apple Shortcut documentation and update every legitimate client; +7. complete the user-controlled WHOOP OAuth consent; +8. verify backfill completion, webhook delivery, scheduled reconciliation, typed reads, export redaction, and rollback readiness in production. + +Do not run remote migrations, create queues, write secrets, rotate credentials, deploy, configure the WHOOP dashboard, or approve OAuth as part of local development or testing. diff --git a/docs/superpowers/plans/2026-08-19-whoop-health-source.md b/docs/superpowers/plans/2026-08-19-whoop-health-source.md new file mode 100644 index 0000000..fa0ee6e --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-whoop-health-source.md @@ -0,0 +1,633 @@ +# WHOOP Health Source Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make WHOOP the sole ongoing wearable source, preserving Apple Health as immutable legacy history, while exposing secure, typed, provider-native WHOOP records to `anurag.os`. + +**Architecture:** The Cloudflare Worker owns OAuth, encrypted token storage, webhook authentication, queue-driven synchronization, and D1 persistence. Provider records remain in WHOOP-native tables keyed by upstream IDs; a separate read-model route derives stable OS responses and excludes tombstones. The existing Apple Health routes remain unchanged until an explicitly authorized cutover after production verification. + +**Tech Stack:** TypeScript, Hono, Zod, Cloudflare Workers/D1/Queues/Web Crypto, Vitest, Wrangler. + +**Spec:** `docs/superpowers/specs/2026-08-19-whoop-health-source-design.md` + +## Global Constraints + +- Use WHOOP Developer API v2 only; UUIDs identify sleep/workout records and integer IDs identify cycles. +- Request exactly `offline read:profile read:body_measurement read:cycles read:recovery read:sleep read:workout`. +- Do not use PKCE unless updated official WHOOP documentation and live validation explicitly authorize it. +- Preserve all existing `/v1/health*` Apple routes and `apple_health_*` rows unchanged in this release. +- WHOOP data is read-only; `/v1/custom/*` continues to own custom workout planning/logging. +- Support exactly one active connection, while retaining `whoop_user_id` on every source table. +- Store source energy in kilojoules. Expose any kcal value only as `energy_kcal_estimate = kilojoules / 4.184`. +- Use `raw_json` on every WHOOP source table; retain upstream fields before the typed model is expanded. +- Never log, return, export, commit, fixture, or print authorization codes, token values, client secrets, webhook signatures, or full PII payloads. +- Encrypt access and refresh tokens with AES-256-GCM using `WHOOP_TOKEN_ENCRYPTION_KEY`; bind ciphertext with `:` additional authenticated data. +- OAuth state is exactly eight URL-safe alphanumeric characters as required by WHOOP, generated without modulo bias, SHA-256 hashed before storage, expires in 10 minutes, and is consumed exactly once. +- Refresh tokens rotate. A D1 lease serializes refreshes; its expiry is 30 seconds and non-owners never submit a token they read before the lease owner completes. +- Initial collection backfill uses `limit=25` and follows every `next_token`; completion means pagination exhaustion, not a promised historical date. +- Queue consumer concurrency is one; webhooks acknowledge valid events within one second and perform upstream work asynchronously. +- Tombstone WHOOP deletions with `deleted_at`; normal read routes exclude tombstones. +- All management and WHOOP read routes require the existing bearer middleware. Only `/integrations/whoop/callback` and `/integrations/whoop/webhook` are public, with callback state and webhook HMAC as their compensating controls. +- Deployment, remote migrations, queue creation, Worker secret writes, credential rotation, WHOOP dashboard configuration, and OAuth consent are external gates; do not perform them in implementation tasks. + +--- + +## File Structure + +- `migrations/0020_whoop.sql` — provider-native tables, tombstones, sync state, indexes, and no Apple-table changes. +- `src/types/env.ts` and `wrangler.toml` — typed secret/Queue bindings and local queue configuration only. +- `src/schemas/whoop.ts` — validated WHOOP v2 payloads, route query schemas, OAuth/webhook contracts, and exact public response schemas. +- `src/types/whoop.ts` — provider, queue, and read-model interfaces shared by routes/services. +- `src/services/whoop/crypto.ts` — AES-GCM and one-way state hashing. +- `src/services/whoop/client.ts` — authenticated v2 HTTP client, token exchange/refresh/revoke, pagination, and safe upstream errors. +- `src/services/whoop/repository.ts` — all WHOOP D1 access, idempotent source upserts, state/lease operations, sync state, and export queries. +- `src/services/whoop/sync.ts` — page-by-page source synchronization, recovery resolution, tombstones, rate-limit/retry decisions, and queue message handling. +- `src/routes/whoop-integration.ts` — protected management routes plus public OAuth callback/webhook endpoints. +- `src/routes/whoop-health.ts` — protected typed provider read routes and overview derivation. +- `src/scheduled.ts`, `src/index.ts`, `src/routes/export.ts`, `src/schemas/openapi.ts` — route mounting, scheduled reconciliation, queue dispatch, export and OpenAPI integration. +- `src/__tests__/whoop/*` — fixtures and focused tests; no live WHOOP requests or real credentials. +- `src/__tests__/whoop/fixtures.ts` — redacted v2 records, fixed test environment, fake queue/D1 helpers, bearer request helpers, and local HMAC request construction. +- `docs/WHOOP_HEALTH_SOURCE.md`, `readme.md`, `docs/APPLE_SHORTCUTS.md` — operating contract, legacy Apple status, and credential-safe documentation. + +### Task 1: Add the provider schema and Cloudflare bindings + +**Files:** +- Create: `migrations/0020_whoop.sql` +- Create: `src/types/whoop.ts` +- Create: `src/schemas/whoop.ts` +- Create: `src/__tests__/whoop/schema.test.ts` +- Create: `src/__tests__/whoop/fixtures.ts` +- Modify: `src/types/env.ts:1-16` +- Modify: `wrangler.toml:5-21` + +**Interfaces:** +- Produces `WhoopConnectionStatus`, `WhoopQueueMessage`, `WhoopWebhookEvent`, `WHOOP_SCOPES`, `whoopCollectionQuerySchema`, and `whoopWebhookSchema` for all later tasks. +- Produces `Env.WHOOP_CLIENT_ID`, `Env.WHOOP_CLIENT_SECRET`, `Env.WHOOP_TOKEN_ENCRYPTION_KEY`, `Env.WHOOP_REDIRECT_URI`, `Env.OS_BASE_URL`, and `Env.WHOOP_SYNC_QUEUE: Queue`. + +- [ ] **Step 1: Write failing schema tests** + +```ts +it("accepts the exact OAuth scopes and rejects an added scope", () => { + expect(WHOOP_SCOPES).toEqual([ + "offline", "read:profile", "read:body_measurement", "read:cycles", + "read:recovery", "read:sleep", "read:workout", + ]); + expect(whoopWebhookSchema.safeParse({ + user_id: 42, + id: "f7c85ce7-7e44-4bb4-8cb4-ee5b94b54e1c", + type: "sleep.updated", + trace_id: "7b2dc91e-7423-42b1-a3cb-ecce1a0e2de8", + }).success).toBe(true); + expect(whoopWebhookSchema.safeParse({ user_id: 42, id: "x", type: "sleep.created", trace_id: "t" }).success).toBe(false); +}); + +it("rejects an invalid local cursor and a limit above 100", () => { + expect(whoopCollectionQuerySchema.safeParse({ limit: "101" }).success).toBe(false); + expect(whoopCollectionQuerySchema.safeParse({ cursor: "not-base64!" }).success).toBe(false); +}); +``` + +- [ ] **Step 2: Run the new test to verify it fails** + +Run: `npm test -- src/__tests__/whoop/schema.test.ts` + +Expected: FAIL because `src/schemas/whoop.ts` does not exist. + +- [ ] **Step 3: Add the minimal shared contracts and binding declarations** + +```ts +export const WHOOP_SCOPES = [ + "offline", "read:profile", "read:body_measurement", "read:cycles", + "read:recovery", "read:sleep", "read:workout", +] as const; + +export type WhoopQueueMessage = + | { kind: "backfill" | "reconcile"; whoopUserId: number; resource: WhoopResource; nextToken?: string } + | { kind: "webhook"; traceId: string; whoopUserId: number; resourceId: string; eventType: WhoopWebhookEventType }; +``` + +Use `.strict()` for webhook and local route envelopes. Provider record schemas must validate their required modeled identity/timing fields and use `.passthrough()` so upstream extension fields survive in `raw_json`; quarantine invalid core records with sanitized diagnostics. Define local collection query fields as ISO date-times (`start`, `end`), `limit` integer string 1–100, and URL-safe base64 cursor. Add a `[[queues.producers]]` binding and one `[[queues.consumers]]` entry for `whoop-health-sync`, `dead_letter_queue = "whoop-health-sync-dlq"`, `max_batch_size = 1`, `max_batch_timeout = 1`, `max_concurrency = 1`, and `max_retries = 5`. + +Create `0020_whoop.sql` with the eleven tables named in the approved spec: `whoop_connections`, `whoop_oauth_states`, six source tables, `whoop_webhook_events`, `whoop_sync_checkpoints`, and `whoop_sync_runs`. Add source-table primary keys exactly as specified, `deleted_at`, `synced_at`, `raw_json TEXT NOT NULL`, and indexes on user/time plus `deleted_at`. Add `CHECK` constraints for connection status and webhook event type. Do not create a migration with a duplicate `0015` prefix. + +Create `fixtures.ts` with only synthetic values and these exported helpers used in later test tasks: + +```ts +export const NOW = "2026-08-19T12:00:00.000Z"; +export const NOW_MS = String(Date.parse(NOW)); +export const NOW_MINUS_SIX_MINUTES_MS = String(Date.parse(NOW) - 6 * 60 * 1000); +export const SLEEP = { id: "f7c85ce7-7e44-4bb4-8cb4-ee5b94b54e1c", cycle_id: 9, user_id: 42, created_at: NOW, updated_at: NOW }; +export const WORKOUT = { id: "a2f0c3df-cdb4-48f8-a39b-221b5d8b7a34", user_id: 42, created_at: NOW, updated_at: NOW }; +export const SLEEP_UPDATED = { user_id: 42, id: SLEEP.id, type: "sleep.updated", trace_id: "7b2dc91e-7423-42b1-a3cb-ecce1a0e2de8" }; +export const KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; +export const bearerGet = () => ({ headers: { Authorization: "Bearer test-api-token" } }); +export const bearerPost = () => ({ method: "POST", headers: { Authorization: "Bearer test-api-token" } }); +export const jsonResponse = (body: unknown, init?: ResponseInit) => new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, ...init }); +``` + +`batchOf(message)` returns `{ messages: [{ body: message, ack: vi.fn(), retry: vi.fn() }] } as unknown as MessageBatch`. `signedWebhook(payload, timestamp = NOW_MS)` serializes `payload`, computes base64 HMAC-SHA-256 over the original millisecond timestamp header string plus `body` with the synthetic test secret using Web Crypto, and returns a `RequestInit` with exact `X-WHOOP-Signature`, `X-WHOOP-Signature-Timestamp`, and `content-type` headers. `ENV` uses only fixture strings, `API_TOKEN: "test-api-token"`, and `WHOOP_SYNC_QUEUE.send: vi.fn()`. + +- [ ] **Step 4: Run schema tests and typecheck** + +Run: `npm test -- src/__tests__/whoop/schema.test.ts && npm run typecheck` + +Expected: PASS. + +- [ ] **Step 5: Commit the self-contained foundation** + +```bash +git add migrations/0020_whoop.sql src/types/whoop.ts src/schemas/whoop.ts src/types/env.ts wrangler.toml src/__tests__/whoop/schema.test.ts src/__tests__/whoop/fixtures.ts +git commit -m "feat: add WHOOP schema and bindings" +``` + +### Task 2: Build secret-safe OAuth state and token encryption primitives + +**Files:** +- Create: `src/services/whoop/crypto.ts` +- Create: `src/__tests__/whoop/crypto.test.ts` + +**Interfaces:** +- Consumes: `Env.WHOOP_TOKEN_ENCRYPTION_KEY` and `whoop_user_id` from Task 1. +- Produces `hashOAuthState(state: string): Promise`, `createOAuthState(): Promise`, `encryptWhoopToken(keyMaterial: string, whoopUserId: number, kind: "access" | "refresh", plaintext: string): Promise`, and `decryptWhoopToken(...)`. + +- [ ] **Step 1: Write failing crypto tests** + +```ts +it("round-trips a token only with its matching user and token kind", async () => { + const encrypted = await encryptWhoopToken(KEY, 42, "refresh", "fixture-refresh-token"); + await expect(decryptWhoopToken(KEY, 42, "refresh", encrypted)).resolves.toBe("fixture-refresh-token"); + await expect(decryptWhoopToken(KEY, 43, "refresh", encrypted)).rejects.toThrow("WHOOP token decryption failed"); +}); + +it("creates high-entropy state and hashes it deterministically", async () => { + const state = await createOAuthState(); + expect(state).toMatch(/^[A-Za-z0-9]{8}$/); + expect(await hashOAuthState(state)).toBe(await hashOAuthState(state)); +}); +``` + +- [ ] **Step 2: Run the failing crypto tests** + +Run: `npm test -- src/__tests__/whoop/crypto.test.ts` + +Expected: FAIL because `crypto.ts` does not exist. + +- [ ] **Step 3: Implement Web Crypto only** + +Use `crypto.getRandomValues`, `crypto.subtle.digest("SHA-256", ...)`, and `crypto.subtle.encrypt/decrypt({ name: "AES-GCM", iv, additionalData })`. Generate state from an alphanumeric alphabet with rejection sampling and return exactly eight characters. Decode the 32-byte base64url key once per operation; reject any other byte length. Base64url encode ciphertext/nonce/hash. Throw the fixed sanitized message `WHOOP token decryption failed`; do not interpolate ciphertext, key, or plaintext. + +- [ ] **Step 4: Run focused tests and typecheck** + +Run: `npm test -- src/__tests__/whoop/crypto.test.ts && npm run typecheck` + +Expected: PASS. + +- [ ] **Step 5: Commit crypto primitives** + +```bash +git add src/services/whoop/crypto.ts src/__tests__/whoop/crypto.test.ts +git commit -m "feat: encrypt WHOOP OAuth tokens" +``` + +### Task 3: Implement the WHOOP v2 client with one-refresh retry semantics + +**Files:** +- Create: `src/services/whoop/client.ts` +- Create: `src/__tests__/whoop/client.test.ts` + +**Interfaces:** +- Consumes: `WHOOP_SCOPES`, schemas, and encrypted token records from Tasks 1–2. +- Produces `WhoopClient`, `exchangeAuthorizationCode(code: string)`, `refreshToken(refreshToken: string)`, `revokeAccess(accessToken: string)`, `getProfile()`, `getBodyMeasurements()`, `getCollection(resource, params)`, `getCycle(cycleId)`, `getRecovery(cycleId)`, `getSleep(sleepId)`, and `getWorkout(workoutId)`. + +- [ ] **Step 1: Write failing HTTP-boundary tests with mocked `fetch`** + +```ts +it("uses v2, sends a bearer token, and preserves WHOOP next_token", async () => { + fetchMock.mockResolvedValue(jsonResponse({ records: [SLEEP], next_token: "page-2" })); + const page = await client.getCollection("sleep", { limit: 25 }); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/developer/v2/activity/sleep?limit=25"), + expect.objectContaining({ headers: expect.objectContaining({ authorization: "Bearer access" }) }), + ); + expect(page.nextToken).toBe("page-2"); +}); + +it("turns a 429 into a retryable error using retry-after", async () => { + fetchMock.mockResolvedValue(new Response("", { status: 429, headers: { "retry-after": "30" } })); + await expect(client.getCollection("workout", { limit: 25 })).rejects.toMatchObject({ retryAfterSeconds: 30, retryable: true }); +}); +``` + +- [ ] **Step 2: Run the failing client tests** + +Run: `npm test -- src/__tests__/whoop/client.test.ts` + +Expected: FAIL because `client.ts` does not exist. + +- [ ] **Step 3: Implement the client and sanitized errors** + +Use the fixed base URL `https://api.prod.whoop.com`. Emit no request/response bodies in errors. Parse `X-RateLimit-Reset` and `Retry-After`; mark 429 and 500–599 retryable. Make 401 an identifiable `WhoopUnauthorizedError`; lease orchestration belongs to Task 4, not this class. Validate every successful response with the strict Task 1 schemas while retaining its original `JSON.stringify(payload)` as `rawJson`. + +- [ ] **Step 4: Run focused tests and typecheck** + +Run: `npm test -- src/__tests__/whoop/client.test.ts && npm run typecheck` + +Expected: PASS. + +- [ ] **Step 5: Commit the client** + +```bash +git add src/services/whoop/client.ts src/__tests__/whoop/client.test.ts +git commit -m "feat: add WHOOP v2 client" +``` + +### Task 4: Add repository operations, ordering, and serialized token refresh + +**Files:** +- Create: `src/services/whoop/repository.ts` +- Create: `src/__tests__/whoop/repository.test.ts` + +**Interfaces:** +- Consumes: Task 1 table/schema interfaces and Task 2 encryption helpers. +- Produces `WhoopRepository` methods `consumeOAuthState`, `upsertConnection`, `acquireRefreshLease`, `releaseRefreshLease`, `storeRotatedTokens`, `upsertSourceRecord(resource, record, { tombstonePolicy: "preserve" | "reconcile" })`, `tombstoneSourceRecord`, `createSyncRun`, `upsertCheckpoint`, `recordWebhookEvent`, and `markWebhookQueued`, plus `withWhoopAccessToken` for serialized one-refresh request retry. + +- [ ] **Step 1: Write failing D1 interaction tests** + +```ts +it("does not overwrite a newer source record with an older update", async () => { + await repository.upsertSourceRecord("workout", { ...WORKOUT, updated_at: "2026-08-19T10:00:00Z" }, { tombstonePolicy: "reconcile" }); + await repository.upsertSourceRecord("workout", { ...WORKOUT, updated_at: "2026-08-19T09:00:00Z", score: { strain: 1 } }, { tombstonePolicy: "reconcile" }); + expect(db.executedSql()).toContain("WHERE excluded.upstream_updated_at >= whoop_workouts.upstream_updated_at"); +}); + +it("preserves a webhook tombstone until authoritative reconciliation", async () => { + await repository.tombstoneSourceRecord("workout", WORKOUT.id, NOW); + await repository.upsertSourceRecord("workout", WORKOUT, { tombstonePolicy: "preserve" }); + expect(await repository.getSourceRecord("workout", WORKOUT.id)).toMatchObject({ deleted_at: NOW }); + await repository.upsertSourceRecord("workout", WORKOUT, { tombstonePolicy: "reconcile" }); + expect(await repository.getSourceRecord("workout", WORKOUT.id)).toMatchObject({ deleted_at: null }); +}); + +it("allows only one refresh lease owner", async () => { + expect(await repository.acquireRefreshLease(42, "lease-a", NOW)).toBe(true); + expect(await repository.acquireRefreshLease(42, "lease-b", NOW)).toBe(false); +}); +``` + +- [ ] **Step 2: Run the failing repository tests** + +Run: `npm test -- src/__tests__/whoop/repository.test.ts` + +Expected: FAIL because `repository.ts` does not exist. + +- [ ] **Step 3: Implement parameterized D1 operations** + +Implement each source upsert with stable provider key and `WHERE excluded.upstream_updated_at >= .upstream_updated_at`; retain deterministic equal-time updates. Webhook deletion tombstones idempotently by provider key because WHOOP supplies no source-update timestamp. `tombstonePolicy: "preserve"` leaves `deleted_at` intact for webhook/backfill writes; only scheduled authoritative reconciliation passes `"reconcile"` and may clear it after the upstream collection confirms the record exists. Consume OAuth state with a conditional `UPDATE ... WHERE consumed_at IS NULL AND expires_at > ?` and require exactly one changed row. Acquire lease with a conditional update requiring null/expired lease; the lease owner re-reads tokens before rotating and clears the lease in the same token update. A non-owner waits briefly and re-reads the connection once without submitting the token it observed before the wait. `withWhoopAccessToken` retries the original request once after serialized refresh and marks `needs_reauth` on a second 401. Return safe status/progress projections that omit every ciphertext, nonce, lease ID, and raw payload; a missing connection returns virtual status `not_connected`. + +- [ ] **Step 4: Run repository tests and typecheck** + +Run: `npm test -- src/__tests__/whoop/repository.test.ts && npm run typecheck` + +Expected: PASS. + +- [ ] **Step 5: Commit persistence behavior** + +```bash +git add src/services/whoop/repository.ts src/__tests__/whoop/repository.test.ts +git commit -m "feat: persist WHOOP source records safely" +``` + +### Task 5: Implement protected OAuth management and public callback routes + +**Files:** +- Create: `src/routes/whoop-integration.ts` +- Create: `src/__tests__/whoop/integration-route.test.ts` +- Modify: `src/index.ts:11-169` +- Modify: `src/schemas/openapi.ts:19-183` + +**Interfaces:** +- Consumes: Tasks 1–4. +- Produces protected `GET|DELETE /v1/integrations/whoop`, `POST /v1/integrations/whoop/connect`, `POST /v1/integrations/whoop/sync`, `DELETE /v1/integrations/whoop/data`, and public `GET /integrations/whoop/callback`. + +- [ ] **Step 1: Write failing route tests** + +```ts +it("requires bearer auth for connect and returns a fixed-redirect authorization URL", async () => { + const unauthorized = await app.request("/v1/integrations/whoop/connect", { method: "POST" }, ENV); + expect(unauthorized.status).toBe(401); + const authorized = await app.request("/v1/integrations/whoop/connect", bearerPost(), ENV); + expect((await authorized.json()).authorization_url).toContain(encodeURIComponent(ENV.WHOOP_REDIRECT_URI)); + expect((await authorized.json()).authorization_url).not.toContain("returnTo"); +}); + +it("rejects consumed state before exchanging the authorization code", async () => { + repository.consumeOAuthState.mockResolvedValue(false); + const response = await app.request("/integrations/whoop/callback?code=redacted&state=used", {}, ENV); + expect(response.status).toBe(400); + expect(client.exchangeAuthorizationCode).not.toHaveBeenCalled(); +}); +``` + +- [ ] **Step 2: Run the failing integration-route tests** + +Run: `npm test -- src/__tests__/whoop/integration-route.test.ts` + +Expected: FAIL because the route module is not mounted. + +- [ ] **Step 3: Implement management and callback flow** + +`connect` verifies all required bindings, creates/stores hashed state, and returns only `{ authorization_url }`. The callback consumes state before code exchange, gets profile identity, rejects a second active connection, encrypts tokens, queues initial six-resource backfill, and redirects to `${OS_BASE_URL}/health/source?result=connected` only. Callback errors redirect to the same fixed route with `result=failed`; never include code, state, or upstream error text. `sync` only emits reconciliation messages. `DELETE /v1/integrations/whoop` invokes `DELETE /developer/v2/user/access`, clears token fields, and retains source history. `DELETE /data` requires disconnected status and removes local WHOOP source/operational rows only. + +- [ ] **Step 4: Run route tests and typecheck** + +Run: `npm test -- src/__tests__/whoop/integration-route.test.ts && npm run typecheck` + +Expected: PASS. + +- [ ] **Step 5: Commit OAuth route behavior** + +```bash +git add src/routes/whoop-integration.ts src/__tests__/whoop/integration-route.test.ts src/index.ts src/schemas/openapi.ts +git commit -m "feat: add WHOOP OAuth management routes" +``` + +### Task 6: Implement queue-driven backfill and reconciliation + +**Files:** +- Create: `src/services/whoop/sync.ts` +- Create: `src/__tests__/whoop/sync.test.ts` +- Modify: `src/index.ts:163-170` + +**Interfaces:** +- Consumes: `WhoopQueueMessage`, `WhoopClient`, and `WhoopRepository` from prior tasks. +- Produces `handleWhoopQueue(batch: MessageBatch, env: Env): Promise` and `enqueueReconciliation(env, whoopUserId, trigger)`. + +- [ ] **Step 1: Write failing sync tests** + +```ts +it("persists a page, checkpoint, and exactly one next-page message", async () => { + client.getCollection.mockResolvedValue({ records: [SLEEP], nextToken: "next" }); + await handleWhoopQueue(batchOf({ kind: "backfill", whoopUserId: 42, resource: "sleep" }), ENV); + expect(repository.upsertSourceRecord).toHaveBeenCalledWith("sleep", SLEEP, { tombstonePolicy: "preserve" }); + expect(ENV.WHOOP_SYNC_QUEUE.send).toHaveBeenCalledWith(expect.objectContaining({ nextToken: "next" })); +}); + +it("resolves recovery.updated through sleep then cycle recovery", async () => { + await processWebhook({ eventType: "recovery.updated", resourceId: SLEEP.id, whoopUserId: 42 }, deps); + expect(client.getSleep).toHaveBeenCalledWith(SLEEP.id); + expect(client.getRecovery).toHaveBeenCalledWith(SLEEP.cycle_id); +}); +``` + +- [ ] **Step 2: Run the failing sync tests** + +Run: `npm test -- src/__tests__/whoop/sync.test.ts` + +Expected: FAIL because `sync.ts` does not exist. + +- [ ] **Step 3: Implement idempotent queue processing** + +For backfill, request each page with `limit: 25`, upsert records using `{ tombstonePolicy: "preserve" }`, checkpoint page/record counters, and enqueue only the returned cursor. For scheduled reconciliation, queue 14-day overlapping collections, daily profile/body reads, and pending/unscorable recovery retries, and use `{ tombstonePolicy: "reconcile" }` for authoritative collection results. On retryable upstream error, checkpoint sanitized status and call `message.retry({ delaySeconds })`; permanent 4xx marks the resource run error without looping. A queue message acknowledges only after source write/checkpoint succeeds. Webhook update messages fetch authoritative source data and upsert with `"preserve"`; delete messages tombstone locally without a fetch. + +- [ ] **Step 4: Run sync tests and typecheck** + +Run: `npm test -- src/__tests__/whoop/sync.test.ts && npm run typecheck` + +Expected: PASS. + +- [ ] **Step 5: Commit sync engine** + +```bash +git add src/services/whoop/sync.ts src/__tests__/whoop/sync.test.ts src/index.ts +git commit -m "feat: synchronize WHOOP records through queues" +``` + +### Task 7: Add signed public webhook ingestion and scheduled reconciliation + +**Files:** +- Create: `src/__tests__/whoop/webhook.test.ts` +- Modify: `src/routes/whoop-integration.ts` +- Modify: `src/scheduled.ts:13-178` +- Modify: `src/__tests__/scheduled.test.ts` + +**Interfaces:** +- Consumes: queue/sync services from Task 6. +- Produces public `POST /integrations/whoop/webhook` and scheduled `whoop` refresh-health job. + +- [ ] **Step 1: Write failing raw-body signature and schedule tests** + +```ts +it("accepts one signed event and acknowledges a duplicate trace", async () => { + const first = await app.request("/integrations/whoop/webhook", signedWebhook(SLEEP_UPDATED), ENV); + const duplicate = await app.request("/integrations/whoop/webhook", signedWebhook(SLEEP_UPDATED), ENV); + expect(first.status).toBe(204); + expect(duplicate.status).toBe(204); + expect(ENV.WHOOP_SYNC_QUEUE.send).toHaveBeenCalledTimes(1); +}); + +it("rejects a timestamp older than five minutes before inserting an event", async () => { + const response = await app.request("/integrations/whoop/webhook", signedWebhook(SLEEP_UPDATED, NOW_MINUS_SIX_MINUTES_MS), ENV); + expect(response.status).toBe(401); + expect(repository.recordWebhookEvent).not.toHaveBeenCalled(); +}); + +it("replays a durable initial backfill intent after ambiguous queue publication", async () => { + repository.getPendingInitialBackfills.mockResolvedValue([{ whoopUserId: 42, connectionId: "connection-3", credentialVersion: 3 }]); + await handleScheduled(SCHEDULED_EVENT, ENV); + expect(ENV.WHOOP_SYNC_QUEUE.sendBatch).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ body: expect.objectContaining({ kind: "backfill", whoopUserId: 42, connectionId: "connection-3" }) })]), + ); + expect(repository.markInitialBackfillQueued).toHaveBeenCalledWith(42, "connection-3", 3, expect.any(String)); +}); +``` + +- [ ] **Step 2: Run failing webhook/scheduled tests** + +Run: `npm test -- src/__tests__/whoop/webhook.test.ts src/__tests__/scheduled.test.ts` + +Expected: FAIL because the public webhook and `whoop` scheduled job do not exist. + +- [ ] **Step 3: Implement the security boundary and cron behavior** + +Read `await c.req.raw.text()` exactly once. Require the timestamp header to be a finite integer number of milliseconds since epoch, reject values more than 300,000ms in the past or future, and compute `base64(HMAC-SHA-256(originalTimestampHeader + rawBody, WHOOP_CLIENT_SECRET))`. Compare decoded byte arrays with a constant-time padded XOR loop and accept only `difference === 0`. Require both headers, validate the strict v2 envelope, and load the matching current active/backfilling connection. Persist `connection_id` with the webhook event, include it in the queue message, and require it in `recordWebhookEvent`/`markWebhookQueued`, so a disconnect or reconnect between validation and persistence turns into a safe stale result. Atomically insert a trace with status `received`; send the queue message and mark it `queued` before returning `204`. A duplicate already marked `queued` for that connection returns `204`; a duplicate still in `received` retries queue publication so a transient Queue failure cannot permanently suppress the event. A trace from an old connection lifecycle must never be republished under a new one. Mount this public route outside `/v1/*`; do not add it to bearer skip paths. + +In `handleScheduled`, first replay every durable `initial_backfill_pending` intent through one six-message `sendBatch`, carrying its exact `connectionId`; clear the flag with `markInitialBackfillQueued(whoopUserId, connectionId, credentialVersion, ...)` only after confirmed publication. Ambiguous publication leaves the flag set, so the next schedule safely sends duplicate idempotent backfill work rather than losing history. Then call the canonical Task 6 `enqueueReconciliation(env, whoopUserId, "scheduled")` for active connections; it alone begins the monotonic reconciliation generation and publishes `connectionId`/`reconcileRunId`/generation-fenced messages. Run the work through existing `runRefreshJob(env, "whoop", ...)`; preserve the independent `Promise.allSettled` behavior of other jobs. Refresh-before-expiry follows the Task 4 lease flow. + +- [ ] **Step 4: Run focused tests and typecheck** + +Run: `npm test -- src/__tests__/whoop/webhook.test.ts src/__tests__/scheduled.test.ts && npm run typecheck` + +Expected: PASS. + +- [ ] **Step 5: Commit webhook and scheduler integration** + +```bash +git add src/routes/whoop-integration.ts src/scheduled.ts src/__tests__/whoop/webhook.test.ts src/__tests__/scheduled.test.ts +git commit -m "feat: process WHOOP webhooks and reconciliation" +``` + +### Task 8: Expose typed WHOOP health reads without altering Apple endpoints + +**Files:** +- Create: `src/routes/whoop-health.ts` +- Create: `src/__tests__/whoop/health-route.test.ts` +- Modify: `src/index.ts` +- Modify: `src/schemas/openapi.ts` + +**Interfaces:** +- Consumes: repository read queries and source types. +- Produces `GET /v1/health/whoop/{overview,cycles,recoveries,sleeps,workouts,profile}` and `GET /v1/health/whoop/workouts/:workoutId`. + +- [ ] **Step 1: Write failing contract tests** + +```ts +it("normalizes pending recovery separately from calibration and excludes tombstones", async () => { + repository.getWhoopOverview.mockResolvedValue({ currentRecovery: { score_state: "PENDING_SCORE", user_calibrating: true }, recentWorkouts: [] }); + const response = await app.request("/v1/health/whoop/overview", bearerGet(), ENV); + expect(response.status).toBe(200); + expect((await response.json()).current_recovery).toMatchObject({ score_state: "pending", user_calibrating: true, score: null }); +}); + +it("labels a derived kcal estimate while retaining kilojoules", async () => { + const response = await app.request("/v1/health/whoop/workouts?limit=25", bearerGet(), ENV); + expect((await response.json()).records[0]).toMatchObject({ kilojoules: 418.4, energy_kcal_estimate: 100 }); +}); +``` + +- [ ] **Step 2: Run the failing health-route tests** + +Run: `npm test -- src/__tests__/whoop/health-route.test.ts` + +Expected: FAIL because WHOOP health routes are not mounted. + +- [ ] **Step 3: Implement read models and cursor pagination** + +Validate `start`, `end`, `limit`, and opaque local cursor. Query only `deleted_at IS NULL`; workout detail returns 404 for a missing/tombstoned UUID. Return `start_at`/`end_at`/`created_at`/`updated_at`, sync state, and `energy_kcal_estimate` where `kilojoules` exists. Convert native duration milliseconds to rounded integer `*_seconds`; expose HRV as `hrv_rmssd_milliseconds`. Never return `raw_json`, token/lease fields, OAuth state, or webhook signatures. `overview` returns `current_cycle`, `current_recovery`, `current_sleep`, recent workouts, seven- and thirty-day trend arrays, and sanitized synchronization health. Normalize upstream `SCORED`, `PENDING_SCORE`, and `UNSCORABLE` to lowercase `scored`, `pending`, and `unscorable`; expose recovery `user_calibrating` separately and use `null`, never zero, for absent scores. Register exact OpenAPI response/query schemas. + +- [ ] **Step 4: Run route tests and typecheck** + +Run: `npm test -- src/__tests__/whoop/health-route.test.ts && npm run typecheck` + +Expected: PASS. + +- [ ] **Step 5: Commit typed read APIs** + +```bash +git add src/routes/whoop-health.ts src/__tests__/whoop/health-route.test.ts src/index.ts src/schemas/openapi.ts +git commit -m "feat: expose typed WHOOP health reads" +``` + +### Task 9: Extend safe export and document the legacy/cutover contract + +**Files:** +- Create: `src/__tests__/whoop/export.test.ts` +- Create: `docs/WHOOP_HEALTH_SOURCE.md` +- Modify: `src/routes/export.ts:36-141` +- Modify: `readme.md:236-254` +- Modify: `docs/APPLE_SHORTCUTS.md` + +**Interfaces:** +- Consumes: WHOOP source tables from Task 1 and read-only Apple history decision. +- Produces an export that includes source data but excludes all credentials and operational secrets. + +- [ ] **Step 1: Write failing export tests** + +```ts +it("exports WHOOP source records but not OAuth state, ciphertext, or webhook signatures", async () => { + const response = await app.request("/v1/export", bearerGet(), ENV); + const body = await response.json() as Record; + expect(body.whoop).toHaveProperty("workouts"); + expect(JSON.stringify(body)).not.toContain("access_token_ciphertext"); + expect(JSON.stringify(body)).not.toContain("whoop_oauth_states"); + expect(JSON.stringify(body)).not.toContain("signature"); +}); +``` + +- [ ] **Step 2: Run the failing export test** + +Run: `npm test -- src/__tests__/whoop/export.test.ts` + +Expected: FAIL because the export has no WHOOP section. + +- [ ] **Step 3: Implement export and documentation changes** + +Add only profiles, body measurements, cycles, recoveries, sleeps, and workouts to a `whoop` object in `/v1/export`; exclude connections, OAuth states, webhook events, checkpoints, runs, ciphertext, nonce, signature, and error fields. Document all routes, source units, tombstones, unsupported metrics, OAuth ownership, and external rollout gates in `docs/WHOOP_HEALTH_SOURCE.md`. Mark existing Apple endpoints as legacy history in `readme.md` without changing behavior. Replace every literal credential in `APPLE_SHORTCUTS.md` with `${API_TOKEN}` or `YOUR_API_TOKEN`; do not rotate any credential in this task. + +- [ ] **Step 4: Run export tests, full suite, and typecheck** + +Run: `npm test -- src/__tests__/whoop/export.test.ts && npm test && npm run typecheck` + +Expected: PASS. + +- [ ] **Step 5: Commit safe export and docs** + +```bash +git add src/routes/export.ts src/__tests__/whoop/export.test.ts docs/WHOOP_HEALTH_SOURCE.md readme.md docs/APPLE_SHORTCUTS.md +git commit -m "docs: document WHOOP health source" +``` + +### Task 10: Validate the migration and run release-level checks + +**Files:** +- Create: `src/__tests__/whoop/migration.test.ts` +- Modify: `package.json:6-19` +- Modify: `.github/workflows/ci.yml:24-31` + +**Interfaces:** +- Consumes: all preceding modules. +- Produces `npm run test:whoop` and CI coverage of the focused WHOOP suite. + +- [ ] **Step 1: Write a failing local-D1 migration test** + +```ts +it("creates every WHOOP table and index without changing Apple Health tables", async () => { + const migrationSql = await readFile("migrations/0020_whoop.sql", "utf8"); + expect(migrationSql).not.toMatch(/ALTER TABLE\s+apple_health_/i); + await execFileAsync("npx", ["wrangler", "d1", "migrations", "apply", "personal_api", "--local", "--persist-to", tempDir], { cwd: process.cwd() }); + const { stdout } = await execFileAsync("npx", ["wrangler", "d1", "execute", "personal_api", "--local", "--persist-to", tempDir, "--command", "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", "--json"], { cwd: process.cwd() }); + const tables = JSON.parse(stdout)[0].results.map((row: { name: string }) => row.name); + expect(tables).toEqual(expect.arrayContaining([ + "whoop_connections", "whoop_oauth_states", "whoop_profiles", "whoop_body_measurements", + "whoop_cycles", "whoop_recoveries", "whoop_sleeps", "whoop_workouts", + "whoop_webhook_events", "whoop_sync_checkpoints", "whoop_sync_runs", + ])); +}); +``` + +- [ ] **Step 2: Run the migration test to verify it fails** + +Run: `npm test -- src/__tests__/whoop/migration.test.ts` + +Expected: FAIL until the local D1 migration harness is present. + +- [ ] **Step 3: Add the smallest reproducible migration harness and release commands** + +At the top of `migration.test.ts`, import `mkdtemp`/`rm` from `node:fs/promises`, `tmpdir` from `node:os`, `join` from `node:path`, `readFile` from `node:fs/promises`, and `execFile` from `node:child_process`, then promisify it as `execFileAsync`. Create `tempDir = await mkdtemp(join(tmpdir(), "whoop-d1-"))` in `beforeEach` and remove it with `await rm(tempDir, { recursive: true, force: true })` in `afterEach`. Add `"test:whoop": "vitest run src/__tests__/whoop"` to `package.json`; run it in CI before the existing complete test command. Do not call remote D1, deploy, create queues, write secrets, or invoke OAuth. + +- [ ] **Step 4: Run all release-level checks** + +Run: `npm run test:whoop && npm test && npm run typecheck` + +Expected: PASS with no network requests. + +- [ ] **Step 5: Commit verification coverage** + +```bash +git add src/__tests__/whoop/migration.test.ts package.json .github/workflows/ci.yml +git commit -m "test: validate WHOOP migration and sync suite" +``` + +## External rollout gates after implementation + +1. Obtain explicit authorization to rotate the leaked API bearer credential and remove it from all tracked history where feasible. +2. Obtain explicit authorization to create Cloudflare queues, write Worker secrets, apply `0020_whoop.sql` remotely, and deploy the Worker. +3. Register the exact `WHOOP_REDIRECT_URI` and v2 webhook URL in WHOOP's Developer Dashboard. +4. The user completes WHOOP OAuth consent; no agent completes it. +5. Observe queue/DLQ counts, full-pagination completion, sampled v2 record parity, a test workout/sleep edit webhook, deletion tombstones, and 14-day reconciliation. +6. Only after those checks and explicit approval, disable Apple Shortcut automation and label Apple ingestion read-only legacy history. + +## Self-review + +- **Spec coverage:** Tasks 1–4 cover storage, configuration, schemas, encryption, OAuth state, refresh leases, and typed upstream client behavior. Tasks 5–7 cover OAuth, queues, webhook HMAC/replay/deduplication, backfill/reconciliation, deletion, rate limits, and cron. Tasks 8–9 cover typed OS-facing reads, source-unit labeling, legacy Apple preservation, export exclusions, and documentation. Task 10 validates fresh-D1 migration, focused tests, full tests, typecheck, and CI. +- **Type consistency:** All routes consume `WhoopQueueMessage`, `WhoopRepository`, `WhoopClient`, and Task 1 schemas. Provider UUIDs remain strings, cycle IDs remain numbers, and all source energy remains `kilojoules` through storage and reads. +- **Placeholder scan:** This plan contains no `TBD`, `TODO`, “implement later”, or unspecified error-handling steps. External gates are intentionally named actions, not implementation placeholders. diff --git a/docs/superpowers/specs/2026-08-19-whoop-health-source-design.md b/docs/superpowers/specs/2026-08-19-whoop-health-source-design.md new file mode 100644 index 0000000..a6d711e --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-whoop-health-source-design.md @@ -0,0 +1,233 @@ +# WHOOP Health Source API Design + +**Status:** Approved in chat on 2026-08-19; written-spec review pending + +**Companion:** `os/docs/superpowers/specs/2026-08-19-whoop-health-source-design.md` + +## Goal + +Make WHOOP the sole ongoing source of wearable health data for the personal API while preserving existing Apple Health records as read-only history. Store every record and field exposed by the public WHOOP Developer API v2, sync it reliably, and expose typed read models to `anurag.os`. + +The user confirmed that they own the imported WHOOP data and have permission to retain it. This design does not claim access to data WHOOP does not expose through its public API. + +## Product Decisions + +- WHOOP is the only ongoing wearable ingestion source after cutover. +- Existing `apple_health_*` rows remain intact and queryable as historical data. +- Apple Shortcut ingestion is retired after WHOOP backfill and live synchronization are verified. +- WHOOP source records use provider-native tables. They are not forced into Apple Health's calendar-day schema. +- The API supplies a derived, stable health read model for OS; OS does not interpret raw WHOOP payloads. +- Imported WHOOP data is read-only. The public WHOOP API exposes read scopes, not workout or sleep mutations. +- Custom workout planning and logging under `/v1/custom/*` remain independent. +- The first release supports one connected WHOOP account, matching this personal API's single-owner architecture. Tables retain `whoop_user_id` so this constraint can be lifted without rewriting source records. + +## Verified WHOOP Constraints + +- Use Developer API v2. Activity IDs are UUIDs; cycle IDs remain integers. +- Required scopes are `offline`, `read:profile`, `read:body_measurement`, `read:cycles`, `read:recovery`, `read:sleep`, and `read:workout`. +- Access tokens are short-lived. Refreshing rotates both the access and refresh tokens and invalidates the previous pair. +- Collection endpoints return at most 25 records per page and paginate with `next_token`/`nextToken`. +- Default limits are 100 requests per minute and 10,000 requests per day. +- V2 webhooks cover workout, sleep, and recovery updates/deletions. They can be duplicated and have no documented ordering guarantee. +- Cycle, profile, and body-measurement changes have no documented webhook events and require reconciliation. +- Recovery webhook IDs are associated sleep UUIDs, not cycle IDs. +- WHOOP retries failed webhook deliveries five times over roughly one hour and recommends a successful response within one second. +- The public API does not expose continuous heart-rate samples, raw sensor data, steps, VO2 Max, Stress Monitor, Healthspan, WHOOP Age, Pace of Aging, or device-specific Peak fields. + +## System Shape + +```text +WHOOP OAuth callback ─┐ +WHOOP signed webhook ─┼─> API integration routes ─> Cloudflare Queue ─> WHOOP sync consumer +Scheduled reconcile ──┘ │ + v + WHOOP-native D1 tables + │ + v + Typed /v1/health/whoop API + │ + v + anurag.os + +Existing apple_health_* tables ─> read-only legacy endpoints/archive +``` + +The Cloudflare Worker remains the integration owner. OS initiates connection through protected API calls but never receives the WHOOP client secret, access token, or refresh token. + +## Configuration and Secrets + +Add server-side configuration: + +- `WHOOP_CLIENT_ID`: Worker secret because WHOOP's terms classify developer credentials as confidential. +- `WHOOP_CLIENT_SECRET`: Worker secret used for token exchange and webhook HMAC verification. +- `WHOOP_TOKEN_ENCRYPTION_KEY`: 32-byte random Worker secret, encoded for import into Web Crypto AES-256-GCM. +- `WHOOP_REDIRECT_URI`: exact registered callback, normally `https://api.anuragd.me/integrations/whoop/callback`. +- `OS_BASE_URL`: fixed post-OAuth redirect origin. It must not be accepted from a request parameter. +- `WHOOP_SYNC_QUEUE`: queue producer binding. + +Add queue configuration for `whoop-health-sync` and a `whoop-health-sync-dlq`. The API Worker is both producer and consumer. Start with a small batch size, a retry delay, and a single consumer concurrency because WHOOP refresh tokens rotate and concurrent refreshes race. + +## Storage Design + +Migration `0020_whoop.sql` creates the following tables. Every source table includes `raw_json` so fields added by WHOOP are retained before the typed model is updated. + +### `whoop_connections` + +One row per WHOOP user. Columns: + +- `whoop_user_id` primary key +- `status`: `connecting`, `backfilling`, `active`, `needs_reauth`, `disconnected`, or `error` +- encrypted access token ciphertext, nonce, and expiry +- encrypted refresh token ciphertext and nonce +- granted scopes +- refresh lease ID and expiry used to serialize token rotation +- connection, refresh, last-success, last-error, and disconnection timestamps +- sanitized last error and consecutive failure count + +AES-GCM additional authenticated data binds ciphertext to the WHOOP user ID and token kind. Token values are never logged, returned, or included in exports. + +### `whoop_oauth_states` + +Short-lived, one-use OAuth state records: + +- SHA-256 state hash as the primary key +- creation and expiry timestamps +- consumed timestamp + +WHOOP requires manually generated state to be exactly eight characters. Generate eight random URL-safe alphanumeric characters with unbiased rejection sampling, store only its SHA-256 hash, and never log the plaintext. Callback consumption is atomic and rejects missing, expired, or reused state. + +### Source tables + +- `whoop_profiles`: user ID, name, email, upstream timestamps when present, `raw_json`, `synced_at` +- `whoop_body_measurements`: user ID, height, weight, max HR, `raw_json`, `synced_at` +- `whoop_cycles`: integer cycle ID, user ID, time bounds, timezone offset, score state, strain, kilojoules, average/max HR, upstream timestamps, `raw_json`, deletion/sync timestamps +- `whoop_recoveries`: sleep UUID primary key, cycle ID, user ID, score state, calibration flag, recovery score, RHR, HRV, SpO2, skin temperature, upstream timestamps, `raw_json`, deletion/sync timestamps +- `whoop_sleeps`: sleep UUID, cycle/user IDs, time bounds, timezone, nap flag, score state, all approved stage durations including in-bed/no-data, baseline/debt/recent-strain/recent-nap sleep need, cycle/disturbance counts, respiratory rate, performance/consistency/efficiency, upstream timestamps, `raw_json`, deletion/sync timestamps +- `whoop_workouts`: workout UUID, user ID, time bounds, timezone, sport ID/name, score state, strain, HR, kilojoules, percent recorded, distance/elevation, all six HR-zone durations, upstream timestamps, `raw_json`, deletion/sync timestamps + +Provider IDs are stable upsert keys. An update is accepted when its upstream `updated_at` is newer than or equal to the stored value. Equal timestamps remain safe because upserts are deterministic. + +### Synchronization tables + +- `whoop_webhook_events`: `trace_id` primary key, user ID, resource ID, event type, received/processed timestamps, status, attempts, sanitized error +- `whoop_sync_checkpoints`: user ID plus resource primary key, mode, window, next token, status, page/record counts, timestamps, sanitized error +- `whoop_sync_runs`: run ID, user/lifecycle/generation, trigger, exact target/completion counts, derived page/record counters, queued/running/retrying/complete/error status, start/success/error timestamps, sanitized error + +Deletion webhooks idempotently mark source rows with `deleted_at`; they do not hard-delete immediately. Because WHOOP deletion envelopes carry no source-update timestamp, a tombstone blocks webhook-driven resurrection until scheduled authoritative reconciliation confirms a current upstream record. Read endpoints exclude tombstones by default. + +## OAuth Flow + +1. OS calls protected `POST /v1/integrations/whoop/connect`. +2. API verifies configuration, creates an OAuth state record, and returns a WHOOP authorization URL using the fixed redirect URI and exact scopes. +3. The browser navigates to WHOOP. WHOOP redirects to public `GET /integrations/whoop/callback`. +4. API atomically consumes state before exchanging the code. +5. API exchanges the authorization code server-side, fetches the basic profile, encrypts both tokens, and upserts the connection. +6. API queues initial profile, body, cycle, recovery, sleep, and workout synchronization and redirects only to fixed `OS_BASE_URL` with a non-sensitive result code. + +WHOOP's public documentation does not state PKCE support. Do not send PKCE parameters unless live validation or updated official documentation confirms support. + +Refresh uses a single function with a D1 lease stored on `whoop_connections`. A conditional update acquires a random lease ID only when the prior lease is absent or expired. The lease owner re-reads the current encrypted token immediately before refresh and stores the rotated pair while clearing its lease. A non-owner waits briefly, re-reads the newly stored token once, and never submits the old refresh token concurrently. An abandoned lease expires after 30 seconds. The original WHOOP request is retried once; a second 401 marks `needs_reauth` rather than looping. + +## Backfill and Reconciliation + +Initial backfill requests each collection without a minimum start date, `limit=25`, and follows every `next_token`. WHOOP documents no historical retention guarantee, so successful exhaustion—not a promised lookback—is the completion condition. + +Each queue message processes one resource page and enqueues the next cursor. Checkpoints make the process resumable. Requests inspect `X-RateLimit-*`; a 429 or transient 5xx retries after the documented reset/retry delay. Permanent 4xx responses fail the resource checkpoint without retry storms. + +Scheduled synchronization performs: + +- token refresh before expiry +- recent-window reconciliation for cycles, recoveries, sleeps, and workouts using a 14-day overlapping window +- daily profile and body-measurement refresh +- recovery reconciliation for pending/unscorable records that may later become scored + +Manual protected `POST /v1/integrations/whoop/sync` queues the same idempotent reconciliation; it does not perform upstream work inside the request. + +Reconciliation creates its lifecycle-fenced run before queue publication. Run counters and state are recomputed from durable per-target checkpoints rather than incremented, so redelivery cannot double-count progress. Durable reconciliation and webhook success/failure also updates sanitized connection health only when the exact `connection_id` remains current. + +An independent scheduled retention job performs bounded deletes. It removes expired/consumed OAuth states, abandoned seen rows, and nonterminal checkpoint/run rows proven superseded by connection lifecycle or reconciliation generation after one day. Superseded terminal checkpoints/runs and processed update-webhook receipts are removed after 30 days. Current nonterminal work, nonterminal webhook receipts, and every deletion-webhook receipt are retained; the latest useful progress is preserved. + +## Webhook Flow + +Public `POST /integrations/whoop/webhook` reads the unmodified raw request body and: + +1. Requires `X-WHOOP-Signature` and `X-WHOOP-Signature-Timestamp`. +2. Rejects stale timestamps outside a five-minute replay window. +3. Verifies `base64(HMAC-SHA256(timestamp + rawBody, client_secret))` with Web Crypto. +4. Validates the v2 payload and connected WHOOP user. +5. Inserts `trace_id` idempotently and publishes the event to `WHOOP_SYNC_QUEUE`. +6. Returns 204 within one second. A duplicate valid trace also returns 204. + +The consumer fetches authoritative current data rather than trusting webhook payloads. For `recovery.updated`, it fetches the sleep UUID first to obtain `cycle_id`, then fetches that cycle's recovery. Delete events write tombstones directly from their stable resource identifiers. Scheduled reconciliation repairs missed, late, or out-of-order delivery. + +## API Surface + +Integration management: + +- `GET /v1/integrations/whoop` — connection, scopes, backfill progress, and health; never tokens +- `POST /v1/integrations/whoop/connect` — authorization URL +- `POST /v1/integrations/whoop/sync` — enqueue reconciliation +- `DELETE /v1/integrations/whoop` — revoke WHOOP access and stop synchronization while retaining imported history +- `DELETE /v1/integrations/whoop/data` — separately confirmed local data deletion; rejected while the connection is active + +Typed read endpoints: + +- `GET /v1/health/whoop/overview` +- `GET /v1/health/whoop/cycles` +- `GET /v1/health/whoop/recoveries` +- `GET /v1/health/whoop/sleeps` +- `GET /v1/health/whoop/workouts` +- `GET /v1/health/whoop/workouts/:workoutId` +- `GET /v1/health/whoop/profile` + +Collection endpoints use validated `start`, `end`, `limit`, and opaque local cursor parameters. The workout-detail endpoint returns 404 for a missing or tombstoned UUID. Overview returns `current_cycle`, `current_recovery`, `current_sleep`, recent workouts, 7/30-day trend points, and synchronization status. Normalized score states are `scored`, `pending`, and `unscorable`; recovery calibration is the separate nullable `user_calibrating` flag. Missing or pending values remain `null`, never zero. + +Read-model timestamps use `start_at`, `end_at`, `created_at`, and `updated_at`. Native WHOOP duration fields are persisted in milliseconds; API sleep-stage, sleep-need, and heart-rate-zone durations are integer `*_seconds` values derived by dividing by 1,000. HRV is exposed as `hrv_rmssd_milliseconds`. A missing connection row is represented by the non-persisted read state `{ "status": "not_connected" }`. + +Energy is stored and returned in WHOOP's source unit, kilojoules. Read models may additionally return `energy_kcal_estimate`, calculated as `kilojoule / 4.184` and explicitly labeled as a derived estimate. + +Existing `/v1/health*` Apple endpoints remain unchanged during the first release. They are documented as legacy history after cutover. `/v1/export` adds WHOOP source tables but excludes OAuth states, token ciphertext, webhook signatures, and operational secrets. + +## Failure and Privacy Behavior + +- Error responses and logs identify operation, resource, status code, and WHOOP trace/request metadata when safe; they never contain tokens, authorization codes, client secrets, webhook signatures, or full PII payloads. +- Upstream 401 triggers one serialized refresh. Repeated 401 requires reconnection. +- Upstream 429 honors the reset header and retries through the queue. +- Upstream 5xx and network failures retry with backoff and eventually enter the DLQ. +- Provider record schemas validate required modeled fields while permitting extension fields, and raw payloads are retained before projection. Records whose core identity/timing fields are invalid are quarantined with sanitized diagnostics rather than silently dropped. +- Disconnect invokes WHOOP's `DELETE /developer/v2/user/access`, clears token ciphertext, and stops new webhook processing. Local deletion is a distinct, explicit action. +- D1 supplies platform encryption at rest and TLS in transit; OAuth token fields additionally use application-level AES-256-GCM. + +## Security Prerequisite + +Before deployment, rotate the API bearer credential currently present in tracked Apple Shortcut documentation and remove credential material from tracked files. The tracked OS Shortcut signing private key is handled as a separate focused remediation. No credential values may appear in commits, plans, tests, fixtures, or command output. + +## Testing + +Add focused Vitest coverage for: + +- exact OAuth scopes, fixed redirects, state expiry/reuse, and callback failures +- AES-GCM token round trips without value logging +- refresh-token rotation and concurrent refresh serialization +- v2 schema parsing for scored, pending, unscorable, optional, and newly added fields +- pagination exhaustion, cursor persistence, 429 handling, and resume after failure +- signature verification using the raw body, timestamp replay rejection, and `trace_id` deduplication +- update ordering, equal-timestamp idempotency, tombstones, and recovery sleep-ID resolution +- queue acknowledgement/retry behavior and scheduled reconciliation +- authorization boundaries for public callback/webhook versus protected management/read routes +- export secret exclusion + +Baseline gates remain TypeScript typecheck and the full Vitest suite. Migration validation uses a fresh local D1 database plus representative v2 fixtures. Production verification uses one authorized WHOOP account, observes backfill counts and queue/DLQ state, edits a test sleep/workout to generate real webhooks, and compares sampled records with the WHOOP app. + +## Rollout and Cutover + +1. Complete credential remediation. +2. Deploy schema, queue bindings, and integration code without retiring Apple ingestion. +3. Register the exact callback and v2 webhook in WHOOP's dashboard. +4. The user explicitly completes OAuth consent. +5. Run and verify full backfill and live webhook/reconciliation behavior. +6. Release the OS WHOOP experience. +7. Disable Apple Shortcut automation and label Apple endpoints/history as legacy. +8. Monitor sync health and DLQ for at least seven days before considering the migration complete. + +Deployment, remote migration, queue creation, secret writes, OAuth consent, and credential rotation are external actions requiring explicit execution authorization at the relevant step. diff --git a/migrations/0020_whoop.sql b/migrations/0020_whoop.sql new file mode 100644 index 0000000..01769a6 --- /dev/null +++ b/migrations/0020_whoop.sql @@ -0,0 +1,246 @@ +-- WHOOP Developer API v2 source records and synchronization state. +-- Existing apple_health_* history remains untouched. + +CREATE TABLE IF NOT EXISTS whoop_connections ( + whoop_user_id INTEGER PRIMARY KEY, + connection_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('connecting', 'backfilling', 'active', 'needs_reauth', 'disconnected', 'error')), + access_token_ciphertext TEXT, + access_token_nonce TEXT, + access_token_expires_at TEXT, + refresh_token_ciphertext TEXT, + refresh_token_nonce TEXT, + granted_scopes TEXT NOT NULL, + credential_version INTEGER NOT NULL DEFAULT 1, + reconcile_generation INTEGER NOT NULL DEFAULT 0, + initial_backfill_pending INTEGER NOT NULL DEFAULT 0, + refresh_lease_id TEXT, + refresh_lease_expires_at TEXT, + refresh_dispatched_at TEXT, + connected_at TEXT, + refreshed_at TEXT, + last_success_at TEXT, + last_error_at TEXT, + disconnected_at TEXT, + last_error TEXT, + consecutive_failure_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS whoop_oauth_states ( + state_hash TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT +); + +CREATE TABLE IF NOT EXISTS whoop_profiles ( + whoop_user_id INTEGER PRIMARY KEY, + first_name TEXT, + last_name TEXT, + email TEXT, + upstream_created_at TEXT, + upstream_updated_at TEXT, + deleted_at TEXT, + synced_at TEXT NOT NULL, + raw_json TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_whoop_profiles_user_updated ON whoop_profiles(whoop_user_id, upstream_updated_at); +CREATE INDEX IF NOT EXISTS idx_whoop_profiles_deleted ON whoop_profiles(deleted_at); + +CREATE TABLE IF NOT EXISTS whoop_body_measurements ( + whoop_user_id INTEGER PRIMARY KEY, + height_meter REAL, + weight_kilogram REAL, + max_heart_rate INTEGER, + upstream_created_at TEXT, + upstream_updated_at TEXT, + deleted_at TEXT, + synced_at TEXT NOT NULL, + raw_json TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_whoop_body_measurements_user_updated ON whoop_body_measurements(whoop_user_id, upstream_updated_at); +CREATE INDEX IF NOT EXISTS idx_whoop_body_measurements_deleted ON whoop_body_measurements(deleted_at); + +CREATE TABLE IF NOT EXISTS whoop_cycles ( + cycle_id INTEGER PRIMARY KEY, + whoop_user_id INTEGER NOT NULL, + start_at TEXT NOT NULL, + end_at TEXT, + timezone_offset TEXT, + score_state TEXT, + strain REAL, + kilojoules REAL, + average_heart_rate REAL, + max_heart_rate REAL, + upstream_created_at TEXT NOT NULL, + upstream_updated_at TEXT NOT NULL, + deleted_at TEXT, + synced_at TEXT NOT NULL, + raw_json TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_whoop_cycles_user_start ON whoop_cycles(whoop_user_id, start_at); +CREATE INDEX IF NOT EXISTS idx_whoop_cycles_user_end ON whoop_cycles(whoop_user_id, end_at); +CREATE INDEX IF NOT EXISTS idx_whoop_cycles_deleted ON whoop_cycles(deleted_at); + +CREATE TABLE IF NOT EXISTS whoop_recoveries ( + sleep_id TEXT PRIMARY KEY, + cycle_id INTEGER NOT NULL, + whoop_user_id INTEGER NOT NULL, + score_state TEXT, + user_calibrating INTEGER, + recovery_score REAL, + resting_heart_rate REAL, + hrv_rmssd_milliseconds REAL, + spo2_percentage REAL, + skin_temperature_celsius REAL, + upstream_created_at TEXT NOT NULL, + upstream_updated_at TEXT NOT NULL, + deleted_at TEXT, + synced_at TEXT NOT NULL, + raw_json TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_whoop_recoveries_user_updated ON whoop_recoveries(whoop_user_id, upstream_updated_at); +CREATE INDEX IF NOT EXISTS idx_whoop_recoveries_deleted ON whoop_recoveries(deleted_at); + +CREATE TABLE IF NOT EXISTS whoop_sleeps ( + sleep_id TEXT PRIMARY KEY, + cycle_id INTEGER NOT NULL, + whoop_user_id INTEGER NOT NULL, + start_at TEXT, + end_at TEXT, + timezone_offset TEXT, + nap INTEGER, + score_state TEXT, + stage_awake_milliseconds INTEGER, + stage_in_bed_milliseconds INTEGER, + stage_no_data_milliseconds INTEGER, + stage_light_milliseconds INTEGER, + stage_slow_wave_milliseconds INTEGER, + stage_rem_milliseconds INTEGER, + sleep_needed_milliseconds INTEGER, + sleep_debt_milliseconds INTEGER, + sleep_need_recent_strain_milliseconds INTEGER, + sleep_need_recent_nap_milliseconds INTEGER, + sleep_cycle_count INTEGER, + disturbance_count INTEGER, + sleep_efficiency_percentage REAL, + sleep_consistency_percentage REAL, + sleep_performance_percentage REAL, + respiratory_rate REAL, + upstream_created_at TEXT NOT NULL, + upstream_updated_at TEXT NOT NULL, + deleted_at TEXT, + synced_at TEXT NOT NULL, + raw_json TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_whoop_sleeps_user_start ON whoop_sleeps(whoop_user_id, start_at); +CREATE INDEX IF NOT EXISTS idx_whoop_sleeps_user_end ON whoop_sleeps(whoop_user_id, end_at); +CREATE INDEX IF NOT EXISTS idx_whoop_sleeps_deleted ON whoop_sleeps(deleted_at); + +CREATE TABLE IF NOT EXISTS whoop_workouts ( + workout_id TEXT PRIMARY KEY, + whoop_user_id INTEGER NOT NULL, + start_at TEXT, + end_at TEXT, + timezone_offset TEXT, + sport_id INTEGER, + sport_name TEXT, + score_state TEXT, + strain REAL, + average_heart_rate REAL, + max_heart_rate REAL, + kilojoules REAL, + percent_recorded REAL, + distance_meter REAL, + elevation_gain_meter REAL, + zone_zero_milliseconds INTEGER, + zone_one_milliseconds INTEGER, + zone_two_milliseconds INTEGER, + zone_three_milliseconds INTEGER, + zone_four_milliseconds INTEGER, + zone_five_milliseconds INTEGER, + upstream_created_at TEXT NOT NULL, + upstream_updated_at TEXT NOT NULL, + deleted_at TEXT, + synced_at TEXT NOT NULL, + raw_json TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_whoop_workouts_user_start ON whoop_workouts(whoop_user_id, start_at); +CREATE INDEX IF NOT EXISTS idx_whoop_workouts_user_end ON whoop_workouts(whoop_user_id, end_at); +CREATE INDEX IF NOT EXISTS idx_whoop_workouts_deleted ON whoop_workouts(deleted_at); + +CREATE TABLE IF NOT EXISTS whoop_webhook_events ( + trace_id TEXT PRIMARY KEY, + whoop_user_id INTEGER NOT NULL, + connection_id TEXT NOT NULL, + resource_id TEXT NOT NULL, + event_type TEXT NOT NULL CHECK (event_type IN ('workout.updated', 'workout.deleted', 'sleep.updated', 'sleep.deleted', 'recovery.updated', 'recovery.deleted')), + received_at TEXT NOT NULL, + processed_at TEXT, + status TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT +); + +CREATE INDEX IF NOT EXISTS idx_whoop_webhook_events_user_received ON whoop_webhook_events(whoop_user_id, received_at); + +CREATE TABLE IF NOT EXISTS whoop_reconcile_seen ( + whoop_user_id INTEGER NOT NULL, + connection_id TEXT NOT NULL, + reconcile_generation INTEGER NOT NULL, + reconcile_run_id TEXT NOT NULL, + resource TEXT NOT NULL, + provider_id TEXT NOT NULL, + seen_at TEXT NOT NULL, + PRIMARY KEY (whoop_user_id, connection_id, reconcile_generation, reconcile_run_id, resource, provider_id) +); + +CREATE TABLE IF NOT EXISTS whoop_sync_checkpoints ( + whoop_user_id INTEGER NOT NULL, + connection_id TEXT NOT NULL, + resource TEXT NOT NULL, + mode TEXT NOT NULL, + reconcile_generation INTEGER NOT NULL, + sync_run_id TEXT NOT NULL, + target_id TEXT NOT NULL, + window_start TEXT, + window_end TEXT, + next_token TEXT, + status TEXT NOT NULL, + page_count INTEGER NOT NULL DEFAULT 0, + record_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_error TEXT, + PRIMARY KEY (whoop_user_id, connection_id, resource, mode, reconcile_generation, sync_run_id, target_id) +); + +CREATE INDEX IF NOT EXISTS idx_whoop_sync_checkpoints_progress + ON whoop_sync_checkpoints(whoop_user_id, connection_id, target_id, resource, mode, reconcile_generation, created_at); + +CREATE TABLE IF NOT EXISTS whoop_sync_runs ( + run_id TEXT PRIMARY KEY, + whoop_user_id INTEGER NOT NULL, + connection_id TEXT NOT NULL, + reconcile_generation INTEGER NOT NULL, + trigger TEXT NOT NULL, + status TEXT NOT NULL, + expected_target_count INTEGER NOT NULL, + completed_target_count INTEGER NOT NULL DEFAULT 0, + page_count INTEGER NOT NULL DEFAULT 0, + record_count INTEGER NOT NULL DEFAULT 0, + started_at TEXT NOT NULL, + succeeded_at TEXT, + failed_at TEXT, + last_error TEXT +); + +CREATE INDEX IF NOT EXISTS idx_whoop_sync_runs_user_started ON whoop_sync_runs(whoop_user_id, started_at); diff --git a/package.json b/package.json index af1b702..df55b01 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "enrich:shelf-books": "node scripts/enrich-shelf-books.mjs", "sync:shelf-posters": "node scripts/enrich-shelf-posters-tmdb.mjs --commit", "test": "vitest run", + "test:whoop": "vitest run src/__tests__/whoop", "test:watch": "vitest", "typecheck": "tsc --noEmit" }, diff --git a/readme.md b/readme.md index 002b08a..abf4c1d 100644 --- a/readme.md +++ b/readme.md @@ -233,7 +233,26 @@ List endpoints support `?limit&offset&search&sort&tags&start&end` where applicab - `DELETE /v1/location/{id}` - `GET /v1/location/latest` -### Apple Health Data +### WHOOP Health Source + +WHOOP is the sole ongoing wearable source. See [WHOOP_HEALTH_SOURCE.md](docs/WHOOP_HEALTH_SOURCE.md) for the OAuth, synchronization, units, deletion, export, and rollout contract. + +- `GET /v1/integrations/whoop` - Get connection and synchronization status +- `POST /v1/integrations/whoop/connect` - Begin WHOOP OAuth +- `POST /v1/integrations/whoop/sync` - Request asynchronous reconciliation +- `DELETE /v1/integrations/whoop` - Revoke and disconnect WHOOP +- `DELETE /v1/integrations/whoop/data` - Delete local WHOOP data after disconnect +- `GET /v1/health/whoop/overview` - Get current health state and bounded trends +- `GET /v1/health/whoop/profile` - Get the current WHOOP profile +- `GET /v1/health/whoop/cycles` - Get WHOOP cycles +- `GET /v1/health/whoop/recoveries` - Get WHOOP recoveries +- `GET /v1/health/whoop/sleeps` - Get WHOOP sleeps +- `GET /v1/health/whoop/workouts` - Get WHOOP workouts +- `GET /v1/health/whoop/workouts/{workoutId}` - Get WHOOP workout detail + +### Apple Health Data (Legacy History) + +These endpoints remain unchanged for historical Apple Health data. WHOOP replaces Apple Health only as the ongoing wearable source; this release does not delete or rewrite Apple rows. - `GET /v1/health` - Get daily health metrics (date range) - `POST /v1/health` - Submit daily health metrics @@ -251,7 +270,7 @@ List endpoints support `?limit&offset&search&sort&tags&start&end` where applicab - `DELETE /v1/health/workouts/{id}` - Delete workout - `GET /v1/health/summary` - Get health summary with recent data and averages -See [APPLE_HEALTH_EXPORT.md](docs/APPLE_HEALTH_EXPORT.md) for detailed health API documentation. +See [APPLE_HEALTH_EXPORT.md](docs/APPLE_HEALTH_EXPORT.md) for legacy Apple Health API documentation. ### External Integrations diff --git a/src/__tests__/scheduled.test.ts b/src/__tests__/scheduled.test.ts index 711ada2..0e9a9df 100644 --- a/src/__tests__/scheduled.test.ts +++ b/src/__tests__/scheduled.test.ts @@ -1,6 +1,12 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Env } from "../types/env"; -import { runRefreshJob } from "../scheduled"; +import { handleScheduled, runRefreshJob, type ScheduledDependencies } from "../scheduled"; +import { enqueueReconciliation as enqueueCanonicalReconciliation } from "../services/whoop/sync"; +import type { WhoopQueueMessage } from "../types/whoop"; +import { CONNECTION_ID, ENV, NOW } from "./whoop/fixtures"; + +const RESOURCES = ["profile", "body_measurement", "cycle", "recovery", "sleep", "workout"] as const; +const SCHEDULED_EVENT = {} as ScheduledEvent; function createDb() { const calls: Array<{ sql: string; bindings: unknown[] }> = []; @@ -20,6 +26,56 @@ function createDb() { return { db: db as unknown as D1Database, calls }; } +function createScheduledDependencies() { + const repository = { + getPendingInitialBackfills: vi.fn().mockResolvedValue([]), + markInitialBackfillQueued: vi.fn().mockResolvedValue(true), + getCurrentConnection: vi.fn().mockResolvedValue(null), + withWhoopAccessToken: vi.fn(async (_userId, request) => request("fixture-access-token", 3)), + beginReconciliation: vi.fn().mockResolvedValue(8), + getPendingRecoveryCycleIds: vi.fn().mockResolvedValue([]), + pruneOperationalData: vi.fn().mockResolvedValue({ + oauthStates: 0, checkpoints: 0, runs: 0, seen: 0, webhookReceipts: 0, + }), + createSyncRun: vi.fn().mockResolvedValue(true), + markSyncRunPublicationFailure: vi.fn().mockResolvedValue(true), + recordSyncFailure: vi.fn().mockResolvedValue(true), + }; + const enqueueReconciliation = vi.fn().mockResolvedValue(undefined); + const refreshJobs = { + lanyard: vi.fn().mockResolvedValue(undefined), + wakatime: vi.fn().mockResolvedValue(undefined), + github: vi.fn().mockResolvedValue(undefined), + }; + return { + dependencies: { + repository, + enqueueReconciliation, + refreshJobs, + now: () => new Date(NOW), + }, + enqueueReconciliation, + refreshJobs, + repository, + }; +} + +async function runScheduled( + env: Env, + dependencies: ScheduledDependencies, +) { + const scheduled = handleScheduled as unknown as ( + event: ScheduledEvent, + bindings: Env, + injected: typeof dependencies, + ) => Promise; + await scheduled(SCHEDULED_EVENT, env, dependencies); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + describe("scheduled refresh health", () => { it("records start and success for a completed job", async () => { const { db, calls } = createDb(); @@ -50,4 +106,234 @@ describe("scheduled refresh health", () => { expect(calls[1].bindings[2]).toContain("rate limited"); expect(calls[1].sql).toContain("consecutive_failures + 1"); }); + + it("replays each durable initial backfill as one exact six-message batch before clearing intent", async () => { + const { db } = createDb(); + const queue = { send: vi.fn(), sendBatch: vi.fn().mockResolvedValue(undefined) }; + const env = { ...ENV, DB: db, WHOOP_SYNC_QUEUE: queue as unknown as Queue }; + const { dependencies, repository } = createScheduledDependencies(); + repository.getPendingInitialBackfills.mockResolvedValue([{ + whoopUserId: 42, + connectionId: "connection-3", + credentialVersion: 3, + }]); + + await runScheduled(env, dependencies); + + expect(queue.sendBatch).toHaveBeenCalledWith(RESOURCES.map((resource) => ({ + body: { + kind: "backfill", + whoopUserId: 42, + connectionId: "connection-3", + resource, + }, + }))); + expect(repository.markInitialBackfillQueued).toHaveBeenCalledWith( + 42, + "connection-3", + 3, + NOW, + ); + expect(queue.send).not.toHaveBeenCalled(); + }); + + it("keeps ambiguous backfill intent pending and replays the same batch on the next schedule", async () => { + const { db } = createDb(); + const queue = { + send: vi.fn(), + sendBatch: vi.fn() + .mockRejectedValueOnce(new Error("queue publication unknown")) + .mockResolvedValueOnce(undefined), + }; + const env = { ...ENV, DB: db, WHOOP_SYNC_QUEUE: queue as unknown as Queue }; + const { dependencies, repository } = createScheduledDependencies(); + repository.getPendingInitialBackfills.mockResolvedValue([{ + whoopUserId: 42, + connectionId: "connection-3", + credentialVersion: 3, + }]); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await runScheduled(env, dependencies); + expect(repository.markInitialBackfillQueued).not.toHaveBeenCalled(); + + await runScheduled(env, dependencies); + + expect(queue.sendBatch).toHaveBeenCalledTimes(2); + expect(queue.sendBatch.mock.calls[1][0]).toEqual(queue.sendBatch.mock.calls[0][0]); + expect(repository.markInitialBackfillQueued).toHaveBeenCalledTimes(1); + error.mockRestore(); + }); + + it("delegates active connections to the canonical scheduled reconciliation producer", async () => { + const { db } = createDb(); + const queue = { send: vi.fn(), sendBatch: vi.fn() }; + const env = { ...ENV, DB: db, WHOOP_SYNC_QUEUE: queue as unknown as Queue }; + const { dependencies, enqueueReconciliation, repository } = createScheduledDependencies(); + repository.getCurrentConnection.mockResolvedValue({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + credentialVersion: 3, + reconcileGeneration: 7, + status: "active", + }); + + await runScheduled(env, dependencies); + + expect(enqueueReconciliation).toHaveBeenCalledWith( + env, + 42, + "scheduled", + { + repository, + now: dependencies.now, + expectedConnectionId: CONNECTION_ID, + requireActiveConnection: true, + }, + ); + expect(queue.send).not.toHaveBeenCalled(); + expect(queue.sendBatch).not.toHaveBeenCalled(); + }); + + it("runs bounded WHOOP retention as an isolated scheduled job", async () => { + const { db } = createDb(); + const env = { ...ENV, DB: db }; + const { dependencies, repository } = createScheduledDependencies(); + + await runScheduled(env, dependencies); + + expect(repository.pruneOperationalData).toHaveBeenCalledWith(NOW); + }); + + it("does not let retention failure suppress reconciliation or existing jobs", async () => { + const { db } = createDb(); + const env = { ...ENV, DB: db }; + const { dependencies, enqueueReconciliation, refreshJobs, repository } = createScheduledDependencies(); + repository.pruneOperationalData.mockRejectedValue(new Error("retention unavailable")); + repository.getCurrentConnection.mockResolvedValue({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + credentialVersion: 3, + reconcileGeneration: 7, + status: "active", + }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await runScheduled(env, dependencies); + + expect(enqueueReconciliation).toHaveBeenCalledTimes(1); + expect(refreshJobs.lanyard).toHaveBeenCalledTimes(1); + expect(refreshJobs.wakatime).toHaveBeenCalledTimes(1); + expect(refreshJobs.github).toHaveBeenCalledTimes(1); + error.mockRestore(); + }); + + it("checks token expiry through the serialized refresh path without making an access-token request", async () => { + const { db } = createDb(); + const env = { ...ENV, DB: db }; + const { dependencies, enqueueReconciliation, repository } = createScheduledDependencies(); + repository.getCurrentConnection.mockResolvedValue({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + credentialVersion: 3, + reconcileGeneration: 7, + status: "active", + }); + repository.withWhoopAccessToken.mockImplementation(async (_userId, request) => { + const result = await request("must-not-leave-local-callback", 3); + expect(result).toBeUndefined(); + return result; + }); + + await runScheduled(env, dependencies); + + expect(repository.withWhoopAccessToken).toHaveBeenCalledWith( + 42, + expect.any(Function), + expect.any(Function), + { + expectedConnectionId: CONNECTION_ID, + refreshBeforeExpirationMilliseconds: 5 * 60 * 1000, + }, + ); + expect(repository.withWhoopAccessToken.mock.invocationCallOrder[0]) + .toBeLessThan(enqueueReconciliation.mock.invocationCallOrder[0]); + }); + + it("does not reconcile a replacement backfilling lifecycle after active token preflight", async () => { + const { db } = createDb(); + const queue = { send: vi.fn(), sendBatch: vi.fn() }; + const env = { ...ENV, DB: db, WHOOP_SYNC_QUEUE: queue as unknown as Queue }; + const { dependencies, repository } = createScheduledDependencies(); + repository.getCurrentConnection + .mockResolvedValueOnce({ + whoopUserId: 42, + connectionId: "connection-c1", + credentialVersion: 3, + reconcileGeneration: 7, + status: "active", + }) + .mockResolvedValueOnce({ + whoopUserId: 42, + connectionId: "connection-c2", + credentialVersion: 4, + reconcileGeneration: 0, + status: "backfilling", + }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await runScheduled(env, { + ...dependencies, + enqueueReconciliation: enqueueCanonicalReconciliation, + }); + + expect(repository.beginReconciliation).not.toHaveBeenCalled(); + expect(repository.getPendingRecoveryCycleIds).not.toHaveBeenCalled(); + expect(queue.sendBatch).not.toHaveBeenCalled(); + error.mockRestore(); + }); + + it("starts reconciliation only for active connections", async () => { + const { db } = createDb(); + const env = { ...ENV, DB: db }; + const { dependencies, enqueueReconciliation, repository } = createScheduledDependencies(); + + for (const status of ["active", "backfilling", "disconnected"] as const) { + repository.getCurrentConnection.mockResolvedValueOnce({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + credentialVersion: 3, + reconcileGeneration: 7, + status, + }); + await runScheduled(env, dependencies); + } + + expect(enqueueReconciliation).toHaveBeenCalledTimes(1); + }); + + it("keeps WHOOP and every existing scheduled job isolated through allSettled", async () => { + const { db, calls } = createDb(); + const env = { ...ENV, DB: db }; + const { dependencies, enqueueReconciliation, refreshJobs, repository } = createScheduledDependencies(); + refreshJobs.lanyard.mockRejectedValue(new Error("lanyard unavailable")); + repository.getCurrentConnection.mockResolvedValue({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + credentialVersion: 3, + reconcileGeneration: 7, + status: "active", + }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await runScheduled(env, dependencies); + + expect(refreshJobs.lanyard).toHaveBeenCalledTimes(1); + expect(refreshJobs.wakatime).toHaveBeenCalledTimes(1); + expect(refreshJobs.github).toHaveBeenCalledTimes(1); + expect(enqueueReconciliation).toHaveBeenCalledTimes(1); + expect(calls.filter(({ bindings }) => bindings[0] === "lanyard")).toHaveLength(2); + expect(calls.filter(({ bindings }) => bindings[0] === "whoop")).toHaveLength(2); + error.mockRestore(); + }); }); diff --git a/src/__tests__/whoop/client.test.ts b/src/__tests__/whoop/client.test.ts new file mode 100644 index 0000000..576b63d --- /dev/null +++ b/src/__tests__/whoop/client.test.ts @@ -0,0 +1,272 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + WhoopClient, + WhoopRequestError, + WhoopUnauthorizedError, +} from "../../services/whoop/client"; +import { + BODY_MEASUREMENT, + CYCLE, + CURRENT_CYCLE, + ENV, + PROFILE, + RECOVERY, + SLEEP, + WORKOUT, + jsonResponse, +} from "./fixtures"; + +const TOKEN_RESPONSE = { + access_token: "rotated-access", + refresh_token: "rotated-refresh", + expires_in: 3600, + token_type: "bearer", +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("WHOOP v2 client", () => { + it("uses the v2 activity path, bearer token, next token, and original record JSON", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + jsonResponse({ records: [SLEEP], next_token: "page-2" }), + ); + const client = new WhoopClient(ENV, "access"); + + const page = await client.getCollection("sleep", { limit: 25 }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.prod.whoop.com/developer/v2/activity/sleep?limit=25", + expect.objectContaining({ headers: expect.objectContaining({ authorization: "Bearer access" }) }), + ); + expect(page.nextToken).toBe("page-2"); + expect(page.records).toEqual([{ ...SLEEP, rawJson: JSON.stringify(SLEEP) }]); + }); + + it("uses the v2 collection and detail paths for every activity resource", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(jsonResponse({ records: [CYCLE] })) + .mockResolvedValueOnce(jsonResponse({ records: [RECOVERY] })) + .mockResolvedValueOnce(jsonResponse({ records: [WORKOUT] })) + .mockResolvedValueOnce(jsonResponse(CURRENT_CYCLE)) + .mockResolvedValueOnce(jsonResponse(RECOVERY)) + .mockResolvedValueOnce(jsonResponse(SLEEP)) + .mockResolvedValueOnce(jsonResponse(WORKOUT)); + const client = new WhoopClient(ENV, "access"); + + await client.getCollection("cycle", { start: "2026-08-19T00:00:00.000Z", end: "2026-08-20T00:00:00.000Z", nextToken: "next" }); + await client.getCollection("recovery", { limit: 25 }); + await client.getCollection("workout", { limit: 25 }); + await client.getCycle(9); + await client.getRecovery(9); + await client.getSleep(SLEEP.id); + await client.getWorkout(WORKOUT.id); + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "https://api.prod.whoop.com/developer/v2/cycle?start=2026-08-19T00%3A00%3A00.000Z&end=2026-08-20T00%3A00%3A00.000Z&limit=25&nextToken=next", + "https://api.prod.whoop.com/developer/v2/recovery?limit=25", + "https://api.prod.whoop.com/developer/v2/activity/workout?limit=25", + "https://api.prod.whoop.com/developer/v2/cycle/9", + "https://api.prod.whoop.com/developer/v2/cycle/9/recovery", + `https://api.prod.whoop.com/developer/v2/activity/sleep/${SLEEP.id}`, + `https://api.prod.whoop.com/developer/v2/activity/workout/${WORKOUT.id}`, + ]); + }); + + it("validates user responses and keeps body user context outside the provider payload", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(jsonResponse(PROFILE)) + .mockResolvedValueOnce(jsonResponse(BODY_MEASUREMENT)); + const client = new WhoopClient(ENV, "access"); + + await expect(client.getProfile()).resolves.toEqual({ ...PROFILE, rawJson: JSON.stringify(PROFILE) }); + await expect(client.getBodyMeasurements()).resolves.toEqual({ + ...BODY_MEASUREMENT, + rawJson: JSON.stringify(BODY_MEASUREMENT), + }); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "https://api.prod.whoop.com/developer/v2/user/profile/basic", + "https://api.prod.whoop.com/developer/v2/user/measurement/body", + ]); + }); + + it("exchanges and refreshes tokens through the OAuth endpoint without logging token values", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(jsonResponse(TOKEN_RESPONSE)) + .mockResolvedValueOnce(jsonResponse(TOKEN_RESPONSE)); + const client = new WhoopClient(ENV, "access"); + + await expect(client.exchangeAuthorizationCode("test-code")).resolves.toEqual(TOKEN_RESPONSE); + await expect(client.refreshToken("test-refresh")).resolves.toEqual(TOKEN_RESPONSE); + + expect(fetchMock).toHaveBeenNthCalledWith(1, + "https://api.prod.whoop.com/oauth/oauth2/token", + expect.objectContaining({ + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "grant_type=authorization_code&code=test-code&redirect_uri=https%3A%2F%2Fapi.example.test%2Fintegrations%2Fwhoop%2Fcallback&client_id=test-whoop-client-id&client_secret=test-whoop-client-secret", + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith(2, + "https://api.prod.whoop.com/oauth/oauth2/token", + expect.objectContaining({ body: "grant_type=refresh_token&refresh_token=test-refresh&scope=offline&client_id=test-whoop-client-id&client_secret=test-whoop-client-secret" }), + ); + }); + + it("passes the refresh abort signal to the token request fetch", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse(TOKEN_RESPONSE)); + const controller = new AbortController(); + const client = new WhoopClient(ENV, "access"); + + await client.refreshToken("test-refresh", { signal: controller.signal }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.prod.whoop.com/oauth/oauth2/token", + expect.objectContaining({ signal: controller.signal }), + ); + }); + + it("classifies a rejected refresh transport as an ambiguous rotation outcome", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("connection lost")); + const client = new WhoopClient(ENV, "access"); + + await expect(client.refreshToken("test-refresh")).rejects.toMatchObject({ + name: "WhoopRefreshAmbiguousError", + refreshOutcome: "ambiguous", + status: undefined, + }); + }); + + it.each([ + ["truncated JSON", new Response("{", { status: 200, headers: { "content-type": "application/json" } })], + ["schema-invalid JSON", jsonResponse({ access_token: "only-one-field" })], + ])("classifies a 2xx refresh response with %s as ambiguous", async (_label, response) => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(response); + const client = new WhoopClient(ENV, "access"); + + await expect(client.refreshToken("test-refresh")).rejects.toMatchObject({ + name: "WhoopRefreshAmbiguousError", + refreshOutcome: "ambiguous", + }); + }); + + it("classifies a refresh 5xx as ambiguous after dispatch", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 503 })); + const client = new WhoopClient(ENV, "access"); + + await expect(client.refreshToken("test-refresh")).rejects.toMatchObject({ + name: "WhoopRefreshAmbiguousError", + refreshOutcome: "ambiguous", + status: 503, + retryable: true, + }); + }); + + it.each([400, 401, 429])("classifies an explicit refresh %i as a definite failure", async (status) => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status })); + const client = new WhoopClient(ENV, "access"); + + await expect(client.refreshToken("test-refresh")).rejects.toMatchObject({ + name: "WhoopRefreshDefiniteError", + refreshOutcome: "definite", + status, + }); + }); + + it("revokes access at the documented v2 endpoint", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 204 })); + const client = new WhoopClient(ENV, "access"); + + await expect(client.revokeAccess("access-to-revoke")).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.prod.whoop.com/developer/v2/user/access", + expect.objectContaining({ method: "DELETE", headers: { authorization: "Bearer access-to-revoke" } }), + ); + }); + + it("turns a retry-after response into a retryable sanitized error", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("provider detail must not be exposed", { status: 429, headers: { "retry-after": "30" } }), + ); + const client = new WhoopClient(ENV, "access"); + + await expect(client.getCollection("workout", { limit: 25 })).rejects.toMatchObject({ + name: "WhoopRequestError", + status: 429, + retryAfterSeconds: 30, + retryable: true, + }); + await expect(client.getCollection("workout", { limit: 25 })).rejects.not.toThrow("provider detail must not be exposed"); + }); + + it("uses the rate-limit reset as a retry delay and makes 5xx retryable", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("provider detail must not be exposed", { + status: 503, + headers: { "x-ratelimit-reset": "1800000060" }, + }), + ); + vi.spyOn(Date, "now").mockReturnValue(1_800_000_000_000); + const client = new WhoopClient(ENV, "access"); + + await expect(client.getProfile()).rejects.toMatchObject({ + name: "WhoopRequestError", + status: 503, + retryAfterSeconds: 60, + retryable: true, + }); + }); + + it("uses the provider maximum of 25 when no collection limit is supplied", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ records: [SLEEP] })); + const client = new WhoopClient(ENV, "access"); + + await client.getCollection("sleep"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.prod.whoop.com/developer/v2/activity/sleep?limit=25", + expect.any(Object), + ); + }); + + it("rejects provider collection limits outside the whole-number range 1 through 25 before fetching", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch"); + const client = new WhoopClient(ENV, "access"); + + await expect(client.getCollection("sleep", { limit: 0 })).rejects.toThrow("WHOOP collection limit must be an integer from 1 to 25"); + await expect(client.getCollection("sleep", { limit: 12.5 })).rejects.toThrow("WHOOP collection limit must be an integer from 1 to 25"); + await expect(client.getCollection("sleep", { limit: 26 })).rejects.toThrow("WHOOP collection limit must be an integer from 1 to 25"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects invalid provider IDs before constructing user-data paths", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch"); + const client = new WhoopClient(ENV, "access"); + + await expect(client.getCycle(0)).rejects.toThrow("WHOOP cycle ID must be a positive integer"); + await expect(client.getCycle(1.5)).rejects.toThrow("WHOOP cycle ID must be a positive integer"); + await expect(client.getRecovery(-1)).rejects.toThrow("WHOOP cycle ID must be a positive integer"); + await expect(client.getSleep("not-a-uuid")).rejects.toThrow("WHOOP activity ID must be a UUID"); + await expect(client.getWorkout("../../not-a-uuid")).rejects.toThrow("WHOOP activity ID must be a UUID"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("identifies unauthorized responses and never includes upstream bodies in errors", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("authorization bearer access must not appear", { status: 401 }), + ); + const client = new WhoopClient(ENV, "access"); + + await expect(client.getProfile()).rejects.toBeInstanceOf(WhoopUnauthorizedError); + await expect(client.getProfile()).rejects.not.toThrow("authorization bearer access must not appear"); + }); + + it("rejects malformed successful provider payloads without treating them as HTTP retry errors", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ user_id: 42 })); + const client = new WhoopClient(ENV, "access"); + + await expect(client.getProfile()).rejects.not.toBeInstanceOf(WhoopRequestError); + }); +}); diff --git a/src/__tests__/whoop/crypto.test.ts b/src/__tests__/whoop/crypto.test.ts new file mode 100644 index 0000000..b015b51 --- /dev/null +++ b/src/__tests__/whoop/crypto.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + createOAuthState, + decryptWhoopToken, + encryptWhoopToken, + hashOAuthState, +} from "../../services/whoop/crypto"; +import { KEY } from "./fixtures"; + +describe("WHOOP crypto primitives", () => { + it("round-trips a token only with its matching user and token kind", async () => { + const encrypted = await encryptWhoopToken(KEY, 42, "refresh", "fixture-refresh-token"); + + await expect(decryptWhoopToken(KEY, 42, "refresh", encrypted)).resolves.toBe("fixture-refresh-token"); + await expect(decryptWhoopToken(KEY, 43, "refresh", encrypted)).rejects.toThrow("WHOOP token decryption failed"); + await expect(decryptWhoopToken(KEY, 42, "access", encrypted)).rejects.toThrow("WHOOP token decryption failed"); + }); + + it("creates high-entropy state and hashes it deterministically", async () => { + const state = await createOAuthState(); + + expect(state).toMatch(/^[A-Za-z0-9]{8}$/); + expect(await hashOAuthState(state)).toBe(await hashOAuthState(state)); + expect(await hashOAuthState(state)).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("rejects encryption keys that are not exactly 32 bytes", async () => { + const invalidKey = btoa("too-short"); + + await expect(encryptWhoopToken(invalidKey, 42, "access", "fixture-access-token")) + .rejects.toThrow("WHOOP token encryption key must be 32 bytes"); + }); + + it("rejects malformed encryption keys without exposing key material", async () => { + await expect(encryptWhoopToken("not-base64url", 42, "access", "fixture-access-token")) + .rejects.toThrow("WHOOP token encryption key must be 32 bytes"); + }); +}); diff --git a/src/__tests__/whoop/export.test.ts b/src/__tests__/whoop/export.test.ts new file mode 100644 index 0000000..6662ef8 --- /dev/null +++ b/src/__tests__/whoop/export.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import worker from "../../index"; +import type { Env } from "../../types/env"; +import { ENV, bearerGet } from "./fixtures"; + +type SqliteStatement = { + all: (...bindings: unknown[]) => unknown[]; + run: (...bindings: unknown[]) => { changes: number | bigint }; +}; + +type SqliteDatabase = { + close: () => void; + exec: (sql: string) => void; + prepare: (sql: string) => SqliteStatement; +}; + +class ExportD1Statement { + constructor( + private readonly database: SqliteDatabase, + private readonly sql: string, + private readonly bindings: unknown[] = [], + ) {} + + bind(...bindings: unknown[]) { + return new ExportD1Statement(this.database, this.sql, bindings); + } + + async all() { + if (!/\bFROM\s+whoop_/i.test(this.sql)) { + return { results: [] as T[], success: true, meta: {} }; + } + + return { + results: this.database.prepare(this.sql).all(...this.bindings) as T[], + success: true, + meta: {}, + }; + } +} + +const sqliteExportD1 = (database: SqliteDatabase) => ({ + prepare: (sql: string) => new ExportD1Statement(database, sql), +}) as unknown as D1Database; + +describe("WHOOP export", () => { + let database: SqliteDatabase; + let env: Env; + + beforeEach(async () => { + // @ts-expect-error The Worker typecheck intentionally excludes Node test-runtime declarations. + const { DatabaseSync } = await import("node:sqlite"); + // @ts-expect-error The Worker typecheck intentionally excludes Node test-runtime declarations. + const { readFile } = await import("node:fs/promises"); + database = new DatabaseSync(":memory:") as SqliteDatabase; + database.exec(await readFile("migrations/0020_whoop.sql", "utf8")); + env = { ...ENV, DB: sqliteExportD1(database) }; + + database.exec(` + INSERT INTO whoop_profiles ( + whoop_user_id, first_name, last_name, email, upstream_created_at, + upstream_updated_at, deleted_at, synced_at, raw_json + ) VALUES ( + 42, 'Fixture', 'User', 'fixture@whoop.test', + '2026-08-01T00:00:00.000Z', '2026-08-20T11:55:00.000Z', NULL, + '2026-08-20T11:56:00.000Z', '{"refresh_token":"raw-profile-secret"}' + ); + + INSERT INTO whoop_body_measurements ( + whoop_user_id, height_meter, weight_kilogram, max_heart_rate, + upstream_created_at, upstream_updated_at, deleted_at, synced_at, raw_json + ) VALUES ( + 42, 1.8, 75, 190, NULL, '2026-08-20T11:55:00.000Z', NULL, + '2026-08-20T11:56:00.000Z', '{"access_token":"raw-body-secret"}' + ); + + INSERT INTO whoop_cycles ( + cycle_id, whoop_user_id, start_at, end_at, timezone_offset, score_state, + strain, kilojoules, average_heart_rate, max_heart_rate, + upstream_created_at, upstream_updated_at, deleted_at, synced_at, raw_json + ) VALUES ( + 9, 42, '2026-08-20T04:00:00.000Z', NULL, '-04:00', 'SCORED', + 12.3, 836.8, 75, 190, '2026-08-20T04:00:00.000Z', + '2026-08-20T11:50:00.000Z', NULL, '2026-08-20T11:51:00.000Z', + '{"signature":"raw-cycle-secret"}' + ); + + INSERT INTO whoop_recoveries ( + sleep_id, cycle_id, whoop_user_id, score_state, user_calibrating, + recovery_score, resting_heart_rate, hrv_rmssd_milliseconds, + spo2_percentage, skin_temperature_celsius, upstream_created_at, + upstream_updated_at, deleted_at, synced_at, raw_json + ) VALUES ( + '00000000-0000-4000-8000-000000000001', 9, 42, 'SCORED', 0, + 82, 52, 64, 97, 33.2, '2026-08-20T04:00:00.000Z', + '2026-08-20T11:50:00.000Z', NULL, '2026-08-20T11:51:00.000Z', + '{"nonce":"raw-recovery-secret"}' + ); + + INSERT INTO whoop_sleeps ( + sleep_id, cycle_id, whoop_user_id, start_at, end_at, timezone_offset, + nap, score_state, stage_in_bed_milliseconds, stage_no_data_milliseconds, + sleep_need_recent_strain_milliseconds, sleep_need_recent_nap_milliseconds, + sleep_cycle_count, disturbance_count, sleep_efficiency_percentage, upstream_created_at, + upstream_updated_at, deleted_at, synced_at, raw_json + ) VALUES ( + '00000000-0000-4000-8000-000000000001', 9, 42, + '2026-08-20T04:00:00.000Z', '2026-08-20T11:00:00.000Z', '-04:00', + 0, 'SCORED', 28800000, 60000, 600000, -300000, 5, 9, 91, + '2026-08-20T04:00:00.000Z', + '2026-08-20T11:50:00.000Z', NULL, '2026-08-20T11:51:00.000Z', + '{"last_error":"raw-sleep-secret"}' + ); + + INSERT INTO whoop_workouts ( + workout_id, whoop_user_id, start_at, end_at, timezone_offset, sport_id, + sport_name, score_state, strain, kilojoules, upstream_created_at, + upstream_updated_at, deleted_at, synced_at, raw_json + ) VALUES ( + '00000000-0000-4000-8000-000000000002', 42, + '2026-08-19T10:00:00.000Z', '2026-08-19T11:00:00.000Z', '-04:00', 1, + 'running', 'SCORED', 10, 418.4, '2026-08-19T10:00:00.000Z', + '2026-08-19T11:00:00.000Z', '2026-08-20T12:00:00.000Z', + '2026-08-20T12:01:00.000Z', '{"ciphertext":"raw-workout-secret"}' + ); + + INSERT INTO whoop_connections ( + whoop_user_id, connection_id, status, access_token_ciphertext, + access_token_nonce, refresh_token_ciphertext, refresh_token_nonce, + granted_scopes, last_error, created_at, updated_at + ) VALUES ( + 42, '00000000-0000-4000-8000-000000000042', 'active', + 'operational-access-ciphertext', 'operational-access-nonce', + 'operational-refresh-ciphertext', 'operational-refresh-nonce', + 'offline read:profile', 'operational-error', + '2026-08-19T12:00:00.000Z', '2026-08-20T12:00:00.000Z' + ); + + INSERT INTO whoop_oauth_states (state_hash, created_at, expires_at) + VALUES ('operational-state-hash', '2026-08-20T11:00:00.000Z', '2026-08-20T11:10:00.000Z'); + + INSERT INTO whoop_webhook_events ( + trace_id, whoop_user_id, connection_id, resource_id, event_type, + received_at, status, last_error + ) VALUES ( + 'operational-trace', 42, '00000000-0000-4000-8000-000000000042', + '00000000-0000-4000-8000-000000000002', 'workout.updated', + '2026-08-20T12:00:00.000Z', 'received', 'operational-webhook-error' + ); + `); + }); + + afterEach(() => database.close()); + + it("exports only explicit WHOOP source projections, including tombstones", async () => { + const response = await worker.fetch( + new Request("https://api.example.test/v1/export", bearerGet()), + env, + ); + + expect(response.status).toBe(200); + const body = await response.json() as Record; + expect(body.whoop).toMatchObject({ + profiles: [{ whoop_user_id: 42, first_name: "Fixture" }], + body_measurements: [{ whoop_user_id: 42, weight_kilogram: 75 }], + cycles: [{ cycle_id: 9, kilojoules: 836.8 }], + recoveries: [{ cycle_id: 9, recovery_score: 82 }], + sleeps: [{ + cycle_id: 9, + stage_in_bed_milliseconds: 28800000, + stage_no_data_milliseconds: 60000, + sleep_need_recent_strain_milliseconds: 600000, + sleep_need_recent_nap_milliseconds: -300000, + sleep_cycle_count: 5, + disturbance_count: 9, + sleep_efficiency_percentage: 91, + }], + workouts: [{ + workout_id: "00000000-0000-4000-8000-000000000002", + deleted_at: "2026-08-20T12:00:00.000Z", + }], + }); + + const serialized = JSON.stringify(body); + for (const forbidden of [ + "raw_json", + "whoop_connections", + "whoop_oauth_states", + "whoop_webhook_events", + "whoop_sync_checkpoints", + "whoop_sync_runs", + "ciphertext", + "nonce", + "signature", + "last_error", + "operational-", + "raw-", + ]) { + expect(serialized).not.toContain(forbidden); + } + }); +}); diff --git a/src/__tests__/whoop/fixtures.ts b/src/__tests__/whoop/fixtures.ts new file mode 100644 index 0000000..937c5aa --- /dev/null +++ b/src/__tests__/whoop/fixtures.ts @@ -0,0 +1,135 @@ +import { vi } from "vitest"; +import type { Env } from "../../types/env"; +import type { WhoopQueueMessage, WhoopWebhookEvent } from "../../types/whoop"; + +export const NOW = "2026-08-19T12:00:00.000Z"; +export const NOW_MS = String(Date.parse(NOW)); +export const NOW_MINUS_SIX_MINUTES_MS = String(Date.parse(NOW) - 6 * 60 * 1000); +export const CONNECTION_ID = "00000000-0000-4000-8000-000000000042"; +export const RECONCILE_RUN_ID = "00000000-0000-4000-8000-000000000099"; +export const PROFILE = { user_id: 42, email: "fixture@whoop.test", first_name: "Fixture", last_name: "User" }; +export const BODY_MEASUREMENT = { height_meter: 1.8, weight_kilogram: 75, max_heart_rate: 190 }; +export const CYCLE = { + id: 9, + user_id: 42, + created_at: NOW, + updated_at: NOW, + start: "2026-08-19T08:00:00.000Z", + end: "2026-08-19T10:00:00.000Z", + timezone_offset: "-04:00", + score_state: "SCORED", +} as const; +export const CURRENT_CYCLE = { ...CYCLE, end: null }; +export const RECOVERY = { + sleep_id: "f7c85ce7-7e44-4bb4-8cb4-ee5b94b54e1c", + cycle_id: 9, + user_id: 42, + created_at: NOW, + updated_at: NOW, + score_state: "SCORED", +} as const; +export const SLEEP = { + id: "f7c85ce7-7e44-4bb4-8cb4-ee5b94b54e1c", + cycle_id: 9, + user_id: 42, + created_at: NOW, + updated_at: NOW, + start: "2026-08-19T04:00:00.000Z", + end: "2026-08-19T11:00:00.000Z", + timezone_offset: "-04:00", + nap: false, + score_state: "SCORED", +} as const; +export const WORKOUT = { + id: "a2f0c3df-cdb4-48f8-a39b-221b5d8b7a34", + user_id: 42, + created_at: NOW, + updated_at: NOW, + start: "2026-08-19T10:30:00.000Z", + end: "2026-08-19T11:30:00.000Z", + timezone_offset: "-04:00", + sport_name: "running", + score_state: "SCORED", +} as const; +export const SLEEP_UPDATED = { user_id: 42, id: SLEEP.id, type: "sleep.updated", trace_id: "7b2dc91e-7423-42b1-a3cb-ecce1a0e2de8" } as const; +export const KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; + +export const bearerGet = () => ({ headers: { Authorization: "Bearer test-api-token" } }); +export const bearerPost = () => ({ method: "POST", headers: { Authorization: "Bearer test-api-token" } }); +export const jsonResponse = (body: unknown, init?: ResponseInit) => new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + ...init, +}); + +type FixtureQueueMessage = WhoopQueueMessage extends infer Message + ? Message extends WhoopQueueMessage + ? Omit & { + connectionId?: string; + reconcileGeneration?: number; + reconcileRunId?: string; + } + : never + : never; + +export const batchOf = (message: FixtureQueueMessage) => ({ + messages: [{ + body: { + connectionId: CONNECTION_ID, + ...(message.kind === "reconcile" + ? { reconcileGeneration: 7, reconcileRunId: RECONCILE_RUN_ID } + : {}), + ...message, + }, + ack: vi.fn(), + retry: vi.fn(), + }], +}) as unknown as MessageBatch; + +const TEST_WHOOP_CLIENT_SECRET = "test-whoop-client-secret"; +const encoder = new TextEncoder(); + +const bytesToBase64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)); + +export const signedWebhook = async ( + payload: WhoopWebhookEvent, + timestamp = NOW_MS, +): Promise => { + const body = JSON.stringify(payload); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(TEST_WHOOP_CLIENT_SECRET), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(timestamp + body)); + + return { + method: "POST", + body, + headers: { + "X-WHOOP-Signature": bytesToBase64(new Uint8Array(signature)), + "X-WHOOP-Signature-Timestamp": timestamp, + "content-type": "application/json", + }, + }; +}; + +export const ENV: Env = { + DB: {} as D1Database, + R2_BUCKET: {} as R2Bucket, + API_TOKEN: "test-api-token", + WHOOP_CLIENT_ID: "test-whoop-client-id", + WHOOP_CLIENT_SECRET: TEST_WHOOP_CLIENT_SECRET, + WHOOP_TOKEN_ENCRYPTION_KEY: KEY, + WHOOP_REDIRECT_URI: "https://api.example.test/integrations/whoop/callback", + OS_BASE_URL: "https://os.example.test", + WHOOP_SYNC_QUEUE: { send: vi.fn(), sendBatch: vi.fn() } as unknown as Queue, + LANYARD_USER_ID: "test-lanyard-user-id", + WAKATIME_API_KEY: "test-wakatime-api-key", + WAKATIME_TIMEZONE: "America/New_York", + GITHUB_USERNAME: "test-github-username", + GITHUB_TOKEN: "test-github-token", + API_VERSION: "test", + R2_PUBLIC_BASE_URL: "https://media.example.test", +}; diff --git a/src/__tests__/whoop/health-route.test.ts b/src/__tests__/whoop/health-route.test.ts new file mode 100644 index 0000000..80bce81 --- /dev/null +++ b/src/__tests__/whoop/health-route.test.ts @@ -0,0 +1,761 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import worker from "../../index"; +import { + getOpenApiDocument, + whoopHealthCollectionQuerySchema, +} from "../../schemas/openapi"; +import { WhoopHealthReadRepository } from "../../services/whoop/read-repository"; +import type { Env } from "../../types/env"; +import { ENV, bearerGet } from "./fixtures"; + +const emptyDatabase = { + prepare: () => ({ + bind() { return this; }, + first: async () => null, + all: async () => ({ results: [], success: true, meta: {} }), + }), +} as unknown as D1Database; + +type SqliteStatement = { + all: (...bindings: unknown[]) => unknown[]; + get: (...bindings: unknown[]) => unknown; + run: (...bindings: unknown[]) => { changes: number | bigint }; +}; + +type SqliteDatabase = { + close: () => void; + exec: (sql: string) => void; + prepare: (sql: string) => SqliteStatement; +}; + +class SqliteD1Statement { + constructor( + private readonly database: SqliteDatabase, + private readonly sql: string, + private readonly bindings: unknown[] = [], + ) {} + + bind(...bindings: unknown[]) { + return new SqliteD1Statement(this.database, this.sql, bindings); + } + + async first(): Promise { + return (this.database.prepare(this.sql).get(...this.bindings) ?? null) as T | null; + } + + async all() { + return { + results: this.database.prepare(this.sql).all(...this.bindings) as T[], + success: true, + meta: {}, + }; + } +} + +const sqliteD1 = (database: SqliteDatabase) => ({ + prepare: (sql: string) => new SqliteD1Statement(database, sql), +}) as unknown as D1Database; + +const ACTIVE_CONNECTION_ID = "00000000-0000-4000-8000-000000000042"; + +const insertConnection = (database: SqliteDatabase) => database.prepare(` + INSERT INTO whoop_connections ( + whoop_user_id, connection_id, status, granted_scopes, last_success_at, + consecutive_failure_count, created_at, updated_at + ) VALUES (42, ?, 'active', 'read:profile read:workout', + '2026-08-20T11:59:00.000Z', 0, + '2026-08-19T12:00:00.000Z', '2026-08-20T12:00:00.000Z') +`).run(ACTIVE_CONNECTION_ID); + +const insertWorkout = ( + database: SqliteDatabase, + overrides: { + id?: string; + startAt?: string; + deletedAt?: string | null; + scoreState?: string; + kilojoules?: number | null; + zoneZeroMilliseconds?: number | null; + } = {}, +) => database.prepare(` + INSERT INTO whoop_workouts ( + workout_id, whoop_user_id, start_at, end_at, timezone_offset, sport_id, sport_name, + score_state, strain, average_heart_rate, max_heart_rate, kilojoules, percent_recorded, + distance_meter, elevation_gain_meter, zone_zero_milliseconds, zone_one_milliseconds, + zone_two_milliseconds, zone_three_milliseconds, zone_four_milliseconds, zone_five_milliseconds, + upstream_created_at, upstream_updated_at, deleted_at, synced_at, raw_json + ) VALUES (?, 42, ?, '2026-08-20T11:30:00.000Z', '-04:00', 1, 'running', + ?, 10.5, 155, 188, ?, 99.5, 5000, 75, ?, 2000, 3000, 4000, 5000, 6000, + '2026-08-20T10:00:00.000Z', '2026-08-20T11:45:00.000Z', ?, + '2026-08-20T11:46:00.000Z', '{"access_token":"must-never-leak"}') +`).run( + overrides.id ?? "a2f0c3df-cdb4-48f8-a39b-221b5d8b7a34", + overrides.startAt ?? "2026-08-20T10:30:00.000Z", + overrides.scoreState ?? "SCORED", + overrides.kilojoules === undefined ? 418.4 : overrides.kilojoules, + overrides.zoneZeroMilliseconds === undefined ? 1499 : overrides.zoneZeroMilliseconds, + overrides.deletedAt ?? null, +); + +const insertCycle = ( + database: SqliteDatabase, + cycleId: number, + startAt: string, + strain = 10, +) => database.prepare(` + INSERT INTO whoop_cycles ( + cycle_id, whoop_user_id, start_at, end_at, timezone_offset, score_state, + strain, upstream_created_at, upstream_updated_at, synced_at, raw_json + ) VALUES (?, 42, ?, NULL, '+00:00', 'SCORED', ?, ?, ?, ?, '{}') +`).run(cycleId, startAt, strain, startAt, startAt, startAt); + +const insertSleep = ( + database: SqliteDatabase, + sleepId: string, + cycleId: number, + startAt: string, +) => database.prepare(` + INSERT INTO whoop_sleeps ( + sleep_id, cycle_id, whoop_user_id, start_at, end_at, timezone_offset, nap, + score_state, upstream_created_at, upstream_updated_at, synced_at, raw_json + ) VALUES (?, ?, 42, ?, ?, '+00:00', 0, 'SCORED', ?, ?, ?, '{}') +`).run(sleepId, cycleId, startAt, startAt, startAt, startAt, startAt); + +const insertCompleteSourceSet = (database: SqliteDatabase) => { + database.prepare(` + INSERT INTO whoop_profiles ( + whoop_user_id, first_name, last_name, email, upstream_created_at, + upstream_updated_at, deleted_at, synced_at, raw_json + ) VALUES (42, 'Fixture', 'User', 'fixture@whoop.test', + '2026-08-01T00:00:00.000Z', '2026-08-20T11:55:00.000Z', NULL, + '2026-08-20T11:56:00.000Z', '{"refresh_token":"must-never-leak"}') + `).run(); + database.prepare(` + INSERT INTO whoop_cycles ( + cycle_id, whoop_user_id, start_at, end_at, timezone_offset, score_state, + strain, kilojoules, average_heart_rate, max_heart_rate, upstream_created_at, + upstream_updated_at, deleted_at, synced_at, raw_json + ) VALUES (9, 42, '2026-08-20T04:00:00.000Z', NULL, '-04:00', 'SCORED', + 12.3, 836.8, 75, 190, '2026-08-20T04:00:00.000Z', + '2026-08-20T11:50:00.000Z', NULL, '2026-08-20T11:51:00.000Z', '{}') + `).run(); + database.prepare(` + INSERT INTO whoop_recoveries ( + sleep_id, cycle_id, whoop_user_id, score_state, user_calibrating, + recovery_score, resting_heart_rate, hrv_rmssd_milliseconds, spo2_percentage, + skin_temperature_celsius, upstream_created_at, upstream_updated_at, + deleted_at, synced_at, raw_json + ) VALUES ('f7c85ce7-7e44-4bb4-8cb4-ee5b94b54e1c', 9, 42, 'PENDING_SCORE', 1, + 0, NULL, NULL, NULL, NULL, '2026-08-20T10:00:00.000Z', + '2026-08-20T11:40:00.000Z', NULL, '2026-08-20T11:41:00.000Z', '{}') + `).run(); + database.prepare(` + INSERT INTO whoop_sleeps ( + sleep_id, cycle_id, whoop_user_id, start_at, end_at, timezone_offset, nap, + score_state, stage_awake_milliseconds, stage_light_milliseconds, + stage_slow_wave_milliseconds, stage_rem_milliseconds, stage_in_bed_milliseconds, + stage_no_data_milliseconds, sleep_needed_milliseconds, sleep_debt_milliseconds, + sleep_need_recent_strain_milliseconds, sleep_need_recent_nap_milliseconds, + sleep_cycle_count, disturbance_count, sleep_efficiency_percentage, + sleep_consistency_percentage, sleep_performance_percentage, respiratory_rate, + upstream_created_at, upstream_updated_at, deleted_at, synced_at, raw_json + ) VALUES ('f7c85ce7-7e44-4bb4-8cb4-ee5b94b54e1c', 9, 42, + '2026-08-20T03:00:00.000Z', '2026-08-20T10:00:00.000Z', '-04:00', 0, + 'SCORED', 1499, 7200000, 3600000, 5400000, 28800000, 60000, + 28800000, 1800000, 600000, -300000, 5, 9, + 91.2, 87.5, 84, 14.5, '2026-08-20T03:00:00.000Z', + '2026-08-20T11:35:00.000Z', NULL, '2026-08-20T11:36:00.000Z', '{}') + `).run(); +}; + +describe("WHOOP health read routes", () => { + it("mounts the overview behind bearer authentication", async () => { + const env = { ...ENV, DB: emptyDatabase } as Env; + + const unauthorized = await worker.fetch( + new Request("https://api.example.test/v1/health/whoop/overview"), + env, + ); + const authorized = await worker.fetch( + new Request("https://api.example.test/v1/health/whoop/overview", bearerGet()), + env, + ); + + expect(unauthorized.status).toBe(401); + expect(authorized.status).toBe(200); + expect(await authorized.json()).toMatchObject({ + current_cycle: null, + current_recovery: null, + current_sleep: null, + recent_workouts: [], + trends_7_days: [], + trends_30_days: [], + synchronization: { status: "not_connected", progress: [], runs: [] }, + }); + }); + + it("publishes exact protected OpenAPI contracts without replacing Apple routes", () => { + const document = getOpenApiDocument("test"); + const serialized = JSON.stringify(document); + const collectionPaths = ["cycles", "recoveries", "sleeps", "workouts"]; + + for (const resource of collectionPaths) { + const operation = document.paths?.[`/v1/health/whoop/${resource}`]?.get; + expect(operation?.security).toEqual([{ bearerAuth: [] }]); + expect(operation?.parameters?.map((parameter) => "name" in parameter ? parameter.name : null)) + .toEqual(["start", "end", "limit", "cursor"]); + expect(operation?.responses).toHaveProperty("200"); + expect(operation?.responses).toHaveProperty("400"); + expect(operation?.responses).toHaveProperty("401"); + } + for (const resource of ["overview", "profile"] as const) { + expect(document.paths?.[`/v1/health/whoop/${resource}`]?.get?.security) + .toEqual([{ bearerAuth: [] }]); + } + const detail = document.paths?.["/v1/health/whoop/workouts/{workoutId}"]?.get; + expect(detail?.security).toEqual([{ bearerAuth: [] }]); + expect(detail?.responses).toHaveProperty("404"); + expect(JSON.stringify(detail)).toContain("uuid"); + expect(document.paths).toHaveProperty("/v1/health"); + expect(document.paths).toHaveProperty("/v1/health/workouts/{id}"); + expect(serialized).toContain("energy_kcal_estimate"); + expect(serialized).toContain("hrv_rmssd_milliseconds"); + expect(serialized).toContain("stage_durations_seconds"); + expect(serialized).toContain("zone_durations_seconds"); + expect(serialized).not.toMatch(/raw_json|access_token_ciphertext|refresh_token_ciphertext|refresh_lease|webhook_signature/i); + }); + + describe("with WHOOP source rows", () => { + let database: SqliteDatabase; + let env: Env; + + beforeEach(async () => { + // @ts-expect-error The Worker typecheck intentionally excludes Node test-runtime declarations. + const { DatabaseSync } = await import("node:sqlite"); + // @ts-expect-error The Worker typecheck intentionally excludes Node test-runtime declarations. + const { readFile } = await import("node:fs/promises"); + database = new DatabaseSync(":memory:") as SqliteDatabase; + database.exec(await readFile("migrations/0020_whoop.sql", "utf8")); + insertConnection(database); + env = { ...ENV, DB: sqliteD1(database) } as Env; + }); + + afterEach(() => database.close()); + + it("returns typed workout units without exposing stored source payloads", async () => { + insertWorkout(database); + + const response = await worker.fetch( + new Request("https://api.example.test/v1/health/whoop/workouts?limit=25", bearerGet()), + env, + ); + + expect(response.status).toBe(200); + const body = await response.json() as { records: Record[] }; + expect(body.records).toEqual([{ + workout_id: "a2f0c3df-cdb4-48f8-a39b-221b5d8b7a34", + start_at: "2026-08-20T10:30:00.000Z", + end_at: "2026-08-20T11:30:00.000Z", + timezone_offset: "-04:00", + sport_id: 1, + sport_name: "running", + score_state: "scored", + strain: 10.5, + average_heart_rate: 155, + max_heart_rate: 188, + kilojoules: 418.4, + energy_kcal_estimate: 100, + percent_recorded: 99.5, + distance_meter: 5000, + elevation_gain_meter: 75, + zone_durations_seconds: { + zone_zero_seconds: 1, + zone_one_seconds: 2, + zone_two_seconds: 3, + zone_three_seconds: 4, + zone_four_seconds: 5, + zone_five_seconds: 6, + }, + created_at: "2026-08-20T10:00:00.000Z", + updated_at: "2026-08-20T11:45:00.000Z", + synced_at: "2026-08-20T11:46:00.000Z", + }]); + expect(body).toHaveProperty("next_cursor", null); + expect(JSON.stringify(body)).not.toMatch(/raw_json|access_token|ciphertext|nonce/i); + }); + + it("does not turn pending workout score fields into zero-valued health data", async () => { + insertWorkout(database, { + scoreState: "PENDING_SCORE", + kilojoules: 0, + zoneZeroMilliseconds: 0, + }); + + const response = await worker.fetch(new Request( + "https://api.example.test/v1/health/whoop/workouts", + bearerGet(), + ), env); + expect(response.status).toBe(200); + const body = await response.json() as { records: Record[] }; + + expect(body.records[0]).toMatchObject({ + score_state: "pending", + strain: null, + average_heart_rate: null, + max_heart_rate: null, + kilojoules: null, + energy_kcal_estimate: null, + percent_recorded: null, + distance_meter: null, + elevation_gain_meter: null, + zone_durations_seconds: { + zone_zero_seconds: null, + zone_one_seconds: null, + zone_two_seconds: null, + zone_three_seconds: null, + zone_four_seconds: null, + zone_five_seconds: null, + }, + }); + }); + + it("uses stable tombstone-free keyset pagination when newer rows arrive", async () => { + insertWorkout(database, { + id: "00000000-0000-4000-8000-000000000003", + startAt: "2026-08-20T10:00:00.000Z", + }); + insertWorkout(database, { + id: "00000000-0000-4000-8000-000000000002", + startAt: "2026-08-20T10:00:00.000Z", + }); + insertWorkout(database, { + id: "00000000-0000-4000-8000-000000000001", + startAt: "2026-08-20T10:00:00.000Z", + }); + insertWorkout(database, { + id: "00000000-0000-4000-8000-000000000099", + startAt: "2026-08-20T12:00:00.000Z", + deletedAt: "2026-08-20T12:01:00.000Z", + }); + + const firstResponse = await worker.fetch(new Request( + "https://api.example.test/v1/health/whoop/workouts?limit=1", + bearerGet(), + ), env); + expect(firstResponse.status).toBe(200); + const first = await firstResponse.json() as { + records: Array<{ workout_id: string }>; + next_cursor: string | null; + }; + expect(first.records.map((record) => record.workout_id)).toEqual([ + "00000000-0000-4000-8000-000000000003", + ]); + expect(first.next_cursor).toEqual(expect.any(String)); + + insertWorkout(database, { + id: "00000000-0000-4000-8000-000000000004", + startAt: "2026-08-20T11:00:00.000Z", + }); + const secondResponse = await worker.fetch(new Request( + `https://api.example.test/v1/health/whoop/workouts?limit=1&cursor=${first.next_cursor}`, + bearerGet(), + ), env); + expect(secondResponse.status).toBe(200); + const second = await secondResponse.json() as { + records: Array<{ workout_id: string }>; + next_cursor: string | null; + }; + expect(second.records.map((record) => record.workout_id)).toEqual([ + "00000000-0000-4000-8000-000000000002", + ]); + expect(second.next_cursor).toEqual(expect.any(String)); + }); + + it("orders, windows, and paginates offset timestamps by their instant", async () => { + insertWorkout(database, { + id: "00000000-0000-4000-8000-000000000003", + startAt: "2026-08-20T10:00:00.000-04:00", + }); + insertWorkout(database, { + id: "00000000-0000-4000-8000-000000000002", + startAt: "2026-08-20T13:00:00.000Z", + }); + insertWorkout(database, { + id: "00000000-0000-4000-8000-000000000001", + startAt: "2026-08-20T12:00:00.000Z", + }); + + const firstResponse = await worker.fetch(new Request( + "https://api.example.test/v1/health/whoop/workouts?limit=1", + bearerGet(), + ), env); + expect(firstResponse.status).toBe(200); + const first = await firstResponse.json() as { + records: Array<{ workout_id: string }>; + next_cursor: string; + }; + expect(first.records.map((record) => record.workout_id)).toEqual([ + "00000000-0000-4000-8000-000000000003", + ]); + expect(first.next_cursor).toEqual(expect.any(String)); + + const secondResponse = await worker.fetch(new Request( + `https://api.example.test/v1/health/whoop/workouts?limit=1&cursor=${first.next_cursor}`, + bearerGet(), + ), env); + expect(secondResponse.status).toBe(200); + const second = await secondResponse.json() as { records: Array<{ workout_id: string }> }; + expect(second.records.map((record) => record.workout_id)).toEqual([ + "00000000-0000-4000-8000-000000000002", + ]); + + const windowResponse = await worker.fetch(new Request( + "https://api.example.test/v1/health/whoop/workouts?start=2026-08-20T14:00:00.000Z", + bearerGet(), + ), env); + expect(windowResponse.status).toBe(200); + const window = await windowResponse.json() as { records: Array<{ workout_id: string }> }; + expect(window.records.map((record) => record.workout_id)).toEqual([ + "00000000-0000-4000-8000-000000000003", + ]); + }); + + it("validates date windows, limits, and bounded opaque cursors", async () => { + for (const query of [ + "limit=0", + "limit=101", + "limit=1.5", + "start=not-a-date", + "end=2026-08-20", + "start=2026-08-21T00:00:00.000Z&end=2026-08-20T00:00:00.000Z", + `cursor=${"a".repeat(1025)}`, + "cursor=not-base64!", + ]) { + const response = await worker.fetch(new Request( + `https://api.example.test/v1/health/whoop/workouts?${query}`, + bearerGet(), + ), env); + expect(response.status, query).toBe(400); + } + }); + + it("uses the OpenAPI timestamp contract at runtime and rejects invalid calendar dates", async () => { + for (const timestamp of [ + "2026-08-20T12:00:00", + "2026-02-30T12:00:00.000Z", + ]) { + expect(whoopHealthCollectionQuerySchema.safeParse({ start: timestamp }).success, timestamp) + .toBe(false); + const response = await worker.fetch(new Request( + `https://api.example.test/v1/health/whoop/workouts?start=${timestamp}`, + bearerGet(), + ), env); + expect(response.status, timestamp).toBe(400); + } + }); + + it("fails closed for missing or short cursor keys and rejects cursors after rotation", async () => { + insertWorkout(database, { id: "00000000-0000-4000-8000-000000000002" }); + insertWorkout(database, { id: "00000000-0000-4000-8000-000000000001" }); + + const missingKeyResponse = await worker.fetch(new Request( + "https://api.example.test/v1/health/whoop/workouts?limit=1", + bearerGet(), + ), { ...env, WHOOP_TOKEN_ENCRYPTION_KEY: undefined } as unknown as Env); + const shortKeyResponse = await worker.fetch(new Request( + "https://api.example.test/v1/health/whoop/workouts?limit=1", + bearerGet(), + ), { ...env, WHOOP_TOKEN_ENCRYPTION_KEY: "c2hvcnQ=" }); + const validKeyResponse = await worker.fetch(new Request( + "https://api.example.test/v1/health/whoop/workouts?limit=1", + bearerGet(), + ), env); + + expect(missingKeyResponse.status).toBe(500); + expect(shortKeyResponse.status).toBe(500); + expect(validKeyResponse.status).toBe(200); + const { next_cursor: cursor } = await validKeyResponse.json() as { next_cursor: string }; + expect(cursor).toEqual(expect.any(String)); + + const rotatedKeyResponse = await worker.fetch(new Request( + `https://api.example.test/v1/health/whoop/workouts?limit=1&cursor=${cursor}`, + bearerGet(), + ), { + ...env, + WHOOP_TOKEN_ENCRYPTION_KEY: "ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=", + }); + expect(rotatedKeyResponse.status).toBe(400); + }); + + it("rejects tampered cursors and cursors reused with another window", async () => { + insertWorkout(database, { id: "00000000-0000-4000-8000-000000000002" }); + insertWorkout(database, { id: "00000000-0000-4000-8000-000000000001" }); + const firstResponse = await worker.fetch(new Request( + "https://api.example.test/v1/health/whoop/workouts?limit=1", + bearerGet(), + ), env); + expect(firstResponse.status).toBe(200); + const { next_cursor: cursor } = await firstResponse.json() as { next_cursor: string }; + expect(cursor).toEqual(expect.any(String)); + const middle = Math.floor(cursor.length / 2); + const replacement = cursor[middle] === "a" ? "b" : "a"; + const tampered = `${cursor.slice(0, middle)}${replacement}${cursor.slice(middle + 1)}`; + + const tamperedResponse = await worker.fetch(new Request( + `https://api.example.test/v1/health/whoop/workouts?limit=1&cursor=${tampered}`, + bearerGet(), + ), env); + const crossedWindowResponse = await worker.fetch(new Request( + `https://api.example.test/v1/health/whoop/workouts?limit=1&start=2026-08-01T00:00:00.000Z&cursor=${cursor}`, + bearerGet(), + ), env); + + expect(tamperedResponse.status).toBe(400); + expect(crossedWindowResponse.status).toBe(400); + }); + + it("returns exact typed cycles, recoveries, sleeps, and profile fields", async () => { + insertCompleteSourceSet(database); + + const [cyclesResponse, recoveriesResponse, sleepsResponse, profileResponse] = await Promise.all([ + worker.fetch(new Request("https://api.example.test/v1/health/whoop/cycles", bearerGet()), env), + worker.fetch(new Request("https://api.example.test/v1/health/whoop/recoveries", bearerGet()), env), + worker.fetch(new Request("https://api.example.test/v1/health/whoop/sleeps", bearerGet()), env), + worker.fetch(new Request("https://api.example.test/v1/health/whoop/profile", bearerGet()), env), + ]); + expect([ + cyclesResponse.status, + recoveriesResponse.status, + sleepsResponse.status, + profileResponse.status, + ]).toEqual([200, 200, 200, 200]); + const cycles = await cyclesResponse.json() as { records: unknown[] }; + const recoveries = await recoveriesResponse.json() as { records: unknown[] }; + const sleeps = await sleepsResponse.json() as { records: unknown[] }; + + expect(cycles.records).toEqual([{ + cycle_id: 9, + start_at: "2026-08-20T04:00:00.000Z", + end_at: null, + timezone_offset: "-04:00", + score_state: "scored", + strain: 12.3, + kilojoules: 836.8, + energy_kcal_estimate: 200, + average_heart_rate: 75, + max_heart_rate: 190, + created_at: "2026-08-20T04:00:00.000Z", + updated_at: "2026-08-20T11:50:00.000Z", + synced_at: "2026-08-20T11:51:00.000Z", + }]); + expect(recoveries.records).toEqual([{ + sleep_id: "f7c85ce7-7e44-4bb4-8cb4-ee5b94b54e1c", + cycle_id: 9, + score_state: "pending", + user_calibrating: true, + score: null, + resting_heart_rate: null, + hrv_rmssd_milliseconds: null, + spo2_percentage: null, + skin_temperature_celsius: null, + created_at: "2026-08-20T10:00:00.000Z", + updated_at: "2026-08-20T11:40:00.000Z", + synced_at: "2026-08-20T11:41:00.000Z", + }]); + expect(sleeps.records).toEqual([{ + sleep_id: "f7c85ce7-7e44-4bb4-8cb4-ee5b94b54e1c", + cycle_id: 9, + start_at: "2026-08-20T03:00:00.000Z", + end_at: "2026-08-20T10:00:00.000Z", + timezone_offset: "-04:00", + nap: false, + score_state: "scored", + stage_durations_seconds: { + in_bed_seconds: 28800, + awake_seconds: 1, + no_data_seconds: 60, + light_seconds: 7200, + slow_wave_seconds: 3600, + rem_seconds: 5400, + }, + sleep_need_seconds: { + baseline_seconds: 28800, + debt_seconds: 1800, + recent_strain_seconds: 600, + recent_nap_seconds: -300, + }, + sleep_cycle_count: 5, + disturbance_count: 9, + sleep_efficiency_percentage: 91.2, + sleep_consistency_percentage: 87.5, + sleep_performance_percentage: 84, + respiratory_rate: 14.5, + created_at: "2026-08-20T03:00:00.000Z", + updated_at: "2026-08-20T11:35:00.000Z", + synced_at: "2026-08-20T11:36:00.000Z", + }]); + expect(await profileResponse.json()).toEqual({ + whoop_user_id: 42, + first_name: "Fixture", + last_name: "User", + email: "fixture@whoop.test", + created_at: "2026-08-01T00:00:00.000Z", + updated_at: "2026-08-20T11:55:00.000Z", + synced_at: "2026-08-20T11:56:00.000Z", + }); + }); + + it("validates workout UUIDs and returns 404 for missing or tombstoned detail", async () => { + insertWorkout(database, { + id: "00000000-0000-4000-8000-000000000001", + deletedAt: "2026-08-20T12:00:00.000Z", + }); + const malformed = await worker.fetch(new Request( + "https://api.example.test/v1/health/whoop/workouts/not-a-uuid", + bearerGet(), + ), env); + const missing = await worker.fetch(new Request( + "https://api.example.test/v1/health/whoop/workouts/00000000-0000-4000-8000-000000000002", + bearerGet(), + ), env); + const tombstoned = await worker.fetch(new Request( + "https://api.example.test/v1/health/whoop/workouts/00000000-0000-4000-8000-000000000001", + bearerGet(), + ), env); + + expect(malformed.status).toBe(400); + expect(missing.status).toBe(404); + expect(tombstoned.status).toBe(404); + }); + + it("rejects a valid workout cursor on a different collection", async () => { + insertWorkout(database, { id: "00000000-0000-4000-8000-000000000002" }); + insertWorkout(database, { id: "00000000-0000-4000-8000-000000000001" }); + const firstResponse = await worker.fetch(new Request( + "https://api.example.test/v1/health/whoop/workouts?limit=1", + bearerGet(), + ), env); + const { next_cursor: cursor } = await firstResponse.json() as { next_cursor: string }; + + const response = await worker.fetch(new Request( + `https://api.example.test/v1/health/whoop/sleeps?limit=1&cursor=${cursor}`, + bearerGet(), + ), env); + + expect(response.status).toBe(400); + }); + + it("builds current state, bounded trends, recent workouts, and sanitized sync health", async () => { + insertCompleteSourceSet(database); + insertWorkout(database); + database.prepare(` + UPDATE whoop_connections + SET last_error_at = '2026-08-20T11:58:00.000Z', + last_error = 'access_token=must-never-leak' + WHERE whoop_user_id = 42 + `).run(); + database.prepare(` + INSERT INTO whoop_sync_checkpoints ( + whoop_user_id, connection_id, resource, mode, reconcile_generation, + sync_run_id, target_id, status, page_count, record_count, + created_at, updated_at, last_error + ) VALUES (42, ?, 'workout', 'reconcile', 0, + '00000000-0000-4000-8000-000000000099', '', 'error', 2, 7, + '2026-08-20T11:00:00.000Z', '2026-08-20T11:57:00.000Z', + 'refresh_token=must-never-leak') + `).run(ACTIVE_CONNECTION_ID); + + const response = await worker.fetch(new Request( + "https://api.example.test/v1/health/whoop/overview", + bearerGet(), + ), env); + expect(response.status).toBe(200); + const body = await response.json() as Record; + + expect(body).toMatchObject({ + current_cycle: { cycle_id: 9, score_state: "scored", strain: 12.3 }, + current_recovery: { + sleep_id: "f7c85ce7-7e44-4bb4-8cb4-ee5b94b54e1c", + score_state: "pending", + user_calibrating: true, + score: null, + }, + current_sleep: { + sleep_id: "f7c85ce7-7e44-4bb4-8cb4-ee5b94b54e1c", + sleep_performance_percentage: 84, + }, + recent_workouts: [{ + workout_id: "a2f0c3df-cdb4-48f8-a39b-221b5d8b7a34", + }], + trends_7_days: [{ + date: "2026-08-20", + recovery_score: null, + strain: 12.3, + sleep_performance_percentage: 84, + }], + trends_30_days: [{ + date: "2026-08-20", + recovery_score: null, + strain: 12.3, + sleep_performance_percentage: 84, + }], + synchronization: { + status: "active", + last_success_at: "2026-08-20T11:59:00.000Z", + last_error_at: "2026-08-20T11:58:00.000Z", + consecutive_failure_count: 0, + updated_at: "2026-08-20T12:00:00.000Z", + progress: [{ + resource: "workout", + mode: "reconcile", + status: "error", + page_count: 2, + record_count: 7, + updated_at: "2026-08-20T11:57:00.000Z", + }], + runs: [], + }, + }); + expect(JSON.stringify(body)).not.toMatch(/raw_json|access_token|refresh_token|ciphertext|nonce|lease|generation|connection_id/i); + }); + + it("does not pair the current cycle with an unrelated sleep when recovery is absent", async () => { + insertCycle(database, 9, "2026-08-20T04:00:00.000Z"); + insertSleep( + database, + "00000000-0000-4000-8000-000000000008", + 8, + "2026-08-20T03:00:00.000Z", + ); + + const repository = new WhoopHealthReadRepository(sqliteD1(database)); + const overview = await repository.getOverview(42, new Date("2026-08-20T12:00:00.000Z")); + + expect(overview.current_cycle?.cycle_id).toBe(9); + expect(overview.current_recovery).toBeNull(); + expect(overview.current_sleep).toBeNull(); + }); + + it("bounds trend dates to the current UTC calendar date and its prior 29 or 6 dates", async () => { + insertCycle(database, 1, "2026-07-21T18:00:00.000Z"); + insertCycle(database, 2, "2026-07-22T00:00:00.000Z"); + insertCycle(database, 3, "2026-08-14T00:30:00.000+01:00"); + insertCycle(database, 4, "2026-08-14T00:00:00.000Z"); + insertCycle(database, 5, "2026-08-20T08:00:00.000Z"); + + const repository = new WhoopHealthReadRepository(sqliteD1(database)); + const overview = await repository.getOverview(42, new Date("2026-08-20T18:00:00.000Z")); + + expect(overview.trends_30_days.map((point) => point.date)).toEqual([ + "2026-07-22", + "2026-08-13", + "2026-08-14", + "2026-08-20", + ]); + expect(overview.trends_7_days.map((point) => point.date)).toEqual([ + "2026-08-14", + "2026-08-20", + ]); + }); + }); +}); diff --git a/src/__tests__/whoop/integration-route.test.ts b/src/__tests__/whoop/integration-route.test.ts new file mode 100644 index 0000000..ba5b125 --- /dev/null +++ b/src/__tests__/whoop/integration-route.test.ts @@ -0,0 +1,376 @@ +import { Hono } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import worker from "../../index"; +import { requireAuth } from "../../middleware/auth"; +import { + createWhoopIntegrationRoute, + type WhoopIntegrationDependencies, +} from "../../routes/whoop-integration"; +import type { Env } from "../../types/env"; +import type { WhoopQueueMessage } from "../../types/whoop"; +import { getOpenApiDocument } from "../../schemas/openapi"; +import { ENV, PROFILE, bearerGet, bearerPost } from "./fixtures"; + +const FIXED_CONNECTED_REDIRECT = "https://os.example.test/health/source?result=connected"; +const FIXED_FAILED_REDIRECT = "https://os.example.test/health/source?result=failed"; +const CONNECTION_ID = "00000000-0000-4000-8000-000000000042"; +const RESOURCES = ["profile", "body_measurement", "cycle", "recovery", "sleep", "workout"] as const; + +type Connection = { + whoopUserId: number; + connectionId?: string; + status: "not_connected" | "backfilling" | "active" | "disconnected"; + credentialVersion: number; + reconcileGeneration?: number; + granted_scopes?: string[]; +}; + +function createDependencies(connection: Connection | null = null) { + const currentConnection = connection ? { + ...connection, + connectionId: connection.connectionId ?? CONNECTION_ID, + reconcileGeneration: connection.reconcileGeneration ?? 0, + } : null; + const repository = { + createOAuthState: vi.fn().mockResolvedValue(undefined), + consumeOAuthState: vi.fn().mockResolvedValue(true), + getCurrentConnection: vi.fn().mockResolvedValue(currentConnection), + getSyncProgress: vi.fn().mockResolvedValue([]), + getRecentSyncRuns: vi.fn().mockResolvedValue([]), + beginReconciliation: vi.fn().mockResolvedValue(4), + getPendingRecoveryCycleIds: vi.fn().mockResolvedValue([]), + createSyncRun: vi.fn().mockResolvedValue(true), + markSyncRunPublicationFailure: vi.fn().mockResolvedValue(true), + recordSyncFailure: vi.fn().mockResolvedValue(true), + claimAndUpsertConnection: vi.fn().mockResolvedValue(1), + markInitialBackfillQueued: vi.fn().mockResolvedValue(true), + disconnect: vi.fn().mockResolvedValue(true), + deleteLocalData: vi.fn().mockResolvedValue(true), + withWhoopAccessToken: vi.fn(async (_userId, request) => request("fixture-access-token", connection?.credentialVersion ?? 1)), + }; + const client = { + exchangeAuthorizationCode: vi.fn().mockResolvedValue({ + access_token: "fixture-access-token", + refresh_token: "fixture-refresh-token", + expires_in: 3600, + token_type: "bearer", + scope: "offline read:profile read:body_measurement read:cycles read:recovery read:sleep read:workout", + }), + getProfile: vi.fn().mockResolvedValue(PROFILE), + revokeAccess: vi.fn().mockResolvedValue(undefined), + }; + const clientFactory = vi.fn().mockReturnValue(client); + const dependencies = { + repository, + clientFactory, + now: () => new Date("2026-08-19T12:00:00.000Z"), + } as unknown as WhoopIntegrationDependencies; + return { dependencies, repository, client, clientFactory }; +} + +function createApp(dependencies: WhoopIntegrationDependencies) { + const app = new Hono<{ Bindings: Env }>(); + app.use("/v1/*", requireAuth); + app.route("/", createWhoopIntegrationRoute(dependencies)); + return app; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("WHOOP integration management routes", () => { + it("mounts the management route behind the worker bearer middleware", async () => { + const response = await worker.fetch(new Request("https://api.example.test/v1/integrations/whoop/connect", { + method: "POST", + }), ENV); + + expect(response.status).toBe(401); + }); + + it("requires bearer auth and returns only a fixed-redirect authorization URL", async () => { + const { dependencies, repository } = createDependencies(); + const app = createApp(dependencies); + + const unauthorized = await app.request("/v1/integrations/whoop/connect", { method: "POST" }, ENV); + const authorized = await app.request("/v1/integrations/whoop/connect", bearerPost(), ENV); + const body = await authorized.json() as Record; + + expect(unauthorized.status).toBe(401); + expect(authorized.status).toBe(200); + expect(Object.keys(body)).toEqual(["authorization_url"]); + expect(body.authorization_url).toContain(encodeURIComponent(ENV.WHOOP_REDIRECT_URI)); + expect(new URL(String(body.authorization_url)).searchParams.get("scope")) + .toBe("offline read:profile read:body_measurement read:cycles read:recovery read:sleep read:workout"); + expect(body.authorization_url).not.toContain("returnTo"); + expect(repository.createOAuthState).toHaveBeenCalledTimes(1); + }); + + it("validates all required bindings before persisting OAuth state", async () => { + const { dependencies, repository } = createDependencies(); + const app = createApp(dependencies); + const env = { ...ENV, OS_BASE_URL: "" } as Env; + + const response = await app.request("/v1/integrations/whoop/connect", bearerPost(), env); + + expect(response.status).toBe(503); + expect(repository.createOAuthState).not.toHaveBeenCalled(); + }); + + it("consumes callback state before code exchange and redirects failures without query values", async () => { + const { dependencies, repository, client } = createDependencies(); + repository.consumeOAuthState.mockResolvedValue(false); + const app = createApp(dependencies); + + const response = await app.request("/integrations/whoop/callback?code=redacted-code&state=used-state", {}, ENV); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(FIXED_FAILED_REDIRECT); + expect(client.exchangeAuthorizationCode).not.toHaveBeenCalled(); + expect(response.headers.get("location")).not.toContain("redacted-code"); + expect(response.headers.get("location")).not.toContain("used-state"); + }); + + it("encrypts callback tokens, starts a six-resource backfill, and redirects only to the OS result", async () => { + const { dependencies, repository } = createDependencies(); + const app = createApp(dependencies); + + const response = await app.request("/integrations/whoop/callback?code=redacted-code&state=fresh-state", {}, ENV); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(FIXED_CONNECTED_REDIRECT); + expect(repository.claimAndUpsertConnection).toHaveBeenCalledWith(expect.objectContaining({ + whoopUserId: PROFILE.user_id, + connectionId: expect.any(String), + status: "backfilling", + initialBackfillPending: true, + accessToken: expect.objectContaining({ ciphertext: expect.any(String), nonce: expect.any(String) }), + refreshToken: expect.objectContaining({ ciphertext: expect.any(String), nonce: expect.any(String) }), + })); + expect(ENV.WHOOP_SYNC_QUEUE.send).not.toHaveBeenCalled(); + expect(ENV.WHOOP_SYNC_QUEUE.sendBatch).toHaveBeenCalledWith(RESOURCES.map((resource) => ({ + body: { + kind: "backfill", + whoopUserId: PROFILE.user_id, + connectionId: expect.any(String), + resource, + }, + }))); + expect(repository.markInitialBackfillQueued).toHaveBeenCalledWith( + PROFILE.user_id, + expect.any(String), + 1, + expect.any(String), + ); + }); + + it("does not replace an existing active WHOOP identity", async () => { + const { dependencies, repository, client } = createDependencies({ whoopUserId: 7, status: "active", credentialVersion: 1 }); + repository.claimAndUpsertConnection.mockResolvedValue(null); + const app = createApp(dependencies); + + const response = await app.request("/integrations/whoop/callback?code=redacted-code&state=fresh-state", {}, ENV); + + expect(response.headers.get("location")).toBe(FIXED_FAILED_REDIRECT); + expect(repository.claimAndUpsertConnection).toHaveBeenCalledTimes(1); + expect(client.revokeAccess).toHaveBeenCalledWith("fixture-access-token"); + expect(ENV.WHOOP_SYNC_QUEUE.sendBatch).not.toHaveBeenCalled(); + }); + + it("best-effort revokes an exchanged token when pre-claim persistence fails", async () => { + const { dependencies, repository, client } = createDependencies(); + const app = createApp(dependencies); + const env = { ...ENV, WHOOP_TOKEN_ENCRYPTION_KEY: "invalid" } as Env; + + const response = await app.request("/integrations/whoop/callback?code=redacted-code&state=fresh-state", {}, env); + + expect(response.headers.get("location")).toBe(FIXED_FAILED_REDIRECT); + expect(repository.claimAndUpsertConnection).not.toHaveBeenCalled(); + expect(client.revokeAccess).toHaveBeenCalledWith("fixture-access-token"); + }); + + it("keeps durable initial-backfill intent when the atomic queue batch is ambiguous", async () => { + const { dependencies, repository, client } = createDependencies(); + const app = createApp(dependencies); + (ENV.WHOOP_SYNC_QUEUE.sendBatch as unknown as ReturnType) + .mockRejectedValueOnce(new Error("queue publication unknown")); + + const response = await app.request("/integrations/whoop/callback?code=redacted-code&state=fresh-state", {}, ENV); + + expect(response.headers.get("location")).toBe(FIXED_FAILED_REDIRECT); + expect(repository.claimAndUpsertConnection).toHaveBeenCalledWith(expect.objectContaining({ initialBackfillPending: true })); + expect(repository.markInitialBackfillQueued).not.toHaveBeenCalled(); + expect(ENV.WHOOP_SYNC_QUEUE.send).not.toHaveBeenCalled(); + expect(ENV.WHOOP_SYNC_QUEUE.sendBatch).toHaveBeenCalledTimes(1); + expect(client.revokeAccess).not.toHaveBeenCalled(); + }); + + it("consumes a valid state before failing a provider denial without code exchange", async () => { + const { dependencies, repository, client } = createDependencies(); + const app = createApp(dependencies); + + const response = await app.request("/integrations/whoop/callback?state=fresh-state&error=access_denied", {}, ENV); + + expect(response.headers.get("location")).toBe(FIXED_FAILED_REDIRECT); + expect(repository.consumeOAuthState).toHaveBeenCalledWith(expect.any(String), expect.any(String)); + expect(client.exchangeAuthorizationCode).not.toHaveBeenCalled(); + }); + + it("uses the canonical generation-fenced reconciliation publisher for manual sync", async () => { + const { dependencies, repository, client } = createDependencies({ whoopUserId: PROFILE.user_id, status: "active", credentialVersion: 1 }); + const app = createApp(dependencies); + + const response = await app.request("/v1/integrations/whoop/sync", bearerPost(), ENV); + + expect(response.status).toBe(202); + expect(repository.beginReconciliation).toHaveBeenCalledWith( + PROFILE.user_id, + CONNECTION_ID, + "2026-08-19T12:00:00.000Z", + ); + expect(repository.getPendingRecoveryCycleIds).toHaveBeenCalledWith(PROFILE.user_id, 25); + expect(ENV.WHOOP_SYNC_QUEUE.send).not.toHaveBeenCalled(); + expect(ENV.WHOOP_SYNC_QUEUE.sendBatch).toHaveBeenCalledTimes(1); + const messages = (ENV.WHOOP_SYNC_QUEUE.sendBatch as unknown as ReturnType) + .mock.calls[0][0].map(({ body }: { body: WhoopQueueMessage }) => body) as WhoopQueueMessage[]; + const reconcileRunIds = messages.map((message) => { + if (message.kind !== "reconcile") throw new Error("Expected reconciliation message"); + return message.reconcileRunId; + }); + expect(new Set(reconcileRunIds).size).toBe(1); + expect(messages) + .toEqual(RESOURCES.map((resource) => ({ + kind: "reconcile", + whoopUserId: PROFILE.user_id, + connectionId: CONNECTION_ID, + reconcileGeneration: 4, + reconcileRunId: reconcileRunIds[0], + resource, + ...(["cycle", "recovery", "sleep", "workout"].includes(resource) + ? { + windowStart: "2026-08-05T12:00:00.000Z", + windowEnd: "2026-08-19T12:00:00.000Z", + } + : {}), + trigger: "manual", + }))); + expect(client.exchangeAuthorizationCode).not.toHaveBeenCalled(); + }); + + it("revokes before clearing token fields and keeps imported source history", async () => { + const { dependencies, repository, client } = createDependencies({ whoopUserId: PROFILE.user_id, status: "active", credentialVersion: 1 }); + const app = createApp(dependencies); + + const response = await app.request("/v1/integrations/whoop", { method: "DELETE", ...bearerGet() }, ENV); + + expect(response.status).toBe(200); + expect(client.revokeAccess).toHaveBeenCalledWith("fixture-access-token"); + expect(repository.disconnect).toHaveBeenCalledWith(PROFILE.user_id, 1, expect.any(String)); + expect(repository.deleteLocalData).not.toHaveBeenCalled(); + }); + + it("disconnects using the refreshed generation that successfully revoked WHOOP access", async () => { + const { dependencies, repository, client } = createDependencies({ whoopUserId: PROFILE.user_id, status: "active", credentialVersion: 1 }); + repository.withWhoopAccessToken.mockImplementation(async (_userId, request) => request("rotated-access-token", 2)); + const app = createApp(dependencies); + + const response = await app.request("/v1/integrations/whoop", { method: "DELETE", ...bearerGet() }, ENV); + + expect(response.status).toBe(200); + expect(client.revokeAccess).toHaveBeenCalledWith("rotated-access-token"); + expect(repository.disconnect).toHaveBeenCalledWith(PROFILE.user_id, 2, expect.any(String)); + }); + + it("does not clear credentials when WHOOP revocation fails", async () => { + const { dependencies, repository, client } = createDependencies({ whoopUserId: PROFILE.user_id, status: "active", credentialVersion: 1 }); + client.revokeAccess.mockRejectedValue(new Error("upstream detail")); + const app = createApp(dependencies); + + const response = await app.request("/v1/integrations/whoop", { method: "DELETE", ...bearerGet() }, ENV); + + expect(response.status).toBe(502); + expect(repository.disconnect).not.toHaveBeenCalled(); + }); + + it("allows local WHOOP data deletion only after disconnect", async () => { + const active = createDependencies({ whoopUserId: PROFILE.user_id, status: "active", credentialVersion: 1 }); + const disconnected = createDependencies({ whoopUserId: PROFILE.user_id, status: "disconnected", credentialVersion: 2 }); + + const activeResponse = await createApp(active.dependencies) + .request("/v1/integrations/whoop/data", { method: "DELETE", ...bearerGet() }, ENV); + const disconnectedResponse = await createApp(disconnected.dependencies) + .request("/v1/integrations/whoop/data", { method: "DELETE", ...bearerGet() }, ENV); + + expect(activeResponse.status).toBe(409); + expect(active.repository.deleteLocalData).not.toHaveBeenCalled(); + expect(disconnectedResponse.status).toBe(200); + expect(disconnected.repository.deleteLocalData).toHaveBeenCalledWith(PROFILE.user_id, 2); + }); + + it("returns conflict when a concurrent reconnect invalidates disconnect or local-delete CAS", async () => { + const disconnect = createDependencies({ whoopUserId: PROFILE.user_id, status: "active", credentialVersion: 1 }); + disconnect.repository.disconnect.mockResolvedValue(false); + const deletion = createDependencies({ whoopUserId: PROFILE.user_id, status: "disconnected", credentialVersion: 2 }); + deletion.repository.deleteLocalData.mockResolvedValue(false); + + const disconnectResponse = await createApp(disconnect.dependencies) + .request("/v1/integrations/whoop", { method: "DELETE", ...bearerGet() }, ENV); + const deleteResponse = await createApp(deletion.dependencies) + .request("/v1/integrations/whoop/data", { method: "DELETE", ...bearerGet() }, ENV); + + expect(disconnectResponse.status).toBe(409); + expect(deleteResponse.status).toBe(409); + }); + + it("returns a token-free connection and progress projection", async () => { + const { dependencies, repository } = createDependencies({ + whoopUserId: PROFILE.user_id, + status: "active", + credentialVersion: 1, + granted_scopes: ["offline", "read:profile"], + }); + repository.getSyncProgress.mockResolvedValue([{ resource: "sleep", mode: "backfill", status: "retrying" }]); + repository.getRecentSyncRuns.mockResolvedValue([{ + run_id: "00000000-0000-4000-8000-000000000099", + trigger: "scheduled", + status: "error", + page_count: 2, + record_count: 25, + expected_target_count: 6, + completed_target_count: 5, + started_at: "2026-08-19T12:00:00.000Z", + succeeded_at: null, + failed_at: "2026-08-19T12:01:00.000Z", + last_error: "WHOOP synchronization failed", + }]); + const app = createApp(dependencies); + + const response = await app.request("/v1/integrations/whoop", bearerGet(), ENV); + const body = await response.json() as Record; + + expect(response.status).toBe(200); + expect(body).toEqual({ + status: "active", + granted_scopes: ["offline", "read:profile"], + progress: [{ resource: "sleep", mode: "backfill", status: "retrying" }], + runs: [expect.objectContaining({ status: "error", completed_target_count: 5 })], + }); + expect(JSON.stringify(body)).not.toMatch(/token|ciphertext|nonce|raw_json/i); + }); + + it("advertises asynchronous reconciliation and exact WHOOP status/progress values", () => { + const document = getOpenApiDocument("test"); + const sync = document.paths?.["/v1/integrations/whoop/sync"]?.post; + const status = document.paths?.["/v1/integrations/whoop"]?.get; + + expect(sync?.responses).toHaveProperty("202"); + expect(JSON.stringify(status)).toContain("not_connected"); + expect(JSON.stringify(status)).toContain("backfilling"); + expect(JSON.stringify(status)).toContain("body_measurement"); + expect(JSON.stringify(status)).toContain("page_count"); + for (const state of ["queued", "running", "retrying", "complete", "error"]) { + expect(JSON.stringify(status)).toContain(state); + } + expect(JSON.stringify(status)).not.toContain('"failed"'); + }); +}); diff --git a/src/__tests__/whoop/migration.test.ts b/src/__tests__/whoop/migration.test.ts new file mode 100644 index 0000000..e26d0bc --- /dev/null +++ b/src/__tests__/whoop/migration.test.ts @@ -0,0 +1,267 @@ +// @ts-expect-error Node test-runtime types are intentionally excluded from the Worker build. +import { execFile } from "node:child_process"; +// @ts-expect-error Node test-runtime types are intentionally excluded from the Worker build. +import { mkdtemp, readFile, rm } from "node:fs/promises"; +// @ts-expect-error Node test-runtime types are intentionally excluded from the Worker build. +import { tmpdir } from "node:os"; +// @ts-expect-error Node test-runtime types are intentionally excluded from the Worker build. +import { join } from "node:path"; +// @ts-expect-error Node test-runtime types are intentionally excluded from the Worker build. +import process from "node:process"; +// @ts-expect-error Node test-runtime types are intentionally excluded from the Worker build. +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); + +const wranglerEnvironment = { + ...process.env, + CI: "true", + WRANGLER_HIDE_BANNER: "true", + WRANGLER_SEND_METRICS: "false", + WRANGLER_SEND_ERROR_REPORTS: "false", +}; + +const stripSqlComments = (sql: string): string => { + let result = ""; + let index = 0; + let quote: "'" | '"' | "`" | "]" | null = null; + + while (index < sql.length) { + const character = sql[index]; + const next = sql[index + 1]; + + if (quote) { + result += character; + if (quote === "]") { + if (character === "]" && next === "]") { + result += next; + index += 2; + continue; + } + if (character === "]") quote = null; + } else if (character === quote) { + if (next === quote) { + result += next; + index += 2; + continue; + } + quote = null; + } + index += 1; + continue; + } + + if (character === "'" || character === '"' || character === "`") { + quote = character; + result += character; + index += 1; + continue; + } + if (character === "[") { + quote = "]"; + result += character; + index += 1; + continue; + } + if (character === "-" && next === "-") { + index += 2; + while (index < sql.length && sql[index] !== "\n") index += 1; + result += "\n"; + index += 1; + continue; + } + if (character === "/" && next === "*") { + index += 2; + while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) { + if (sql[index] === "\n") result += "\n"; + index += 1; + } + index += 2; + continue; + } + + result += character; + index += 1; + } + + return result; +}; + +const hasAppleHealthReferenceOutsideComments = (sql: string) => ( + /\bapple_health_[A-Za-z0-9_]*\b/i.test(stripSqlComments(sql)) +); + +const WHOOP_TABLES = [ + "whoop_connections", + "whoop_oauth_states", + "whoop_profiles", + "whoop_body_measurements", + "whoop_cycles", + "whoop_recoveries", + "whoop_sleeps", + "whoop_workouts", + "whoop_webhook_events", + "whoop_reconcile_seen", + "whoop_sync_checkpoints", + "whoop_sync_runs", +] as const; + +const WHOOP_INDEXES = [ + "idx_whoop_profiles_user_updated", + "idx_whoop_profiles_deleted", + "idx_whoop_body_measurements_user_updated", + "idx_whoop_body_measurements_deleted", + "idx_whoop_cycles_user_start", + "idx_whoop_cycles_user_end", + "idx_whoop_cycles_deleted", + "idx_whoop_recoveries_user_updated", + "idx_whoop_recoveries_deleted", + "idx_whoop_sleeps_user_start", + "idx_whoop_sleeps_user_end", + "idx_whoop_sleeps_deleted", + "idx_whoop_workouts_user_start", + "idx_whoop_workouts_user_end", + "idx_whoop_workouts_deleted", + "idx_whoop_webhook_events_user_received", + "idx_whoop_sync_checkpoints_progress", + "idx_whoop_sync_runs_user_started", +] as const; + +const requiredColumns: Record = { + whoop_connections: [ + "connection_id", "credential_version", "reconcile_generation", + "initial_backfill_pending", "refresh_dispatched_at", + ], + whoop_oauth_states: ["state_hash", "created_at", "expires_at", "consumed_at"], + whoop_profiles: ["whoop_user_id", "deleted_at", "synced_at", "raw_json"], + whoop_body_measurements: ["whoop_user_id", "deleted_at", "synced_at", "raw_json"], + whoop_cycles: ["cycle_id", "whoop_user_id", "kilojoules", "deleted_at", "synced_at", "raw_json"], + whoop_recoveries: ["sleep_id", "cycle_id", "whoop_user_id", "user_calibrating", "deleted_at", "synced_at", "raw_json"], + whoop_sleeps: [ + "sleep_id", "cycle_id", "whoop_user_id", "stage_in_bed_milliseconds", + "stage_no_data_milliseconds", "sleep_needed_milliseconds", "sleep_debt_milliseconds", + "sleep_need_recent_strain_milliseconds", "sleep_need_recent_nap_milliseconds", + "sleep_cycle_count", "disturbance_count", "deleted_at", "synced_at", "raw_json", + ], + whoop_workouts: ["workout_id", "whoop_user_id", "kilojoules", "deleted_at", "synced_at", "raw_json"], + whoop_webhook_events: ["trace_id", "connection_id", "event_type", "status", "attempts"], + whoop_reconcile_seen: ["connection_id", "reconcile_generation", "reconcile_run_id", "resource", "provider_id"], + whoop_sync_checkpoints: ["connection_id", "mode", "reconcile_generation", "sync_run_id", "target_id", "window_end"], + whoop_sync_runs: [ + "run_id", "whoop_user_id", "connection_id", "reconcile_generation", "trigger", + "status", "expected_target_count", "completed_target_count", + ], +}; + +type WranglerRow = Record; + +const parseWranglerRows = (stdout: string): WranglerRow[] => { + const payload = JSON.parse(stdout) as Array<{ results?: WranglerRow[] }>; + return payload[0]?.results ?? []; +}; + +describe("WHOOP fresh D1 migration", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "whoop-d1-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + const wrangler = async (...args: string[]) => execFileAsync( + process.execPath, + ["node_modules/wrangler/bin/wrangler.js", ...args], + { cwd: process.cwd(), env: wranglerEnvironment }, + ); + + it("applies every migration from empty state and creates the final WHOOP schema", async () => { + const migrationSql = await readFile("migrations/0020_whoop.sql", "utf8"); + expect(hasAppleHealthReferenceOutsideComments(migrationSql)).toBe(false); + + await wrangler( + "d1", "migrations", "apply", "personal_api", "--local", "--persist-to", tempDir, + ); + + const schema = await wrangler( + "d1", "execute", "personal_api", "--local", "--persist-to", tempDir, + "--command", "SELECT type, name FROM sqlite_master WHERE name LIKE 'whoop_%' OR name LIKE 'idx_whoop_%' ORDER BY type, name", + "--json", + ); + const objects = parseWranglerRows(schema.stdout); + const tables = objects.filter((row) => row.type === "table").map((row) => row.name); + const indexes = objects.filter((row) => row.type === "index").map((row) => row.name); + expect(tables).toEqual([...WHOOP_TABLES].sort()); + expect(indexes).toEqual([...WHOOP_INDEXES].sort()); + + for (const [table, expected] of Object.entries(requiredColumns)) { + const result = await wrangler( + "d1", "execute", "personal_api", "--local", "--persist-to", tempDir, + "--command", `SELECT name FROM pragma_table_info('${table}') ORDER BY cid`, "--json", + ); + const columns = parseWranglerRows(result.stdout).map((row) => row.name); + expect(columns, table).toEqual(expect.arrayContaining([...expected])); + } + }, 30_000); + + it("enforces representative WHOOP constraints and has no foreign-key violations", async () => { + await wrangler( + "d1", "migrations", "apply", "personal_api", "--local", "--persist-to", tempDir, + ); + + const invalidStatus = wrangler( + "d1", "execute", "personal_api", "--local", "--persist-to", tempDir, + "--command", "INSERT INTO whoop_connections (whoop_user_id, connection_id, status, granted_scopes, created_at, updated_at) VALUES (42, 'fixture-connection', 'invalid', '', '2026-08-19T12:00:00.000Z', '2026-08-19T12:00:00.000Z')", + ); + await expect(invalidStatus).rejects.toThrow(/CHECK constraint failed/i); + + const invalidEvent = wrangler( + "d1", "execute", "personal_api", "--local", "--persist-to", tempDir, + "--command", "INSERT INTO whoop_webhook_events (trace_id, whoop_user_id, connection_id, resource_id, event_type, received_at, status) VALUES ('trace', 42, 'fixture-connection', 'resource', 'profile.updated', '2026-08-19T12:00:00.000Z', 'received')", + ); + await expect(invalidEvent).rejects.toThrow(/CHECK constraint failed/i); + + const foreignKeys = await wrangler( + "d1", "execute", "personal_api", "--local", "--persist-to", tempDir, + "--command", "PRAGMA foreign_key_check", "--json", + ); + expect(parseWranglerRows(foreignKeys.stdout)).toEqual([]); + }, 30_000); + + it("keeps focused WHOOP verification in the package and CI contracts", async () => { + const packageJson = JSON.parse(await readFile("package.json", "utf8")) as { + scripts?: Record; + }; + const ci = await readFile(".github/workflows/ci.yml", "utf8"); + + expect(packageJson.scripts?.["test:whoop"]).toBe("vitest run src/__tests__/whoop"); + expect(ci).toMatch(/name:\s*Test WHOOP[\s\S]*?run:\s*npm run test:whoop[\s\S]*?name:\s*Test\b/); + }); + + it("disables Wrangler banner, telemetry, and error reporting for local child processes", () => { + expect(wranglerEnvironment).toMatchObject({ + WRANGLER_HIDE_BANNER: "true", + WRANGLER_SEND_METRICS: "false", + WRANGLER_SEND_ERROR_REPORTS: "false", + }); + }); + + it("ignores Apple identifiers in comments but rejects Apple references elsewhere", () => { + expect(hasAppleHealthReferenceOutsideComments("-- apple_health_daily is intentionally untouched\nSELECT 1")).toBe(false); + expect(hasAppleHealthReferenceOutsideComments("/* apple_health_workouts */ SELECT 'ordinary value'")).toBe(false); + expect(hasAppleHealthReferenceOutsideComments("SELECT '-- ordinary string, not a comment' AS note")).toBe(false); + // SQLite accepts single-quoted identifiers, so this migration deliberately rejects the + // Apple prefix even inside string values. There is no legitimate Apple value in 0020. + expect(hasAppleHealthReferenceOutsideComments("SELECT 'apple_health_daily' AS note")).toBe(true); + expect(hasAppleHealthReferenceOutsideComments("CREATE INDEX changed ON apple_health_daily(date)")).toBe(true); + expect(hasAppleHealthReferenceOutsideComments("INSERT INTO apple_health_daily(date) VALUES ('2026-08-20')")).toBe(true); + expect(hasAppleHealthReferenceOutsideComments("UPDATE apple_health_workouts SET source = 'whoop'")).toBe(true); + expect(hasAppleHealthReferenceOutsideComments("DELETE FROM [apple_health_sleep_sessions]")).toBe(true); + expect(hasAppleHealthReferenceOutsideComments("CREATE TABLE 'apple_health_evil' (id INTEGER)")).toBe(true); + expect(hasAppleHealthReferenceOutsideComments("DELETE FROM 'apple_health_daily'")).toBe(true); + expect(hasAppleHealthReferenceOutsideComments("CREATE INDEX changed ON 'apple_health_daily'(date)")).toBe(true); + }); +}); diff --git a/src/__tests__/whoop/repository-sqlite.test.ts b/src/__tests__/whoop/repository-sqlite.test.ts new file mode 100644 index 0000000..4e8b317 --- /dev/null +++ b/src/__tests__/whoop/repository-sqlite.test.ts @@ -0,0 +1,1058 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { WhoopRepository, type CheckpointInput } from "../../services/whoop/repository"; +import { CONNECTION_ID, KEY, NOW, RECOVERY, SLEEP, WORKOUT } from "./fixtures"; + +type SqliteStatement = { + all: (...bindings: unknown[]) => unknown[]; + get: (...bindings: unknown[]) => unknown; + run: (...bindings: unknown[]) => { changes: number | bigint }; +}; + +type SqliteDatabase = { + close: () => void; + exec: (sql: string) => void; + prepare: (sql: string) => SqliteStatement; +}; + +class SqliteD1Statement { + constructor( + private readonly owner: SqliteD1, + readonly sql: string, + readonly bindings: unknown[] = [], + ) {} + + bind(...bindings: unknown[]) { + return new SqliteD1Statement(this.owner, this.sql, bindings); + } + + async first(): Promise { + await this.owner.beforeExecute?.("first", this.sql, this.bindings); + return (this.owner.database.prepare(this.sql).get(...this.bindings) ?? null) as T | null; + } + + async all() { + return { + results: this.owner.database.prepare(this.sql).all(...this.bindings) as T[], + success: true, + meta: {}, + }; + } + + async run() { + await this.owner.beforeExecute?.("run", this.sql, this.bindings); + const result = this.owner.database.prepare(this.sql).run(...this.bindings); + return { success: true, results: [], meta: { changes: Number(result.changes) } }; + } +} + +class SqliteD1 { + beforeExecute?: ( + operation: "first" | "run", + sql: string, + bindings: readonly unknown[], + ) => Promise; + + constructor(readonly database: SqliteDatabase) {} + + prepare(sql: string) { + return new SqliteD1Statement(this, sql); + } + + async batch(statements: D1PreparedStatement[]) { + this.database.exec("BEGIN IMMEDIATE"); + try { + const results = []; + for (const statement of statements as unknown as SqliteD1Statement[]) { + results.push(await statement.run()); + } + this.database.exec("COMMIT"); + return results; + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + } +} + +const checkpoint = (overrides: Partial & { + syncRunId: string; + targetId: string; +}): CheckpointInput => ({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: 0, + resource: "recovery", + mode: "backfill", + windowStart: null, + windowEnd: null, + nextToken: null, + status: "running", + pageCount: 0, + recordCount: 0, + createdAt: NOW, + updatedAt: NOW, + lastError: null, + ...overrides, +} as CheckpointInput); + +const insertSleep = ( + database: SqliteDatabase, + id: string, + startAt: string, +) => database.prepare(` + INSERT INTO whoop_sleeps ( + sleep_id, cycle_id, whoop_user_id, start_at, end_at, timezone_offset, nap, + score_state, upstream_created_at, upstream_updated_at, deleted_at, synced_at, raw_json + ) VALUES (?, 9, 42, ?, ?, '-04:00', 0, 'SCORED', ?, ?, NULL, ?, '{}') +`).run(id, startAt, startAt, startAt, startAt, NOW); + +describe("WHOOP repository on SQLite", () => { + let database: SqliteDatabase; + let d1: SqliteD1; + let repository: WhoopRepository; + + beforeEach(async () => { + // @ts-expect-error The Worker typecheck intentionally excludes Node test-runtime declarations. + const { DatabaseSync } = await import("node:sqlite"); + // @ts-expect-error The Worker typecheck intentionally excludes Node test-runtime declarations. + const { readFile } = await import("node:fs/promises"); + database = new DatabaseSync(":memory:") as SqliteDatabase; + database.exec(await readFile("migrations/0020_whoop.sql", "utf8")); + database.prepare(` + INSERT INTO whoop_connections ( + whoop_user_id, connection_id, status, granted_scopes, + initial_backfill_pending, created_at, updated_at + ) VALUES (?, ?, 'backfilling', '', 1, ?, ?) + `).run(42, CONNECTION_ID, NOW, NOW); + d1 = new SqliteD1(database); + repository = new WhoopRepository(d1 as unknown as D1Database, KEY); + }); + + afterEach(() => database.close()); + + it("isolates backfill, reconciliation, and targeted recovery checkpoints", async () => { + await repository.upsertCheckpoint(checkpoint({ + syncRunId: "initial", + targetId: "", + status: "complete", + pageCount: 3, + recordCount: 51, + })); + await repository.upsertCheckpoint(checkpoint({ + mode: "reconcile", + syncRunId: "reconcile-a", + targetId: "", + pageCount: 1, + recordCount: 20, + updatedAt: "2026-08-19T12:01:00.000Z", + })); + await repository.upsertCheckpoint(checkpoint({ + mode: "reconcile", + syncRunId: "reconcile-a", + targetId: "cycle:9", + status: "retrying", + updatedAt: "2026-08-19T12:02:00.000Z", + })); + + const count = database.prepare("SELECT COUNT(*) AS count FROM whoop_sync_checkpoints") + .get() as { count: number }; + expect(count.count).toBe(3); + await expect(repository.getSyncProgress(42)).resolves.toEqual([ + expect.objectContaining({ + resource: "recovery", + mode: "backfill", + status: "complete", + }), + expect.objectContaining({ + resource: "recovery", + mode: "reconcile", + status: "running", + page_count: 1, + record_count: 20, + }), + ]); + }); + + it("does not regress a completed checkpoint when an older page or failure is redelivered", async () => { + await repository.upsertCheckpoint(checkpoint({ + mode: "reconcile", + syncRunId: "reconcile-a", + targetId: "", + nextToken: null, + status: "complete", + pageCount: 3, + recordCount: 51, + updatedAt: "2026-08-19T12:03:00.000Z", + })); + await repository.upsertCheckpoint(checkpoint({ + mode: "reconcile", + syncRunId: "reconcile-a", + targetId: "", + nextToken: "older-cursor", + status: "running", + pageCount: 2, + recordCount: 25, + updatedAt: "2026-08-19T12:04:00.000Z", + })); + await repository.upsertCheckpoint(checkpoint({ + mode: "reconcile", + syncRunId: "reconcile-a", + targetId: "", + nextToken: "older-cursor", + status: "error", + pageCount: 2, + recordCount: 25, + updatedAt: "2026-08-19T12:05:00.000Z", + lastError: "safe failure", + })); + + const stored = database.prepare(` + SELECT next_token, status, page_count, record_count, last_error + FROM whoop_sync_checkpoints + `).get(); + expect(stored).toEqual({ + next_token: null, + status: "complete", + page_count: 3, + record_count: 51, + last_error: null, + }); + }); + + it("does not refresh a checkpoint timestamp for an exact equal-state redelivery", async () => { + await repository.upsertCheckpoint(checkpoint({ + mode: "reconcile", + syncRunId: "reconcile-a", + targetId: "", + status: "complete", + pageCount: 3, + recordCount: 51, + })); + await repository.upsertCheckpoint(checkpoint({ + mode: "reconcile", + syncRunId: "reconcile-a", + targetId: "", + status: "complete", + pageCount: 3, + recordCount: 51, + updatedAt: "2026-08-19T12:30:00.000Z", + })); + + expect(database.prepare(` + SELECT updated_at FROM whoop_sync_checkpoints + WHERE sync_run_id = 'reconcile-a' + `).get()).toEqual({ updated_at: NOW }); + }); + + it("fences overlapping reconciliation generations and keeps both collection progress modes", async () => { + const lifecycle = repository as unknown as { + beginReconciliation(whoopUserId: number, connectionId: string, begunAt: string): Promise; + }; + await repository.upsertCheckpoint(checkpoint({ + resource: "sleep", + syncRunId: "initial-backfill", + targetId: "", + status: "complete", + pageCount: 4, + recordCount: 100, + })); + const firstGeneration = await lifecycle.beginReconciliation(42, CONNECTION_ID, NOW); + expect(firstGeneration).toBe(1); + await repository.upsertCheckpoint(checkpoint({ + reconcileGeneration: firstGeneration!, + resource: "sleep", + mode: "reconcile", + syncRunId: "reconcile-old", + targetId: "", + status: "complete", + pageCount: 2, + recordCount: 40, + createdAt: "2026-08-19T12:10:00.000Z", + updatedAt: "2026-08-19T12:10:00.000Z", + })); + const currentGeneration = await lifecycle.beginReconciliation( + 42, + CONNECTION_ID, + "2026-08-19T12:20:00.000Z", + ); + expect(currentGeneration).toBe(2); + await repository.upsertCheckpoint(checkpoint({ + reconcileGeneration: currentGeneration!, + resource: "sleep", + mode: "reconcile", + syncRunId: "reconcile-current", + targetId: "", + pageCount: 1, + recordCount: 10, + createdAt: "2026-08-19T12:05:00.000Z", + updatedAt: "2026-08-19T12:05:00.000Z", + })); + + await expect(repository.upsertCheckpoint(checkpoint({ + reconcileGeneration: firstGeneration!, + resource: "sleep", + mode: "reconcile", + syncRunId: "reconcile-old", + targetId: "", + status: "complete", + pageCount: 2, + recordCount: 40, + updatedAt: "2026-08-19T12:30:00.000Z", + }))).resolves.toBe(false); + await expect(repository.upsertSourceRecord("sleep", SLEEP, { + tombstonePolicy: "reconcile", + syncedAt: "2026-08-19T12:30:00.000Z", + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: firstGeneration!, + })).resolves.toBe(false); + expect(database.prepare("SELECT COUNT(*) AS count FROM whoop_sleeps").get()) + .toEqual({ count: 0 }); + await expect(repository.getSyncProgress(42)).resolves.toEqual([ + expect.objectContaining({ resource: "sleep", mode: "backfill", status: "complete" }), + expect.objectContaining({ resource: "sleep", mode: "reconcile", status: "running" }), + ]); + }); + + it("bounds abandoned seen sets when successive reconciliation generations begin", async () => { + const reconciliation = repository as unknown as { + beginReconciliation(whoopUserId: number, connectionId: string, begunAt: string): Promise; + recordReconciliationSeen(input: { + whoopUserId: number; + connectionId: string; + reconcileGeneration: number; + reconcileRunId: string; + resource: "sleep"; + providerId: string; + seenAt: string; + }): Promise; + }; + for (let generation = 1; generation <= 3; generation += 1) { + await expect(reconciliation.beginReconciliation(42, CONNECTION_ID, NOW)) + .resolves.toBe(generation); + await expect(reconciliation.recordReconciliationSeen({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: generation, + reconcileRunId: `run-${generation}`, + resource: "sleep", + providerId: `sleep-${generation}`, + seenAt: NOW, + })).resolves.toBe(true); + } + + expect(database.prepare(` + SELECT reconcile_generation, provider_id FROM whoop_reconcile_seen + `).all()).toEqual([{ reconcile_generation: 3, provider_id: "sleep-3" }]); + }); + + it("never lets a late older begin cleanup delete the current generation", async () => { + const begin = (repository as unknown as { + beginReconciliation(whoopUserId: number, connectionId: string, begunAt: string): Promise; + }).beginReconciliation.bind(repository); + let cleanupCount = 0; + let releaseFirstCleanup!: () => void; + const firstCleanupReleased = new Promise((resolve) => { + releaseFirstCleanup = resolve; + }); + let firstCleanupStarted!: () => void; + const firstCleanupReached = new Promise((resolve) => { + firstCleanupStarted = resolve; + }); + d1.beforeExecute = async (operation, sql) => { + if (operation !== "run" || !sql.includes("DELETE FROM whoop_reconcile_seen")) return; + cleanupCount += 1; + if (cleanupCount !== 1) return; + firstCleanupStarted(); + await firstCleanupReleased; + }; + + const firstBegin = begin(42, CONNECTION_ID, NOW); + await firstCleanupReached; + database.prepare(` + INSERT INTO whoop_reconcile_seen ( + whoop_user_id, connection_id, reconcile_generation, + reconcile_run_id, resource, provider_id, seen_at + ) VALUES (42, ?, 1, 'run-1', 'sleep', 'sleep-1', ?) + `).run(CONNECTION_ID, NOW); + await expect(begin(42, CONNECTION_ID, "2026-08-19T12:01:00.000Z")) + .resolves.toBe(2); + database.prepare(` + INSERT INTO whoop_reconcile_seen ( + whoop_user_id, connection_id, reconcile_generation, + reconcile_run_id, resource, provider_id, seen_at + ) VALUES (42, ?, 2, 'run-2', 'sleep', 'sleep-2', ?) + `).run(CONNECTION_ID, NOW); + releaseFirstCleanup(); + await expect(firstBegin).resolves.toBe(1); + + expect(database.prepare(` + SELECT reconcile_generation, provider_id FROM whoop_reconcile_seen + `).all()).toEqual([{ reconcile_generation: 2, provider_id: "sleep-2" }]); + }); + + it("atomically clears publication intent and activates only a complete six-resource backfill", async () => { + for (const resource of [ + "profile", "body_measurement", "cycle", "recovery", "sleep", "workout", + ] as const) { + await repository.upsertCheckpoint(checkpoint({ + resource, + syncRunId: "initial", + targetId: "", + status: "complete", + pageCount: 1, + recordCount: 1, + })); + } + + const markQueued = repository.markInitialBackfillQueued.bind(repository) as unknown as ( + whoopUserId: number, + connectionId: string, + credentialVersion: number, + queuedAt: string, + ) => Promise; + await expect(markQueued(42, CONNECTION_ID, 1, NOW)).resolves.toBe(true); + + const connection = database.prepare(` + SELECT status, initial_backfill_pending FROM whoop_connections WHERE whoop_user_id = 42 + `).get(); + expect(connection).toEqual({ status: "active", initial_backfill_pending: 0 }); + }); + + it("tombstones only in-window records absent from a completed reconciliation", async () => { + insertSleep(database, "00000000-0000-4000-8000-000000000001", "2026-08-18T08:00:00.000Z"); + insertSleep(database, "00000000-0000-4000-8000-000000000002", "2026-08-17T08:00:00.000Z"); + insertSleep(database, "00000000-0000-4000-8000-000000000003", "2026-07-01T08:00:00.000Z"); + const runId = "00000000-0000-4000-8000-000000000099"; + const recordSeen = repository as unknown as { + recordReconciliationSeen(input: { + whoopUserId: number; + connectionId: string; + reconcileGeneration: number; + reconcileRunId: string; + resource: "sleep"; + providerId: string; + seenAt: string; + }): Promise; + finalizeReconciliation(input: CheckpointInput): Promise; + }; + + await recordSeen.recordReconciliationSeen({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: 0, + reconcileRunId: runId, + resource: "sleep", + providerId: "00000000-0000-4000-8000-000000000001", + seenAt: NOW, + }); + await expect(recordSeen.finalizeReconciliation(checkpoint({ + mode: "reconcile", + syncRunId: runId, + targetId: "", + resource: "sleep", + windowStart: "2026-08-05T12:00:00.000Z", + windowEnd: NOW, + status: "complete", + pageCount: 1, + recordCount: 1, + }))).resolves.toBe(true); + + const rows = database.prepare(` + SELECT sleep_id, deleted_at FROM whoop_sleeps ORDER BY sleep_id + `).all(); + expect(rows).toEqual([ + { sleep_id: "00000000-0000-4000-8000-000000000001", deleted_at: null }, + { sleep_id: "00000000-0000-4000-8000-000000000002", deleted_at: NOW }, + { sleep_id: "00000000-0000-4000-8000-000000000003", deleted_at: null }, + ]); + expect(database.prepare("SELECT COUNT(*) AS count FROM whoop_reconcile_seen").get()) + .toEqual({ count: 0 }); + }); + + it("preserves a post-snapshot write while tombstoning an untouched omission", async () => { + const seenId = "00000000-0000-4000-8000-000000000001"; + const lateWriteId = "00000000-0000-4000-8000-000000000002"; + const untouchedId = "00000000-0000-4000-8000-000000000003"; + insertSleep(database, seenId, "2026-08-18T08:00:00.000Z"); + insertSleep(database, lateWriteId, "2026-08-17T08:00:00.000Z"); + insertSleep(database, untouchedId, "2026-08-16T08:00:00.000Z"); + const runId = "00000000-0000-4000-8000-000000000099"; + const reconciliation = repository as unknown as { + recordReconciliationSeen(input: { + whoopUserId: number; + connectionId: string; + reconcileGeneration: number; + reconcileRunId: string; + resource: "sleep"; + providerId: string; + seenAt: string; + }): Promise; + finalizeReconciliation(input: CheckpointInput): Promise; + }; + await reconciliation.recordReconciliationSeen({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: 0, + reconcileRunId: runId, + resource: "sleep", + providerId: seenId, + seenAt: NOW, + }); + + await repository.upsertSourceRecord("sleep", { + ...SLEEP, + id: lateWriteId, + }, { + tombstonePolicy: "preserve", + syncedAt: "2026-08-19T08:00:00.250-04:00", + whoopUserId: 42, + connectionId: CONNECTION_ID, + }); + await expect(reconciliation.finalizeReconciliation(checkpoint({ + mode: "reconcile", + syncRunId: runId, + targetId: "", + resource: "sleep", + windowStart: "2026-08-05T08:00:00-04:00", + windowEnd: "2026-08-19T08:00:00-04:00", + status: "complete", + pageCount: 1, + recordCount: 1, + }))).resolves.toBe(true); + + expect(database.prepare(` + SELECT sleep_id, deleted_at, synced_at FROM whoop_sleeps ORDER BY sleep_id + `).all()).toEqual([ + { sleep_id: seenId, deleted_at: null, synced_at: NOW }, + { + sleep_id: lateWriteId, + deleted_at: null, + synced_at: "2026-08-19T12:00:00.250Z", + }, + { sleep_id: untouchedId, deleted_at: NOW, synced_at: NOW }, + ]); + }); + + it("retains seen identifiers across pages and finalizes idempotently", async () => { + insertSleep(database, "00000000-0000-4000-8000-000000000001", "2026-08-18T08:00:00.000Z"); + insertSleep(database, "00000000-0000-4000-8000-000000000002", "2026-08-17T08:00:00.000Z"); + insertSleep(database, "00000000-0000-4000-8000-000000000003", "2026-08-16T08:00:00.000Z"); + const runId = "00000000-0000-4000-8000-000000000099"; + const reconciliation = repository as unknown as { + recordReconciliationSeen(input: { + whoopUserId: number; + connectionId: string; + reconcileGeneration: number; + reconcileRunId: string; + resource: "sleep"; + providerId: string; + seenAt: string; + }): Promise; + finalizeReconciliation(input: CheckpointInput): Promise; + }; + + await reconciliation.recordReconciliationSeen({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: 0, + reconcileRunId: runId, + resource: "sleep", + providerId: "00000000-0000-4000-8000-000000000001", + seenAt: NOW, + }); + await repository.upsertCheckpoint(checkpoint({ + mode: "reconcile", + syncRunId: runId, + targetId: "", + resource: "sleep", + windowStart: "2026-08-05T12:00:00.000Z", + windowEnd: NOW, + nextToken: "page-2", + pageCount: 1, + recordCount: 1, + })); + await reconciliation.recordReconciliationSeen({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: 0, + reconcileRunId: runId, + resource: "sleep", + providerId: "00000000-0000-4000-8000-000000000002", + seenAt: NOW, + }); + const finalCheckpoint = checkpoint({ + mode: "reconcile", + syncRunId: runId, + targetId: "", + resource: "sleep", + windowStart: "2026-08-05T12:00:00.000Z", + windowEnd: NOW, + status: "complete", + pageCount: 2, + recordCount: 2, + }); + await reconciliation.finalizeReconciliation(finalCheckpoint); + await reconciliation.recordReconciliationSeen({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: 0, + reconcileRunId: runId, + resource: "sleep", + providerId: "00000000-0000-4000-8000-000000000002", + seenAt: NOW, + }); + await expect(reconciliation.finalizeReconciliation(finalCheckpoint)).resolves.toBe(true); + + expect(database.prepare(` + SELECT sleep_id, deleted_at FROM whoop_sleeps ORDER BY sleep_id + `).all()).toEqual([ + { sleep_id: "00000000-0000-4000-8000-000000000001", deleted_at: null }, + { sleep_id: "00000000-0000-4000-8000-000000000002", deleted_at: null }, + { sleep_id: "00000000-0000-4000-8000-000000000003", deleted_at: NOW }, + ]); + expect(database.prepare("SELECT COUNT(*) AS count FROM whoop_reconcile_seen").get()) + .toEqual({ count: 0 }); + }); + + it("retains seen identifiers and live rows when reconciliation fails before finalization", async () => { + insertSleep(database, "00000000-0000-4000-8000-000000000001", "2026-08-18T08:00:00.000Z"); + insertSleep(database, "00000000-0000-4000-8000-000000000002", "2026-08-17T08:00:00.000Z"); + const runId = "00000000-0000-4000-8000-000000000099"; + const recordSeen = (repository as unknown as { + recordReconciliationSeen(input: { + whoopUserId: number; + connectionId: string; + reconcileGeneration: number; + reconcileRunId: string; + resource: "sleep"; + providerId: string; + seenAt: string; + }): Promise; + }).recordReconciliationSeen.bind(repository); + + await recordSeen({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: 0, + reconcileRunId: runId, + resource: "sleep", + providerId: "00000000-0000-4000-8000-000000000001", + seenAt: NOW, + }); + await repository.upsertCheckpoint(checkpoint({ + mode: "reconcile", + syncRunId: runId, + targetId: "", + resource: "sleep", + windowStart: "2026-08-05T12:00:00.000Z", + windowEnd: NOW, + status: "retrying", + lastError: "WHOOP synchronization failed", + })); + + expect(database.prepare("SELECT sleep_id, deleted_at FROM whoop_sleeps ORDER BY sleep_id").all()) + .toEqual([ + { sleep_id: "00000000-0000-4000-8000-000000000001", deleted_at: null }, + { sleep_id: "00000000-0000-4000-8000-000000000002", deleted_at: null }, + ]); + expect(database.prepare("SELECT provider_id FROM whoop_reconcile_seen").all()) + .toEqual([{ provider_id: "00000000-0000-4000-8000-000000000001" }]); + }); + + it("refuses webhook receipt and queue transitions from a stale connection lifecycle", async () => { + const recordWebhookEvent = repository.recordWebhookEvent.bind(repository) as unknown as (input: { + traceId: string; + whoopUserId: number; + connectionId: string; + resourceId: string; + eventType: "workout.updated"; + receivedAt: string; + }) => Promise; + const markWebhookQueued = repository.markWebhookQueued.bind(repository) as unknown as ( + traceId: string, + whoopUserId: number, + connectionId: string, + ) => Promise; + const getWebhookEventStatus = repository.getWebhookEventStatus.bind(repository); + + database.prepare("UPDATE whoop_connections SET connection_id = 'connection-new'").run(); + await expect(recordWebhookEvent({ + traceId: "trace-stale", + whoopUserId: 42, + connectionId: CONNECTION_ID, + resourceId: WORKOUT.id, + eventType: "workout.updated", + receivedAt: NOW, + })).resolves.toBe(false); + expect(database.prepare("SELECT COUNT(*) AS count FROM whoop_webhook_events").get()) + .toEqual({ count: 0 }); + + await expect(recordWebhookEvent({ + traceId: "trace-current", + whoopUserId: 42, + connectionId: "connection-new", + resourceId: WORKOUT.id, + eventType: "workout.updated", + receivedAt: NOW, + })).resolves.toBe(true); + await expect(getWebhookEventStatus("trace-current", 42, "connection-new")) + .resolves.toBe("received"); + database.prepare("UPDATE whoop_connections SET connection_id = 'connection-newer'").run(); + await expect(markWebhookQueued("trace-current", 42, "connection-new")).resolves.toBe(false); + await expect(getWebhookEventStatus("trace-current", 42, "connection-new")) + .resolves.toBeNull(); + expect(database.prepare("SELECT status FROM whoop_webhook_events WHERE trace_id = 'trace-current'").get()) + .toEqual({ status: "received" }); + }); + + it("does not apply an old connection delete event to a new connection backfill", async () => { + const recordWebhookEvent = repository.recordWebhookEvent.bind(repository) as unknown as (input: { + traceId: string; + whoopUserId: number; + connectionId: string; + resourceId: string; + eventType: "workout.deleted"; + receivedAt: string; + }) => Promise; + await recordWebhookEvent({ + traceId: "trace-old-delete", + whoopUserId: 42, + connectionId: CONNECTION_ID, + resourceId: WORKOUT.id, + eventType: "workout.deleted", + receivedAt: NOW, + }); + database.prepare("UPDATE whoop_connections SET connection_id = 'connection-new'").run(); + + await repository.upsertSourceRecord("workout", WORKOUT, { + tombstonePolicy: "preserve", + syncedAt: NOW, + whoopUserId: 42, + connectionId: "connection-new", + }); + + expect(database.prepare("SELECT deleted_at FROM whoop_workouts WHERE workout_id = ?") + .get(WORKOUT.id)).toEqual({ deleted_at: null }); + }); + + it("fairly rotates the bounded pending recovery batch after targeted success or failure", async () => { + const insert = database.prepare(` + INSERT INTO whoop_recoveries ( + sleep_id, cycle_id, whoop_user_id, score_state, + upstream_created_at, upstream_updated_at, deleted_at, synced_at, raw_json + ) VALUES (?, ?, 42, 'PENDING_SCORE', ?, ?, NULL, ?, '{}') + `); + for (let cycleId = 1; cycleId <= 30; cycleId += 1) { + const timestamp = `2026-08-01T00:00:${String(cycleId).padStart(2, "0")}.000Z`; + insert.run( + `00000000-0000-4000-8000-${String(cycleId).padStart(12, "0")}`, + cycleId, + timestamp, + timestamp, + timestamp, + ); + } + + const firstBatch = await repository.getPendingRecoveryCycleIds(42, 25); + expect(firstBatch).toHaveLength(25); + expect(firstBatch[0]).toBe(1); + expect(firstBatch.at(-1)).toBe(25); + + await repository.upsertSourceRecord("recovery", { + ...RECOVERY, + sleep_id: "00000000-0000-4000-8000-000000000001", + cycle_id: 1, + score_state: "PENDING_SCORE", + }, { + tombstonePolicy: "reconcile", + syncedAt: NOW, + whoopUserId: 42, + connectionId: CONNECTION_ID, + }); + + const secondBatch = await repository.getPendingRecoveryCycleIds(42, 25); + expect(secondBatch).toHaveLength(25); + expect(secondBatch[0]).toBe(2); + expect(secondBatch.at(-1)).toBe(26); + expect(secondBatch).not.toContain(1); + + database.prepare(` + UPDATE whoop_recoveries SET synced_at = '2026-08-01T00:00:01.000Z' WHERE cycle_id = 1 + `).run(); + await repository.upsertCheckpoint(checkpoint({ + mode: "reconcile", + syncRunId: "00000000-0000-4000-8000-000000000099", + targetId: "recovery-cycle:1", + resource: "recovery", + status: "retrying", + updatedAt: NOW, + lastError: "WHOOP synchronization failed", + })); + + const failureBatch = await repository.getPendingRecoveryCycleIds(42, 25); + expect(failureBatch[0]).toBe(2); + expect(failureBatch.at(-1)).toBe(26); + expect(failureBatch).not.toContain(1); + }); + + it("lifecycle-fences connection health and derives run totals without redelivery double counts", async () => { + database.prepare("UPDATE whoop_connections SET reconcile_generation = 1").run(); + await repository.createSyncRun({ + runId: "run-health", + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: 1, + trigger: "manual", + expectedTargetCount: 2, + startedAt: NOW, + }); + await repository.upsertCheckpoint(checkpoint({ + mode: "reconcile", + reconcileGeneration: 1, + syncRunId: "run-health", + targetId: "", + resource: "sleep", + status: "complete", + pageCount: 2, + recordCount: 30, + })); + await repository.upsertCheckpoint(checkpoint({ + mode: "reconcile", + reconcileGeneration: 1, + syncRunId: "run-health", + targetId: "", + resource: "workout", + status: "retrying", + pageCount: 1, + recordCount: 4, + lastError: "WHOOP request failed with status 503", + })); + + await expect(repository.refreshSyncRun("run-health", 42, CONNECTION_ID, 1, NOW)) + .resolves.toBe(true); + await expect(repository.refreshSyncRun("run-health", 42, CONNECTION_ID, 1, NOW)) + .resolves.toBe(true); + expect(database.prepare(` + SELECT status, page_count, record_count, expected_target_count, completed_target_count + FROM whoop_sync_runs WHERE run_id = 'run-health' + `).get()).toEqual({ + status: "retrying", + page_count: 3, + record_count: 34, + expected_target_count: 2, + completed_target_count: 1, + }); + await repository.upsertCheckpoint(checkpoint({ + mode: "reconcile", + reconcileGeneration: 1, + syncRunId: "run-health", + targetId: "", + resource: "workout", + status: "complete", + pageCount: 1, + recordCount: 4, + })); + await repository.refreshSyncRun( + "run-health", 42, CONNECTION_ID, 1, "2026-08-19T12:01:00.000Z", + ); + expect(database.prepare(` + SELECT status, page_count, record_count, completed_target_count, succeeded_at + FROM whoop_sync_runs WHERE run_id = 'run-health' + `).get()).toEqual({ + status: "complete", + page_count: 3, + record_count: 34, + completed_target_count: 2, + succeeded_at: "2026-08-19T12:01:00.000Z", + }); + + await expect(repository.recordSyncFailure( + 42, CONNECTION_ID, NOW, "WHOOP request failed with status 503", + )).resolves.toBe(true); + await expect(repository.recordSyncSuccess(42, CONNECTION_ID, "2026-08-19T12:01:00.000Z")) + .resolves.toBe(true); + expect(database.prepare(` + SELECT last_success_at, last_error_at, last_error, consecutive_failure_count + FROM whoop_connections WHERE whoop_user_id = 42 + `).get()).toEqual({ + last_success_at: "2026-08-19T12:01:00.000Z", + last_error_at: null, + last_error: null, + consecutive_failure_count: 0, + }); + database.prepare("UPDATE whoop_connections SET connection_id = 'replacement'").run(); + await expect(repository.recordSyncFailure(42, CONNECTION_ID, NOW, "stale")) + .resolves.toBe(false); + }); + + it("prunes only bounded terminal operational data and preserves deletion receipts and latest progress", async () => { + database.exec(` + INSERT INTO whoop_oauth_states VALUES ('old-state', '2026-06-01T00:00:00.000Z', '2026-06-01T00:10:00.000Z', NULL); + INSERT INTO whoop_oauth_states VALUES ('fresh-state', '2026-08-19T11:50:00.000Z', '2026-08-19T12:10:00.000Z', NULL); + INSERT INTO whoop_webhook_events VALUES ('old-update', 42, '${CONNECTION_ID}', 'x', 'sleep.updated', '2026-06-01T00:00:00.000Z', '2026-06-01T00:01:00.000Z', 'processed', 1, NULL); + INSERT INTO whoop_webhook_events VALUES ('old-delete', 42, '${CONNECTION_ID}', 'x', 'sleep.deleted', '2026-06-01T00:00:00.000Z', '2026-06-01T00:01:00.000Z', 'processed', 1, NULL); + INSERT INTO whoop_webhook_events VALUES ('old-queued', 42, '${CONNECTION_ID}', 'x', 'sleep.updated', '2026-06-01T00:00:00.000Z', NULL, 'queued', 1, NULL); + `); + await repository.upsertCheckpoint(checkpoint({ + syncRunId: "old", + targetId: "", + status: "complete", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + })); + await repository.upsertCheckpoint(checkpoint({ + syncRunId: "old-targeted", + targetId: "recovery-cycle:9", + status: "error", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + })); + await repository.upsertCheckpoint(checkpoint({ syncRunId: "latest", targetId: "", status: "complete", updatedAt: NOW, createdAt: NOW })); + + await repository.pruneOperationalData(NOW); + + expect(database.prepare("SELECT state_hash FROM whoop_oauth_states ORDER BY state_hash").all()) + .toEqual([{ state_hash: "fresh-state" }]); + expect(database.prepare("SELECT trace_id FROM whoop_webhook_events ORDER BY trace_id").all()) + .toEqual([{ trace_id: "old-delete" }, { trace_id: "old-queued" }]); + expect(database.prepare("SELECT sync_run_id FROM whoop_sync_checkpoints ORDER BY sync_run_id").all()) + .toEqual([{ sync_run_id: "latest" }]); + }); + + it("prunes abandoned nonterminal work only after grace while preserving current work", async () => { + database.prepare("UPDATE whoop_connections SET reconcile_generation = 3, status = 'active'").run(); + const insertCheckpoint = database.prepare(` + INSERT INTO whoop_sync_checkpoints ( + whoop_user_id, connection_id, resource, mode, reconcile_generation, + sync_run_id, target_id, status, page_count, record_count, created_at, updated_at + ) VALUES (42, ?, ?, 'reconcile', ?, ?, '', ?, 0, 0, ?, ?) + `); + const old = "2026-08-17T00:00:00.000Z"; + const withinGrace = "2026-08-19T06:00:00.000Z"; + insertCheckpoint.run(CONNECTION_ID, "sleep", 3, "current-running", "running", old, old); + insertCheckpoint.run(CONNECTION_ID, "workout", 3, "current-retrying", "retrying", old, old); + insertCheckpoint.run(CONNECTION_ID, "cycle", 3, "current-queued", "queued", old, old); + insertCheckpoint.run(CONNECTION_ID, "sleep", 2, "superseded-recent", "retrying", withinGrace, withinGrace); + insertCheckpoint.run(CONNECTION_ID, "sleep", 1, "superseded-old", "queued", old, old); + insertCheckpoint.run("old-connection", "sleep", 8, "old-lifecycle", "running", old, old); + + const insertRun = database.prepare(` + INSERT INTO whoop_sync_runs ( + run_id, whoop_user_id, connection_id, reconcile_generation, trigger, status, + expected_target_count, completed_target_count, page_count, record_count, started_at + ) VALUES (?, 42, ?, ?, 'scheduled', ?, 6, 0, 0, 0, ?) + `); + insertRun.run("current-queued", CONNECTION_ID, 3, "queued", old); + insertRun.run("current-running", CONNECTION_ID, 3, "running", old); + insertRun.run("current-retrying", CONNECTION_ID, 3, "retrying", old); + insertRun.run("superseded-recent", CONNECTION_ID, 2, "retrying", withinGrace); + insertRun.run("superseded-old", CONNECTION_ID, 1, "running", old); + insertRun.run("old-lifecycle", "old-connection", 8, "queued", old); + + await repository.pruneOperationalData(NOW); + + expect(database.prepare("SELECT sync_run_id FROM whoop_sync_checkpoints ORDER BY sync_run_id").all()) + .toEqual([ + { sync_run_id: "current-queued" }, + { sync_run_id: "current-retrying" }, + { sync_run_id: "current-running" }, + { sync_run_id: "superseded-recent" }, + ]); + expect(database.prepare("SELECT run_id FROM whoop_sync_runs ORDER BY run_id").all()) + .toEqual([ + { run_id: "current-queued" }, + { run_id: "current-retrying" }, + { run_id: "current-running" }, + { run_id: "superseded-recent" }, + ]); + }); + + it("does not let abandoned nonterminal work supersede the useful terminal projection", async () => { + database.prepare("UPDATE whoop_connections SET reconcile_generation = 3, status = 'active'").run(); + const insertCheckpoint = database.prepare(` + INSERT INTO whoop_sync_checkpoints ( + whoop_user_id, connection_id, resource, mode, reconcile_generation, + sync_run_id, target_id, status, page_count, record_count, created_at, updated_at + ) VALUES (42, ?, 'sleep', 'reconcile', ?, ?, ?, ?, 0, 0, ?, ?) + `); + const oldTerminal = "2026-06-01T00:00:00.000Z"; + const abandoned = "2026-08-17T00:00:00.000Z"; + insertCheckpoint.run(CONNECTION_ID, 1, "useful-terminal", "", "complete", oldTerminal, oldTerminal); + insertCheckpoint.run(CONNECTION_ID, 2, "abandoned-newer", "", "retrying", abandoned, abandoned); + insertCheckpoint.run( + CONNECTION_ID, 2, "different-target", "recovery-cycle:9", "complete", NOW, NOW, + ); + + const insertRun = database.prepare(` + INSERT INTO whoop_sync_runs ( + run_id, whoop_user_id, connection_id, reconcile_generation, trigger, status, + expected_target_count, completed_target_count, page_count, record_count, started_at + ) VALUES (?, 42, ?, ?, 'scheduled', ?, 6, 0, 0, 0, ?) + `); + insertRun.run("useful-terminal", CONNECTION_ID, 1, "complete", oldTerminal); + insertRun.run("abandoned-newer", CONNECTION_ID, 2, "running", abandoned); + + await expect(repository.pruneOperationalData(NOW)).resolves.toEqual(expect.objectContaining({ + checkpoints: 1, + runs: 1, + })); + expect(database.prepare("SELECT sync_run_id FROM whoop_sync_checkpoints ORDER BY sync_run_id").all()) + .toEqual([{ sync_run_id: "different-target" }, { sync_run_id: "useful-terminal" }]); + expect(database.prepare("SELECT run_id FROM whoop_sync_runs").all()) + .toEqual([{ run_id: "useful-terminal" }]); + }); + + it("prunes terminal history through repeated bounded sweeps within its projection partition", async () => { + const old = "2026-06-01T00:00:00.000Z"; + const newest = "2026-08-19T12:00:00.000Z"; + const insertCheckpoint = database.prepare(` + INSERT INTO whoop_sync_checkpoints ( + whoop_user_id, connection_id, resource, mode, reconcile_generation, + sync_run_id, target_id, status, page_count, record_count, created_at, updated_at + ) VALUES (42, ?, ?, 'reconcile', ?, ?, '', 'complete', 0, 0, ?, ?) + `); + for (let index = 0; index < 102; index += 1) { + insertCheckpoint.run( + CONNECTION_ID, "sleep", 1, `old-checkpoint-${index.toString().padStart(3, "0")}`, old, old, + ); + } + insertCheckpoint.run(CONNECTION_ID, "sleep", 2, "newest-checkpoint", newest, newest); + insertCheckpoint.run(CONNECTION_ID, "workout", 1, "different-resource", old, old); + insertCheckpoint.run("old-connection", "sleep", 1, "different-lifecycle", old, old); + + const insertRun = database.prepare(` + INSERT INTO whoop_sync_runs ( + run_id, whoop_user_id, connection_id, reconcile_generation, trigger, status, + expected_target_count, completed_target_count, page_count, record_count, started_at + ) VALUES (?, 42, ?, ?, 'scheduled', 'complete', 6, 6, 0, 0, ?) + `); + for (let index = 0; index < 102; index += 1) { + insertRun.run(`old-run-${index.toString().padStart(3, "0")}`, CONNECTION_ID, 1, old); + } + insertRun.run("newest-run", CONNECTION_ID, 2, newest); + insertRun.run("different-lifecycle", "old-connection", 1, old); + + await expect(repository.pruneOperationalData(NOW)).resolves.toEqual(expect.objectContaining({ + checkpoints: 100, + runs: 100, + })); + await expect(repository.pruneOperationalData(NOW)).resolves.toEqual(expect.objectContaining({ + checkpoints: 2, + runs: 2, + })); + await expect(repository.pruneOperationalData(NOW)).resolves.toEqual(expect.objectContaining({ + checkpoints: 0, + runs: 0, + })); + + expect(database.prepare("SELECT sync_run_id FROM whoop_sync_checkpoints ORDER BY sync_run_id").all()) + .toEqual([ + { sync_run_id: "different-lifecycle" }, + { sync_run_id: "different-resource" }, + { sync_run_id: "newest-checkpoint" }, + ]); + expect(database.prepare("SELECT run_id FROM whoop_sync_runs ORDER BY run_id").all()) + .toEqual([{ run_id: "different-lifecycle" }, { run_id: "newest-run" }]); + }); +}); diff --git a/src/__tests__/whoop/repository.test.ts b/src/__tests__/whoop/repository.test.ts new file mode 100644 index 0000000..0239e30 --- /dev/null +++ b/src/__tests__/whoop/repository.test.ts @@ -0,0 +1,1391 @@ +import { describe, expect, it, vi } from "vitest"; +import { + WhoopClient, + WhoopRefreshDefiniteError, + WhoopRequestError, + WhoopUnauthorizedError, +} from "../../services/whoop/client"; +import { encryptWhoopToken } from "../../services/whoop/crypto"; +import { + WhoopRepository, + withWhoopAccessToken, +} from "../../services/whoop/repository"; +import { ENV, jsonResponse, KEY, NOW, WORKOUT } from "./fixtures"; + +type DbRow = Record; +type SqlCall = { sql: string; bindings: unknown[] }; + +const TABLE_KEYS: Record = { + whoop_profiles: "whoop_user_id", + whoop_body_measurements: "whoop_user_id", + whoop_cycles: "cycle_id", + whoop_recoveries: "sleep_id", + whoop_sleeps: "sleep_id", + whoop_workouts: "workout_id", +}; + +class FakeD1 { + readonly calls: SqlCall[] = []; + readonly sourceRows = new Map(); + readonly connections = new Map(); + readonly oauthStates = new Map(); + readonly webhookEvents = new Map(); + pendingInitialBackfills: DbRow[] = []; + onLeaseAcquired?: () => void | Promise; + onStoreRotated?: () => void | Promise; + onQuarantine?: () => void | Promise; + + prepare(sql: string) { + return { + bind: (...bindings: unknown[]) => this.bound(sql, bindings), + first: () => this.first(sql, []), + all: () => this.all(sql, []), + run: () => this.run(sql, []), + }; + } + + executedSql(): string { + return this.calls.map(({ sql }) => sql).join("\n"); + } + + sourceRow(table: string, id: string | number): DbRow | undefined { + return this.sourceRows.get(`${table}:${id}`); + } + + private bound(sql: string, bindings: unknown[]) { + return { + first: () => this.first(sql, bindings), + all: () => this.all(sql, bindings), + run: () => this.run(sql, bindings), + }; + } + + private record(sql: string, bindings: unknown[]) { + this.calls.push({ sql, bindings }); + } + + private async first(sql: string, bindings: unknown[]): Promise { + this.record(sql, bindings); + if (sql.includes("FROM whoop_connections")) { + const row = this.connections.get(Number(bindings[0])); + if (!row) return null; + if (sql.includes("SELECT 1 AS current")) { + return row.connection_id === bindings[1] + && ["active", "backfilling"].includes(String(row.status)) + ? { current: 1 } as T + : null; + } + if (sql.includes("refresh_lease_id = ?")) { + const [, credentialVersion, leaseId, now] = bindings; + if (!["active", "backfilling"].includes(String(row.status)) + || row.credential_version !== credentialVersion + || row.refresh_lease_id !== leaseId + || String(row.refresh_lease_expires_at) <= String(now)) return null; + } + return row as T; + } + return null; + } + + private async all(sql: string, bindings: unknown[]) { + this.record(sql, bindings); + if (sql.includes("initial_backfill_pending = 1")) { + return { results: this.pendingInitialBackfills as T[], success: true, meta: {} }; + } + return { results: [] as T[], success: true, meta: {} }; + } + + private async run(sql: string, bindings: unknown[]) { + this.record(sql, bindings); + const normalized = sql.replace(/\s+/g, " ").trim(); + + if (normalized.startsWith("UPDATE whoop_oauth_states")) { + const [consumedAt, stateHash, now] = bindings as [string, string, string]; + const row = this.oauthStates.get(stateHash); + if (!row || row.consumed_at !== null || String(row.expires_at) <= now) return result(0); + row.consumed_at = consumedAt; + return result(1); + } + + if (normalized.startsWith("UPDATE whoop_connections SET refresh_lease_id = ?")) { + const [leaseId, expiresAt, whoopUserId, credentialVersion, now] = bindings as [string, string, number, number, string]; + const row = this.connections.get(whoopUserId); + if (!row || !["active", "backfilling"].includes(String(row.status)) + || row.credential_version !== credentialVersion + || row.refresh_dispatched_at !== null + || (row.refresh_lease_id !== null && String(row.refresh_lease_expires_at) > now)) return result(0); + row.refresh_lease_id = leaseId; + row.refresh_lease_expires_at = expiresAt; + await this.onLeaseAcquired?.(); + return result(1); + } + + if (normalized.startsWith("UPDATE whoop_connections SET refresh_dispatched_at = ?")) { + const [dispatchedAt, whoopUserId, leaseId, credentialVersion, now] = bindings; + const row = this.connections.get(Number(whoopUserId)); + if (!row || !["active", "backfilling"].includes(String(row.status)) + || row.refresh_lease_id !== leaseId + || row.credential_version !== credentialVersion + || row.refresh_dispatched_at !== null + || String(row.refresh_lease_expires_at) <= String(now)) return result(0); + row.refresh_dispatched_at = dispatchedAt; + return result(1); + } + + if (normalized.startsWith("UPDATE whoop_connections SET access_token_ciphertext = ?")) { + const [accessCiphertext, accessNonce, accessExpiresAt, refreshCiphertext, refreshNonce, + grantedScopes, refreshedAt, updatedAt, whoopUserId, leaseId, credentialVersion] = bindings; + const row = this.connections.get(Number(whoopUserId)); + await this.onStoreRotated?.(); + if (!row || row.refresh_lease_id !== leaseId || row.credential_version !== credentialVersion + || row.refresh_dispatched_at === null) return result(0); + Object.assign(row, { + access_token_ciphertext: accessCiphertext, + access_token_nonce: accessNonce, + access_token_expires_at: accessExpiresAt, + refresh_token_ciphertext: refreshCiphertext, + refresh_token_nonce: refreshNonce, + granted_scopes: grantedScopes, + refreshed_at: refreshedAt, + updated_at: updatedAt, + refresh_lease_id: null, + refresh_lease_expires_at: null, + refresh_dispatched_at: null, + credential_version: Number(credentialVersion) + 1, + }); + return result(1); + } + + if (normalized.startsWith("UPDATE whoop_connections SET refresh_dispatched_at = NULL")) { + const [updatedAt, whoopUserId, leaseId, credentialVersion] = bindings; + const row = this.connections.get(Number(whoopUserId)); + if (!row || row.refresh_lease_id !== leaseId || row.credential_version !== credentialVersion + || row.refresh_dispatched_at === null) return result(0); + Object.assign(row, { + refresh_dispatched_at: null, + refresh_lease_id: null, + refresh_lease_expires_at: null, + updated_at: updatedAt, + }); + return result(1); + } + + if (normalized.startsWith("UPDATE whoop_connections SET refresh_lease_id = NULL")) { + const [updatedAt, whoopUserId, leaseId, credentialVersion] = bindings; + const row = this.connections.get(Number(whoopUserId)); + if (!row || row.refresh_lease_id !== leaseId || row.credential_version !== credentialVersion + || row.refresh_dispatched_at !== null) return result(0); + row.refresh_lease_id = null; + row.refresh_lease_expires_at = null; + row.updated_at = updatedAt; + return result(1); + } + + if (normalized.startsWith("UPDATE whoop_connections SET status = 'needs_reauth'") + && normalized.includes("refresh_lease_id = NULL")) { + const [lastErrorAt, updatedAt, whoopUserId, leaseId, credentialVersion] = bindings; + const row = this.connections.get(Number(whoopUserId)); + await this.onQuarantine?.(); + if (!row || row.refresh_lease_id !== leaseId || row.credential_version !== credentialVersion) return result(0); + Object.assign(row, { + status: "needs_reauth", + last_error_at: lastErrorAt, + updated_at: updatedAt, + last_error: "WHOOP token refresh outcome is unknown", + refresh_lease_id: null, + refresh_lease_expires_at: null, + }); + return result(1); + } + + if (normalized.startsWith("UPDATE whoop_connections SET status = 'needs_reauth'")) { + const [lastErrorAt, updatedAt, whoopUserId, credentialVersion] = bindings; + const row = this.connections.get(Number(whoopUserId)); + if (!row || row.credential_version !== credentialVersion) return result(0); + Object.assign(row, { status: "needs_reauth", last_error_at: lastErrorAt, updated_at: updatedAt }); + return result(1); + } + + if (normalized.startsWith("INSERT INTO whoop_connections")) { + const [whoopUserId, connectionId, status, accessCiphertext, accessNonce, accessExpiresAt, + refreshCiphertext, refreshNonce, grantedScopes, connectedAt, createdAt, updatedAt] = bindings; + const current = this.connections.get(Number(whoopUserId)); + this.connections.set(Number(whoopUserId), { + ...current, + whoop_user_id: whoopUserId, + connection_id: connectionId, + status, + access_token_ciphertext: accessCiphertext, + access_token_nonce: accessNonce, + access_token_expires_at: accessExpiresAt, + refresh_token_ciphertext: refreshCiphertext, + refresh_token_nonce: refreshNonce, + granted_scopes: grantedScopes, + refresh_lease_id: null, + refresh_lease_expires_at: null, + refresh_dispatched_at: null, + connected_at: connectedAt, + disconnected_at: null, + last_error: null, + consecutive_failure_count: 0, + credential_version: current ? Number(current.credential_version) + 1 : 1, + created_at: current?.created_at ?? createdAt, + updated_at: updatedAt, + }); + return result(1); + } + + if (normalized.startsWith("UPDATE whoop_webhook_events SET status = 'queued'")) { + const [traceId] = bindings as [string]; + const row = this.webhookEvents.get(traceId); + if (!row || row.status !== "received") return result(0); + row.status = "queued"; + return result(1); + } + + if (normalized.startsWith("INSERT INTO whoop_webhook_events")) { + const columns = insertColumns(sql); + const row = Object.fromEntries(columns.map((column, index) => [column, bindings[index]])); + row.status = "received"; + row.attempts = 0; + const traceId = String(row.trace_id); + if (this.webhookEvents.has(traceId)) return result(0); + this.webhookEvents.set(traceId, row); + return result(1); + } + + const tombstoneMatch = normalized.match(/^UPDATE (whoop_\w+) SET deleted_at = \?, synced_at = \? WHERE (\w+) = \?$/); + if (tombstoneMatch) { + const [, table, keyColumn] = tombstoneMatch; + const [deletedAt, syncedAt, id] = bindings; + const mapKey = `${table}:${id}`; + const row = this.sourceRows.get(mapKey) ?? { [keyColumn]: id }; + if (!this.sourceRows.has(mapKey)) return result(0); + Object.assign(row, { deleted_at: deletedAt, synced_at: syncedAt }); + this.sourceRows.set(mapKey, row); + return result(1); + } + + const insertMatch = normalized.match(/^INSERT INTO (whoop_\w+) \(/); + if (insertMatch && TABLE_KEYS[insertMatch[1]]) { + const table = insertMatch[1]; + const columns = insertColumns(sql); + let bindingIndex = 0; + const incoming: DbRow = {}; + for (const column of columns) { + if (column === "deleted_at" && normalized.includes("SELECT MAX(received_at)")) { + const [whoopUserId, connectionId, resourceId, eventType] = bindings.slice(bindingIndex, bindingIndex + 4); + bindingIndex += 4; + const deletedAt = [...this.webhookEvents.values()] + .filter((event) => event.whoop_user_id === whoopUserId + && event.connection_id === connectionId + && event.resource_id === String(resourceId) + && event.event_type === eventType) + .map((event) => String(event.received_at)) + .sort() + .at(-1); + incoming[column] = deletedAt ?? null; + } else { + incoming[column] = bindings[bindingIndex++]; + } + } + const mapKey = `${table}:${incoming[TABLE_KEYS[table]]}`; + const current = this.sourceRows.get(mapKey); + if (current && String(incoming.upstream_updated_at ?? "") < String(current.upstream_updated_at ?? "")) { + return result(0); + } + if (current + && normalized.includes(`${table}.deleted_at`) + && (normalized.includes("deleted_at = CASE") + || normalized.includes(`deleted_at = ${table}.deleted_at`))) { + incoming.deleted_at = [current.deleted_at, incoming.deleted_at] + .filter((value): value is string => typeof value === "string") + .sort() + .at(-1) ?? null; + } + this.sourceRows.set(mapKey, { ...current, ...incoming }); + return result(1); + } + + return result(1); + } +} + +const result = (changes: number) => ({ success: true, results: [], meta: { changes } }); + +class ClaimD1 { + readonly calls: SqlCall[] = []; + readonly connections = new Map(); + + prepare(sql: string) { + return { + bind: (...bindings: unknown[]) => ({ + first: () => this.claim(sql, bindings), + }), + }; + } + + private async claim(sql: string, bindings: unknown[]): Promise { + this.calls.push({ sql, bindings }); + const whoopUserId = Number(bindings[0]); + const existingDifferentIdentity = [...this.connections.values()].some((connection) => + connection.whoop_user_id !== whoopUserId && connection.status !== "disconnected"); + if (existingDifferentIdentity) return null; + const current = this.connections.get(whoopUserId); + const credentialVersion = Number(current?.credential_version ?? 0) + 1; + this.connections.set(whoopUserId, { + whoop_user_id: whoopUserId, + connection_id: bindings[1], + status: bindings[2], + credential_version: credentialVersion, + }); + return { credential_version: credentialVersion } as T; + } +} + +class CasD1 { + readonly calls: SqlCall[] = []; + batchStatements: Array<{ sql: string; bindings: unknown[] }> = []; + connectionDeleteChanges = 1; + + prepare(sql: string) { + return { + bind: (...bindings: unknown[]) => ({ + sql, + bindings, + run: async () => { + this.calls.push({ sql, bindings }); + return result(this.connectionDeleteChanges); + }, + }), + }; + } + + async batch(statements: D1PreparedStatement[]) { + this.batchStatements = statements as unknown as Array<{ sql: string; bindings: unknown[] }>; + return this.batchStatements.map((statement, index) => result( + index === this.batchStatements.length - 1 ? this.connectionDeleteChanges : 1, + )); + } +} + +function insertColumns(sql: string): string[] { + const match = sql.match(/\(([^)]+)\)\s*(?:VALUES|SELECT)/i); + if (!match) throw new Error("Test fake could not parse INSERT columns"); + return match[1].split(",").map((column) => column.trim()); +} + +async function connectionRow(overrides: DbRow = {}): Promise { + const access = await encryptWhoopToken(KEY, 42, "access", "access-before-refresh"); + const refresh = await encryptWhoopToken(KEY, 42, "refresh", "refresh-before-lease"); + return { + whoop_user_id: 42, + connection_id: "connection-1", + status: "active", + access_token_ciphertext: access.ciphertext, + access_token_nonce: access.nonce, + access_token_expires_at: "2026-08-19T13:00:00.000Z", + refresh_token_ciphertext: refresh.ciphertext, + refresh_token_nonce: refresh.nonce, + granted_scopes: "offline read:workout", + credential_version: 1, + reconcile_generation: 9, + refresh_lease_id: null, + refresh_lease_expires_at: null, + refresh_dispatched_at: null, + connected_at: NOW, + refreshed_at: null, + last_success_at: null, + last_error_at: null, + disconnected_at: null, + last_error: null, + consecutive_failure_count: 0, + created_at: NOW, + updated_at: NOW, + ...overrides, + }; +} + +async function reconnectInput(accessPlaintext = "reconnected-access") { + const accessToken = await encryptWhoopToken(KEY, 42, "access", accessPlaintext); + const refreshToken = await encryptWhoopToken(KEY, 42, "refresh", "reconnected-refresh"); + return { + whoopUserId: 42, + connectionId: "connection-2", + status: "active" as const, + accessToken, + accessTokenExpiresAt: "2026-08-19T14:00:00.000Z", + refreshToken, + grantedScopes: ["offline", "read:workout"], + connectedAt: "2026-08-19T12:00:01.000Z", + }; +} + +describe("WHOOP repository", () => { + it("atomically claims one non-disconnected WHOOP identity while allowing that identity to reconnect", async () => { + const fake = new ClaimD1(); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const first = { ...(await reconnectInput()), initialBackfillPending: true }; + const secondAccess = await encryptWhoopToken(KEY, 43, "access", "other-access"); + const secondRefresh = await encryptWhoopToken(KEY, 43, "refresh", "other-refresh"); + const second = { + ...first, + whoopUserId: 43, + accessToken: secondAccess, + refreshToken: secondRefresh, + }; + + const claims = await Promise.all([ + repository.claimAndUpsertConnection(first), + repository.claimAndUpsertConnection(second), + ]); + + expect(claims).toEqual([1, null]); + await expect(repository.claimAndUpsertConnection(first)).resolves.toBe(2); + expect(fake.connections.size).toBe(1); + expect(fake.calls[0].sql).toContain("INSERT INTO whoop_connections"); + expect(fake.calls[0].sql).toContain("WHERE NOT EXISTS"); + expect(fake.calls[0].sql).toContain("status != 'disconnected'"); + expect(fake.calls[0].sql).toContain("reconcile_generation = 0"); + }); + + it("projects durable initial-backfill intent for future queue or scheduler replay", async () => { + const fake = new FakeD1(); + fake.pendingInitialBackfills = [{ + whoop_user_id: 42, + connection_id: "connection-3", + credential_version: 3, + }]; + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await expect(repository.getPendingInitialBackfills()).resolves.toEqual([ + { whoopUserId: 42, connectionId: "connection-3", credentialVersion: 3 }, + ]); + expect(fake.calls[0].sql).toContain("initial_backfill_pending = 1"); + expect(fake.calls[0].sql).toContain("status = 'backfilling'"); + }); + + it("guards queue work by stable connection identity and live status", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow({ connection_id: "connection-current" })); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await expect(repository.isSyncConnectionCurrent(42, "connection-current")).resolves.toBe(true); + await expect(repository.isSyncConnectionCurrent(42, "connection-stale")).resolves.toBe(false); + fake.connections.get(42)!.status = "disconnected"; + await expect(repository.isSyncConnectionCurrent(42, "connection-current")).resolves.toBe(false); + + expect(fake.calls[0].sql).toContain("connection_id = ?"); + expect(fake.calls[0].sql).toContain("status IN ('active', 'backfilling')"); + }); + + it("activates only six-resource current-connection backfills after durable publication intent clears", async () => { + const fake = new CasD1(); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await expect(repository.activateCompletedBackfill(42, "connection-current", NOW)).resolves.toBe(true); + + expect(fake.calls[0].bindings).toEqual([ + NOW, + NOW, + 42, + "connection-current", + 42, + "connection-current", + ]); + expect(fake.calls[0].sql).toContain("initial_backfill_pending = 0"); + expect(fake.calls[0].sql).toContain("COUNT(DISTINCT resource)"); + expect(fake.calls[0].sql).toContain("mode = 'backfill' AND reconcile_generation = 0"); + expect(fake.calls[0].sql).toContain("target_id = '' AND status = 'complete'"); + expect(fake.calls[0].sql).not.toContain("initial_backfill_pending = 1"); + }); + + it("uses observed credential generation to fence disconnect and every atomic local-data delete", async () => { + const fake = new CasD1(); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await expect(repository.disconnect(42, 3, NOW)).resolves.toBe(true); + await expect(repository.deleteLocalData(42, 3)).resolves.toBe(true); + + expect(fake.calls[0].sql).toContain("credential_version = ?"); + expect(fake.calls[0].bindings).toEqual([NOW, NOW, 42, 3]); + expect(fake.batchStatements).toHaveLength(12); + for (const statement of fake.batchStatements) { + expect(statement.sql).toContain("credential_version = ?"); + expect(statement.sql).toContain("status = 'disconnected'"); + } + }); + + it("returns false when the atomic local-data delete loses its disconnected generation", async () => { + const fake = new CasD1(); + fake.connectionDeleteChanges = 0; + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await expect(repository.deleteLocalData(42, 3)).resolves.toBe(false); + }); + + it("conditionally consumes each unexpired OAuth state only once", async () => { + const fake = new FakeD1(); + fake.oauthStates.set("state-hash", { consumed_at: null, expires_at: "2026-08-19T12:05:00.000Z" }); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await expect(repository.consumeOAuthState("state-hash", NOW)).resolves.toBe(true); + await expect(repository.consumeOAuthState("state-hash", NOW)).resolves.toBe(false); + + expect(fake.executedSql()).toContain("consumed_at IS NULL AND expires_at > ?"); + expect(fake.calls[0].bindings).toEqual([NOW, "state-hash", NOW]); + }); + + it("canonicalizes OAuth consumption once for both storage and expiry comparison", async () => { + const fake = new FakeD1(); + fake.oauthStates.set("offset-state", { + consumed_at: null, + expires_at: "2026-08-19T12:00:00.500Z", + }); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await expect(repository.consumeOAuthState( + "offset-state", + "2026-08-19T08:00:00.25-04:00", + )).resolves.toBe(true); + + expect(fake.oauthStates.get("offset-state")?.consumed_at).toBe("2026-08-19T12:00:00.250Z"); + expect(fake.calls[0].bindings).toEqual([ + "2026-08-19T12:00:00.250Z", + "offset-state", + "2026-08-19T12:00:00.250Z", + ]); + }); + + it("does not overwrite a newer source record with an older update", async () => { + const fake = new FakeD1(); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await repository.upsertSourceRecord("workout", { + ...WORKOUT, + updated_at: "2026-08-19T10:00:00.000Z", + }, { tombstonePolicy: "reconcile" }); + await repository.upsertSourceRecord("workout", { + ...WORKOUT, + updated_at: "2026-08-19T09:00:00.000Z", + score: { strain: 1 }, + }, { tombstonePolicy: "reconcile" }); + + expect(fake.executedSql()).toContain("WHERE excluded.upstream_updated_at >= whoop_workouts.upstream_updated_at"); + expect(fake.sourceRow("whoop_workouts", WORKOUT.id)?.upstream_updated_at) + .toBe("2026-08-19T10:00:00.000Z"); + }); + + it("canonicalizes equal upstream instants before deterministic ordering", async () => { + const fake = new FakeD1(); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await repository.upsertSourceRecord("workout", { + ...WORKOUT, + created_at: "2026-08-19T06:00:00-04:00", + updated_at: "2026-08-19T10:00:00Z", + score: { strain: 1 }, + }, { tombstonePolicy: "reconcile" }); + await repository.upsertSourceRecord("workout", { + ...WORKOUT, + created_at: "2026-08-19T10:00:00.0000Z", + updated_at: "2026-08-19T06:00:00.000-04:00", + score: { strain: 2 }, + }, { tombstonePolicy: "reconcile" }); + + expect(fake.sourceRow("whoop_workouts", WORKOUT.id)).toMatchObject({ + upstream_created_at: "2026-08-19T10:00:00.000Z", + upstream_updated_at: "2026-08-19T10:00:00.000Z", + strain: 2, + }); + }); + + it("does not let an older instant overwrite a newer instant through offset formatting", async () => { + const fake = new FakeD1(); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await repository.upsertSourceRecord("workout", { + ...WORKOUT, + updated_at: "2026-08-19T10:00:00.000Z", + score: { strain: 5 }, + }, { tombstonePolicy: "reconcile" }); + await repository.upsertSourceRecord("workout", { + ...WORKOUT, + updated_at: "2026-08-19T05:30:00-04:00", + score: { strain: 1 }, + }, { tombstonePolicy: "reconcile" }); + + expect(fake.sourceRow("whoop_workouts", WORKOUT.id)).toMatchObject({ + upstream_updated_at: "2026-08-19T10:00:00.000Z", + strain: 5, + }); + }); + + it("preserves a webhook tombstone until authoritative reconciliation", async () => { + const fake = new FakeD1(); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await repository.upsertSourceRecord("workout", WORKOUT, { tombstonePolicy: "preserve" }); + await repository.tombstoneSourceRecord("workout", WORKOUT.id, NOW); + await repository.upsertSourceRecord("workout", WORKOUT, { tombstonePolicy: "preserve" }); + expect(fake.sourceRow("whoop_workouts", WORKOUT.id)).toMatchObject({ deleted_at: NOW }); + + await repository.upsertSourceRecord("workout", WORKOUT, { tombstonePolicy: "reconcile" }); + expect(fake.sourceRow("whoop_workouts", WORKOUT.id)).toMatchObject({ deleted_at: null }); + }); + + it("uses a durable delete event to tombstone a record first seen after the webhook", async () => { + const fake = new FakeD1(); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + await repository.recordWebhookEvent({ + traceId: "delete-before-backfill", + whoopUserId: 42, + connectionId: "connection-1", + resourceId: WORKOUT.id, + eventType: "workout.deleted", + receivedAt: "2026-08-19T08:00:00-04:00", + }); + + await repository.tombstoneSourceRecord("workout", WORKOUT.id, NOW); + expect(fake.sourceRow("whoop_workouts", WORKOUT.id)).toBeUndefined(); + + await repository.upsertSourceRecord("workout", WORKOUT, { + tombstonePolicy: "preserve", + connectionId: "connection-1", + }); + expect(fake.sourceRow("whoop_workouts", WORKOUT.id)).toMatchObject({ + deleted_at: "2026-08-19T12:00:00.000Z", + }); + + await repository.tombstoneSourceRecord("workout", WORKOUT.id, "2026-08-19T13:00:00Z"); + await repository.upsertSourceRecord("workout", WORKOUT, { tombstonePolicy: "preserve" }); + expect(fake.sourceRow("whoop_workouts", WORKOUT.id)).toMatchObject({ + deleted_at: "2026-08-19T13:00:00.000Z", + }); + + await repository.upsertSourceRecord("workout", WORKOUT, { tombstonePolicy: "reconcile" }); + expect(fake.sourceRow("whoop_workouts", WORKOUT.id)).toMatchObject({ deleted_at: null }); + }); + + it("rejects an unknown tombstone policy instead of silently clearing a tombstone", async () => { + const fake = new FakeD1(); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await expect(repository.upsertSourceRecord("workout", WORKOUT, { + tombstonePolicy: "unexpected" as "preserve", + })).rejects.toThrow("Invalid WHOOP tombstone policy"); + + expect(fake.calls).toHaveLength(0); + }); + + it("allows only one refresh lease owner and gives the lease exactly 30 seconds", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow()); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await expect(repository.acquireRefreshLease(42, "lease-a", NOW, 1)).resolves.toBe(true); + await expect(repository.acquireRefreshLease(42, "lease-b", NOW, 1)).resolves.toBe(false); + + expect(fake.connections.get(42)).toMatchObject({ + refresh_lease_id: "lease-a", + refresh_lease_expires_at: "2026-08-19T12:00:30.000Z", + }); + }); + + it("allows an expired lease to be taken over within the same credential generation", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow({ + refresh_lease_id: "expired-owner", + refresh_lease_expires_at: "2026-08-19T11:59:59.000Z", + })); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await expect(repository.acquireRefreshLease(42, "new-owner", NOW, 1)).resolves.toBe(true); + expect(fake.connections.get(42)).toMatchObject({ + refresh_lease_id: "new-owner", + credential_version: 1, + }); + }); + + it("canonicalizes lease time once before expiry calculation and comparison", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow({ + refresh_lease_id: "fractional-owner", + refresh_lease_expires_at: "2026-08-19T12:00:00.200Z", + })); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await expect(repository.acquireRefreshLease( + 42, + "canonical-owner", + "2026-08-19T08:00:00.25-04:00", + 1, + )).resolves.toBe(true); + + expect(fake.connections.get(42)).toMatchObject({ + refresh_lease_id: "canonical-owner", + refresh_lease_expires_at: "2026-08-19T12:00:30.250Z", + }); + expect(fake.calls[0].bindings).toEqual([ + "canonical-owner", + "2026-08-19T12:00:30.250Z", + 42, + 1, + "2026-08-19T12:00:00.250Z", + ]); + }); + + it("canonicalizes sync-run, checkpoint-window, and checkpoint-audit timestamps", async () => { + const fake = new FakeD1(); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await repository.createSyncRun({ + runId: "run-1", + whoopUserId: 42, + connectionId: "connection-1", + reconcileGeneration: 1, + trigger: "manual", + expectedTargetCount: 6, + startedAt: "2026-08-19T08:00:00.25-04:00", + }); + await repository.upsertCheckpoint({ + whoopUserId: 42, + connectionId: "connection-1", + reconcileGeneration: 1, + resource: "workout", + mode: "reconcile", + syncRunId: "reconcile-1", + targetId: "", + windowStart: "2026-08-18T08:00:00-04:00", + windowEnd: "2026-08-19T08:00:00.5-04:00", + status: "running", + pageCount: 1, + recordCount: 25, + createdAt: "2026-08-19T08:00:00.25-04:00", + updatedAt: "2026-08-19T08:01:00.1250-04:00", + }); + + expect(fake.calls[0].bindings[6]).toBe("2026-08-19T12:00:00.250Z"); + expect(fake.calls[1].bindings.slice(7, 9)).toEqual([ + "2026-08-18T12:00:00.000Z", + "2026-08-19T12:00:00.500Z", + ]); + expect(fake.calls[1].bindings.slice(13, 15)).toEqual([ + "2026-08-19T12:00:00.250Z", + "2026-08-19T12:01:00.125Z", + ]); + }); + + it("deduplicates webhook receipt and only moves received events to queued", async () => { + const fake = new FakeD1(); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const event = { + traceId: "trace-1", + whoopUserId: 42, + connectionId: "connection-1", + resourceId: WORKOUT.id, + eventType: "workout.updated" as const, + receivedAt: NOW, + }; + + await expect(repository.recordWebhookEvent(event)).resolves.toBe(true); + await expect(repository.recordWebhookEvent(event)).resolves.toBe(false); + await expect(repository.markWebhookQueued(event.traceId, 42, "connection-1")).resolves.toBe(true); + await expect(repository.markWebhookQueued(event.traceId, 42, "connection-1")).resolves.toBe(false); + expect(fake.webhookEvents.get(event.traceId)?.status).toBe("queued"); + const insert = fake.calls.find(({ sql }) => sql.includes("whoop_webhook_events") && sql.includes("INSERT")); + expect(insert?.sql).toContain("ON CONFLICT(trace_id) DO NOTHING"); + expect(insert?.sql).not.toContain("INSERT OR IGNORE"); + }); + + it("returns a virtual missing status and projects no token, nonce, lease, or raw columns", async () => { + const fake = new FakeD1(); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await expect(repository.getConnectionStatus(42)).resolves.toEqual({ status: "not_connected" }); + + const select = fake.calls[0].sql; + expect(select).not.toMatch(/ciphertext|nonce|refresh_lease|raw_json|SELECT\s+\*/i); + }); + + it("lease owner rereads the refresh token, atomically stores rotation, and retries once", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow()); + const refreshAfterLease = await encryptWhoopToken(KEY, 42, "refresh", "refresh-after-lease"); + fake.onLeaseAcquired = () => { + Object.assign(fake.connections.get(42)!, { + refresh_token_ciphertext: refreshAfterLease.ciphertext, + refresh_token_nonce: refreshAfterLease.nonce, + }); + }; + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const request = vi.fn() + .mockRejectedValueOnce(new WhoopUnauthorizedError("fixture request")) + .mockResolvedValueOnce("ok"); + const refresh = vi.fn().mockResolvedValue({ + access_token: "rotated-access", + refresh_token: "rotated-refresh", + expires_in: 3600, + token_type: "bearer", + scope: "offline read:workout", + }); + + await expect(withWhoopAccessToken(repository, 42, request, refresh, { + now: () => new Date(NOW), + leaseId: () => "lease-owner", + sleep: vi.fn(), + })).resolves.toBe("ok"); + + expect(refresh).toHaveBeenCalledWith("refresh-after-lease", { + signal: expect.any(AbortSignal), + }); + expect(request.mock.calls.map(([token]) => token)).toEqual(["access-before-refresh", "rotated-access"]); + expect(fake.connections.get(42)).toMatchObject({ + refresh_lease_id: null, + refresh_lease_expires_at: null, + refresh_dispatched_at: null, + }); + const dispatch = fake.calls.find(({ sql }) => sql.includes("SET refresh_dispatched_at = ?")); + expect(dispatch?.bindings).toEqual([NOW, 42, "lease-owner", 1, NOW]); + const rotation = fake.calls.find(({ sql }) => sql.includes("SET access_token_ciphertext = ?")); + expect(rotation?.sql).toContain("refresh_dispatched_at = NULL"); + expect(rotation?.sql).toContain("refresh_lease_id = NULL"); + expect(rotation?.sql).toContain("WHERE whoop_user_id = ? AND refresh_lease_id = ? AND credential_version = ?"); + expect(rotation?.bindings.slice(-3)).toEqual([42, "lease-owner", 1]); + expect(fake.connections.get(42)?.credential_version).toBe(2); + expect(fake.connections.get(42)?.reconcile_generation).toBe(9); + }); + + it("proactively refreshes inside the five-minute expiry window but skips a healthy token", async () => { + const nearExpiryFake = new FakeD1(); + nearExpiryFake.connections.set(42, await connectionRow({ + access_token_expires_at: "2026-08-19T12:04:59.999Z", + })); + const nearExpiryRepository = new WhoopRepository(nearExpiryFake as unknown as D1Database, KEY); + const nearExpiryRequest = vi.fn().mockResolvedValue("ok"); + const refresh = vi.fn().mockResolvedValue({ + access_token: "rotated-access", + refresh_token: "rotated-refresh", + expires_in: 3600, + token_type: "bearer", + }); + + await expect(withWhoopAccessToken( + nearExpiryRepository, + 42, + nearExpiryRequest, + refresh, + { + now: () => new Date(NOW), + leaseId: () => "lease-owner", + sleep: vi.fn(), + refreshBeforeExpirationMilliseconds: 5 * 60 * 1000, + } as unknown as Parameters[4], + )).resolves.toBe("ok"); + + expect(refresh).toHaveBeenCalledTimes(1); + expect(nearExpiryRequest.mock.calls.map(([token]) => token)).toEqual(["rotated-access"]); + + const healthyFake = new FakeD1(); + healthyFake.connections.set(42, await connectionRow({ + access_token_expires_at: "2026-08-19T12:05:00.001Z", + })); + const healthyRepository = new WhoopRepository(healthyFake as unknown as D1Database, KEY); + const healthyRequest = vi.fn().mockResolvedValue("healthy"); + const healthyRefresh = vi.fn(); + + await expect(withWhoopAccessToken( + healthyRepository, + 42, + healthyRequest, + healthyRefresh, + { + now: () => new Date(NOW), + leaseId: () => "unused-owner", + sleep: vi.fn(), + refreshBeforeExpirationMilliseconds: 5 * 60 * 1000, + } as unknown as Parameters[4], + )).resolves.toBe("healthy"); + + expect(healthyRefresh).not.toHaveBeenCalled(); + expect(healthyRequest.mock.calls.map(([token]) => token)).toEqual(["access-before-refresh"]); + }); + + it("proactive refresh lease non-owner waits and uses only the rotated access token", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow({ + access_token_expires_at: "2026-08-19T12:04:00.000Z", + refresh_lease_id: "other-owner", + refresh_lease_expires_at: "2026-08-19T12:00:20.000Z", + })); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const afterWait = await encryptWhoopToken(KEY, 42, "access", "access-after-wait"); + const sleep = vi.fn().mockImplementation(async () => { + Object.assign(fake.connections.get(42)!, { + access_token_ciphertext: afterWait.ciphertext, + access_token_nonce: afterWait.nonce, + access_token_expires_at: "2026-08-19T13:00:00.000Z", + credential_version: 2, + refresh_lease_id: null, + refresh_lease_expires_at: null, + }); + }); + const request = vi.fn().mockResolvedValue("ok"); + const refresh = vi.fn(); + + await expect(withWhoopAccessToken(repository, 42, request, refresh, { + now: () => new Date(NOW), + leaseId: () => "losing-owner", + sleep, + refreshBeforeExpirationMilliseconds: 5 * 60 * 1000, + } as unknown as Parameters[4])).resolves.toBe("ok"); + + expect(sleep).toHaveBeenCalledTimes(1); + expect(refresh).not.toHaveBeenCalled(); + expect(request.mock.calls.map(([token]) => token)).toEqual(["access-after-wait"]); + }); + + it("passes the credential generation paired with each initial and refreshed access token", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow()); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const request = vi.fn() + .mockRejectedValueOnce(new WhoopUnauthorizedError("fixture request")) + .mockResolvedValueOnce("revoked"); + const refresh = vi.fn().mockResolvedValue({ + access_token: "rotated-access", + refresh_token: "rotated-refresh", + expires_in: 3600, + token_type: "bearer", + }); + + await expect(withWhoopAccessToken(repository, 42, request, refresh, { + now: () => new Date(NOW), + leaseId: () => "lease-owner", + sleep: vi.fn(), + })).resolves.toBe("revoked"); + + expect(request.mock.calls.map(([, credentialVersion]) => credentialVersion)).toEqual([1, 2]); + }); + + it("clears its dispatch latch and owned lease after an explicit definite refresh failure", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow()); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const request = vi.fn().mockRejectedValue(new WhoopUnauthorizedError("fixture request")); + + const failure = new WhoopRefreshDefiniteError("token refresh", 400); + + await expect(withWhoopAccessToken(repository, 42, request, vi.fn().mockRejectedValue(failure), { + now: () => new Date(NOW), + leaseId: () => "lease-owner", + sleep: vi.fn(), + })).rejects.toBe(failure); + + expect(fake.connections.get(42)).toMatchObject({ + refresh_lease_id: null, + refresh_lease_expires_at: null, + refresh_dispatched_at: null, + credential_version: 1, + }); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("aborts refresh inside the lease and quarantines an ambiguous token generation", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow()); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + let abortedAt: number | undefined; + let signalRefreshDispatched!: () => void; + const refreshDispatched = new Promise((resolve) => { + signalRefreshDispatched = resolve; + }); + const refresh = vi.fn((_token: string, options?: { signal?: AbortSignal }) => { + if (!options?.signal) return Promise.reject(new Error("WHOOP refresh signal is required")); + signalRefreshDispatched(); + return new Promise((_resolve, reject) => { + options.signal!.addEventListener("abort", () => { + abortedAt = Date.now(); + reject(options.signal!.reason); + }, { once: true }); + }); + }); + const request = vi.fn().mockRejectedValue(new WhoopUnauthorizedError("fixture request")); + + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW)); + try { + const pending = withWhoopAccessToken(repository, 42, request, refresh, { + leaseId: () => "lease-owner", + sleep: vi.fn(), + }); + const outcome = expect(pending).rejects.toThrow("WHOOP token refresh outcome is unknown"); + + await refreshDispatched; + await vi.advanceTimersByTimeAsync(19_999); + expect(abortedAt).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + await outcome; + + expect(abortedAt).toBe(Date.parse(NOW) + 20_000); + expect(abortedAt).toBeLessThan(Date.parse(NOW) + 30_000); + expect(fake.connections.get(42)).toMatchObject({ + status: "needs_reauth", + credential_version: 1, + refresh_lease_id: null, + refresh_lease_expires_at: null, + refresh_dispatched_at: NOW, + }); + + const secondRefresh = vi.fn(); + await expect(withWhoopAccessToken( + repository, + 42, + vi.fn().mockRejectedValue(new WhoopUnauthorizedError("fixture request")), + secondRefresh, + { leaseId: () => "must-not-refresh", sleep: vi.fn() }, + )).rejects.toThrow("WHOOP access token refresh is still in progress"); + expect(secondRefresh).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps explicit 429 refresh failures eligible for another lease", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow()); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const retryable = new WhoopRefreshDefiniteError("token refresh", 429, true, 5); + + await expect(withWhoopAccessToken( + repository, + 42, + vi.fn().mockRejectedValue(new WhoopUnauthorizedError("fixture request")), + vi.fn().mockRejectedValue(retryable), + { now: () => new Date(NOW), leaseId: () => "failed-owner", sleep: vi.fn() }, + )).rejects.toBe(retryable); + + expect(fake.connections.get(42)).toMatchObject({ + status: "active", + credential_version: 1, + refresh_lease_id: null, + refresh_dispatched_at: null, + }); + await expect(repository.acquireRefreshLease(42, "retry-owner", NOW, 1)).resolves.toBe(true); + }); + + it("quarantines and retains the dispatch latch when rotated-token encryption fails", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow()); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const request = vi.fn().mockRejectedValue(new WhoopUnauthorizedError("fixture request")); + const refresh = vi.fn().mockResolvedValue({ + access_token: Symbol("invalid-token-input") as unknown as string, + refresh_token: "unused-refresh-value", + expires_in: 3600, + token_type: "bearer", + }); + + await expect(withWhoopAccessToken(repository, 42, request, refresh, { + now: () => new Date(NOW), + leaseId: () => "lease-owner", + sleep: vi.fn(), + })).rejects.toThrow("WHOOP token refresh outcome is unknown"); + + expect(fake.connections.get(42)).toMatchObject({ + status: "needs_reauth", + refresh_lease_id: null, + refresh_lease_expires_at: null, + refresh_dispatched_at: NOW, + credential_version: 1, + }); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("does not submit refresh after reconnect invalidates the acquired lease", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow()); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const reconnect = await reconnectInput(); + fake.onLeaseAcquired = () => repository.upsertConnection(reconnect); + const request = vi.fn().mockRejectedValue(new WhoopUnauthorizedError("fixture request")); + const refresh = vi.fn(); + + await expect(withWhoopAccessToken(repository, 42, request, refresh, { + now: () => new Date(NOW), + leaseId: () => "stale-owner", + sleep: vi.fn(), + })).rejects.toThrow("WHOOP refresh lease ownership was lost before refresh"); + + expect(refresh).not.toHaveBeenCalled(); + expect(fake.connections.get(42)).toMatchObject({ + status: "active", + credential_version: 2, + refresh_lease_id: null, + }); + }); + + it("quarantines and retains the dispatch latch when rotated-token storage throws", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow()); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + fake.onStoreRotated = () => { + throw new Error("fixture D1 write failed"); + }; + const request = vi.fn().mockRejectedValue(new WhoopUnauthorizedError("fixture request")); + const refresh = vi.fn().mockResolvedValue({ + access_token: "must-not-be-used", + refresh_token: "must-not-be-stored", + expires_in: 3600, + token_type: "bearer", + }); + + await expect(withWhoopAccessToken(repository, 42, request, refresh, { + now: () => new Date(NOW), + leaseId: () => "lease-owner", + sleep: vi.fn(), + })).rejects.toThrow("WHOOP token refresh outcome is unknown"); + + expect(refresh).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledTimes(1); + expect(fake.connections.get(42)).toMatchObject({ + status: "needs_reauth", + credential_version: 1, + refresh_lease_id: null, + refresh_dispatched_at: NOW, + }); + }); + + it("does not use rotated credentials and retains the latch after losing store ownership", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow()); + fake.onStoreRotated = () => { + fake.connections.get(42)!.refresh_lease_id = "takeover-owner"; + }; + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const request = vi.fn().mockRejectedValue(new WhoopUnauthorizedError("fixture request")); + const refresh = vi.fn().mockResolvedValue({ + access_token: "must-not-be-used", + refresh_token: "must-not-be-stored", + expires_in: 3600, + token_type: "bearer", + }); + + await expect(withWhoopAccessToken(repository, 42, request, refresh, { + now: () => new Date(NOW), + leaseId: () => "lease-owner", + sleep: vi.fn(), + })).rejects.toThrow("WHOOP token refresh outcome is unknown"); + + expect(refresh).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledTimes(1); + expect(fake.connections.get(42)).toMatchObject({ + status: "active", + credential_version: 1, + refresh_lease_id: "takeover-owner", + refresh_dispatched_at: NOW, + }); + }); + + it("keeps the durable latch when the ambiguous-outcome quarantine write fails", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow()); + fake.onQuarantine = () => { + throw new Error("fixture quarantine write failed"); + }; + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + + await expect(withWhoopAccessToken( + repository, + 42, + vi.fn().mockRejectedValue(new WhoopUnauthorizedError("fixture request")), + vi.fn().mockRejectedValue(new WhoopRequestError("token refresh")), + { now: () => new Date(NOW), leaseId: () => "lease-owner", sleep: vi.fn() }, + )).rejects.toThrow("fixture quarantine write failed"); + + expect(fake.connections.get(42)).toMatchObject({ + status: "active", + credential_version: 1, + refresh_lease_id: "lease-owner", + refresh_dispatched_at: NOW, + }); + await expect(repository.acquireRefreshLease( + 42, + "must-not-take-over", + "2026-08-19T12:00:31.000Z", + 1, + )).resolves.toBe(false); + }); + + it("prevents every later refresh attempt while a dispatch latch remains set", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow({ + refresh_lease_id: "expired-owner", + refresh_lease_expires_at: "2026-08-19T11:59:59.000Z", + refresh_dispatched_at: "2026-08-19T11:59:30.000Z", + })); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const refresh = vi.fn(); + + await expect(withWhoopAccessToken( + repository, + 42, + vi.fn().mockRejectedValue(new WhoopUnauthorizedError("fixture request")), + refresh, + { now: () => new Date(NOW), leaseId: () => "must-not-refresh", sleep: vi.fn() }, + )).rejects.toThrow("WHOOP access token refresh is still in progress"); + + expect(refresh).not.toHaveBeenCalled(); + expect(fake.connections.get(42)?.refresh_dispatched_at).toBe("2026-08-19T11:59:30.000Z"); + }); + + it("composes the repository deadline signal through the real WHOOP refresh client", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow()); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ + access_token: "rotated-access", + refresh_token: "rotated-refresh", + expires_in: 3600, + token_type: "bearer", + })); + const client = new WhoopClient(ENV, "unused-access"); + const request = vi.fn() + .mockRejectedValueOnce(new WhoopUnauthorizedError("fixture request")) + .mockResolvedValueOnce("ok"); + + await expect(withWhoopAccessToken( + repository, + 42, + request, + (token, options) => client.refreshToken(token, options), + { now: () => new Date(NOW), leaseId: () => "lease-owner", sleep: vi.fn() }, + )).resolves.toBe("ok"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.prod.whoop.com/oauth/oauth2/token", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it("non-owner waits, rereads once, and never resubmits its pre-wait access token", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow({ + refresh_lease_id: "other-owner", + refresh_lease_expires_at: "2026-08-19T12:00:20.000Z", + })); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const afterWait = await encryptWhoopToken(KEY, 42, "access", "access-after-wait"); + const sleep = vi.fn().mockImplementation(async () => { + Object.assign(fake.connections.get(42)!, { + access_token_ciphertext: afterWait.ciphertext, + access_token_nonce: afterWait.nonce, + credential_version: 2, + refresh_lease_id: null, + refresh_lease_expires_at: null, + }); + }); + const request = vi.fn() + .mockRejectedValueOnce(new WhoopUnauthorizedError("fixture request")) + .mockResolvedValueOnce("ok"); + const refresh = vi.fn(); + + await expect(withWhoopAccessToken(repository, 42, request, refresh, { + now: () => new Date(NOW), + leaseId: () => "losing-owner", + sleep, + })).resolves.toBe("ok"); + + expect(sleep).toHaveBeenCalledTimes(1); + expect(refresh).not.toHaveBeenCalled(); + expect(request.mock.calls.map(([token]) => token)).toEqual(["access-before-refresh", "access-after-wait"]); + }); + + it("non-owner does not retry when its single reread still contains the pre-wait token", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow({ + refresh_lease_id: "other-owner", + refresh_lease_expires_at: "2026-08-19T12:00:20.000Z", + })); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const request = vi.fn().mockRejectedValue(new WhoopUnauthorizedError("fixture request")); + + await expect(withWhoopAccessToken(repository, 42, request, vi.fn(), { + now: () => new Date(NOW), + leaseId: () => "losing-owner", + sleep: vi.fn(), + })).rejects.toThrow("WHOOP access token refresh is still in progress"); + + expect(request).toHaveBeenCalledTimes(1); + }); + + it("marks the connection needs_reauth after the one retry also returns 401", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow()); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const request = vi.fn().mockRejectedValue(new WhoopUnauthorizedError("fixture request")); + const refresh = vi.fn().mockResolvedValue({ + access_token: "rotated-access", + refresh_token: "rotated-refresh", + expires_in: 3600, + token_type: "bearer", + }); + + await expect(withWhoopAccessToken(repository, 42, request, refresh, { + now: () => new Date(NOW), + leaseId: () => "lease-owner", + sleep: vi.fn(), + })).rejects.toBeInstanceOf(WhoopUnauthorizedError); + + expect(request).toHaveBeenCalledTimes(2); + expect(fake.connections.get(42)?.status).toBe("needs_reauth"); + }); + + it("refuses stale queue work before using a reconnected generation's access token", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow({ connection_id: "new-connection" })); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const request = vi.fn().mockResolvedValue("must-not-run"); + const refresh = vi.fn(); + + await expect(withWhoopAccessToken(repository, 42, request, refresh, { + expectedConnectionId: "stale-connection", + } as unknown as Parameters[4])).rejects.toThrow( + "WHOOP queue connection is stale", + ); + + expect(request).not.toHaveBeenCalled(); + expect(refresh).not.toHaveBeenCalled(); + }); + + it("does not let a stale second 401 poison a reconnected credential generation", async () => { + const fake = new FakeD1(); + fake.connections.set(42, await connectionRow()); + const repository = new WhoopRepository(fake as unknown as D1Database, KEY); + const reconnect = await reconnectInput("new-generation-access"); + let attempts = 0; + const request = vi.fn().mockImplementation(async () => { + attempts += 1; + if (attempts === 2) await repository.upsertConnection(reconnect); + throw new WhoopUnauthorizedError("fixture request"); + }); + const refresh = vi.fn().mockResolvedValue({ + access_token: "rotated-access", + refresh_token: "rotated-refresh", + expires_in: 3600, + token_type: "bearer", + }); + + await expect(withWhoopAccessToken(repository, 42, request, refresh, { + now: () => new Date(NOW), + leaseId: () => "lease-owner", + sleep: vi.fn(), + })).rejects.toBeInstanceOf(WhoopUnauthorizedError); + + expect(fake.connections.get(42)).toMatchObject({ + status: "active", + credential_version: 3, + refresh_lease_id: null, + }); + }); +}); diff --git a/src/__tests__/whoop/schema.test.ts b/src/__tests__/whoop/schema.test.ts new file mode 100644 index 0000000..2202439 --- /dev/null +++ b/src/__tests__/whoop/schema.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vitest"; +import { + whoopBodyMeasurementSchema, + whoopCollectionQuerySchema, + whoopCycleSchema, + whoopProfileSchema, + whoopRecoverySchema, + whoopSleepSchema, + whoopWebhookSchema, + whoopWorkoutSchema, +} from "../../schemas/whoop"; +import { WHOOP_SCOPES } from "../../types/whoop"; +import { + BODY_MEASUREMENT, + CURRENT_CYCLE, + CYCLE, + ENV, + PROFILE, + RECOVERY, + SLEEP, + SLEEP_UPDATED, + WORKOUT, + signedWebhook, +} from "./fixtures"; + +const readProjectFile = async (path: string) => { + // @ts-expect-error The Worker-only typecheck intentionally excludes Node test-runtime declarations. + const { readFile } = await import("node:fs/promises"); + return readFile(path, "utf8"); +}; + +describe("WHOOP shared schemas", () => { + it("accepts the exact OAuth scopes and rejects an added scope", () => { + expect(WHOOP_SCOPES).toEqual([ + "offline", "read:profile", "read:body_measurement", "read:cycles", + "read:recovery", "read:sleep", "read:workout", + ]); + expect(whoopWebhookSchema.safeParse({ + user_id: 42, + id: "f7c85ce7-7e44-4bb4-8cb4-ee5b94b54e1c", + type: "sleep.updated", + trace_id: "7b2dc91e-7423-42b1-a3cb-ecce1a0e2de8", + }).success).toBe(true); + expect(whoopWebhookSchema.safeParse({ + user_id: 42, + id: "x", + type: "sleep.created", + trace_id: "t", + }).success).toBe(false); + }); + + it("rejects an invalid local cursor and a limit above 100", () => { + expect(whoopCollectionQuerySchema.safeParse({ limit: "101" }).success).toBe(false); + expect(whoopCollectionQuerySchema.safeParse({ cursor: "not-base64!" }).success).toBe(false); + expect(whoopCollectionQuerySchema.safeParse({ cursor: "a" }).success).toBe(false); + expect(whoopCollectionQuerySchema.safeParse({ cursor: "MTIzOjEyMzEyMw" }).success).toBe(true); + }); + + it("rejects unknown fields in strict local and webhook envelopes", () => { + expect(whoopCollectionQuerySchema.safeParse({ limit: "25", untrusted: "value" }).success).toBe(false); + expect(whoopWebhookSchema.safeParse({ ...SLEEP_UPDATED, untrusted: "value" }).success).toBe(false); + }); + + it("accepts official-shaped profile and body records without adding connection context", () => { + expect(whoopProfileSchema.safeParse(PROFILE).success).toBe(true); + expect(whoopBodyMeasurementSchema.safeParse(BODY_MEASUREMENT).success).toBe(true); + }); + + it("preserves provider extension fields", () => { + const parsed = whoopBodyMeasurementSchema.parse({ ...BODY_MEASUREMENT, future_metric: 123 }); + expect(parsed.future_metric).toBe(123); + }); + + it("accepts a current cycle with a null end", () => { + expect(whoopCycleSchema.safeParse(CURRENT_CYCLE).success).toBe(true); + }); + + it("accepts official-shaped cycle, sleep, and workout records", () => { + expect(whoopCycleSchema.safeParse(CYCLE).success).toBe(true); + expect(whoopSleepSchema.safeParse(SLEEP).success).toBe(true); + expect(whoopWorkoutSchema.safeParse(WORKOUT).success).toBe(true); + }); + + it("retains every approved WHOOP v2 sleep detail in the typed schema", () => { + const parsed = whoopSleepSchema.parse({ + ...SLEEP, + score: { + stage_summary: { + total_in_bed_time_milli: 28_800_000, + total_awake_time_milli: 1_800_000, + total_no_data_time_milli: 60_000, + total_light_sleep_time_milli: 14_400_000, + total_slow_wave_sleep_time_milli: 6_000_000, + total_rem_sleep_time_milli: 6_540_000, + sleep_cycle_count: 5, + disturbance_count: 9, + }, + sleep_needed: { + baseline_milli: 27_000_000, + need_from_sleep_debt_milli: 900_000, + need_from_recent_strain_milli: 600_000, + need_from_recent_nap_milli: -300_000, + }, + }, + }); + + expect(parsed.score).toEqual(expect.objectContaining({ + stage_summary: expect.objectContaining({ + total_in_bed_time_milli: 28_800_000, + total_no_data_time_milli: 60_000, + sleep_cycle_count: 5, + disturbance_count: 9, + }), + sleep_needed: expect.objectContaining({ + need_from_recent_strain_milli: 600_000, + need_from_recent_nap_milli: -300_000, + }), + })); + expect(whoopSleepSchema.safeParse({ + ...SLEEP, + score: { stage_summary: { sleep_cycle_count: "five" } }, + }).success).toBe(false); + }); + + it("accepts official-shaped recovery records and preserves provider extensions", () => { + const parsed = whoopRecoverySchema.parse({ ...RECOVERY, future_metric: 123 }); + expect(parsed.future_metric).toBe(123); + }); + + it("rejects provider records missing documented core fields", () => { + const { email: _profileEmail, ...profileWithoutEmail } = PROFILE; + const { timezone_offset: _cycleTimezone, ...cycleWithoutTimezone } = CYCLE; + const { cycle_id: _recoveryCycle, ...recoveryWithoutCycle } = RECOVERY; + const { nap: _sleepNap, ...sleepWithoutNap } = SLEEP; + const { sport_name: _workoutSport, ...workoutWithoutSport } = WORKOUT; + + expect(whoopProfileSchema.safeParse(profileWithoutEmail).success).toBe(false); + expect(whoopCycleSchema.safeParse(cycleWithoutTimezone).success).toBe(false); + expect(whoopRecoverySchema.safeParse(recoveryWithoutCycle).success).toBe(false); + expect(whoopSleepSchema.safeParse(sleepWithoutNap).success).toBe(false); + expect(whoopWorkoutSchema.safeParse(workoutWithoutSport).success).toBe(false); + }); + + it("keeps the migration provider-only with nullable current-cycle ends and required checks", async () => { + const migrationSql = await readProjectFile("migrations/0020_whoop.sql"); + const cyclesDefinition = migrationSql.match(/CREATE TABLE IF NOT EXISTS whoop_cycles \(([\s\S]*?)\n\);/)?.[1]; + const webhookDefinition = migrationSql.match(/CREATE TABLE IF NOT EXISTS whoop_webhook_events \(([\s\S]*?)\n\);/)?.[1]; + const connectionDefinition = migrationSql.match(/CREATE TABLE IF NOT EXISTS whoop_connections \(([\s\S]*?)\n\);/)?.[1]; + const seenDefinition = migrationSql.match(/CREATE TABLE IF NOT EXISTS whoop_reconcile_seen \(([\s\S]*?)\n\);/)?.[1]; + const checkpointDefinition = migrationSql.match(/CREATE TABLE IF NOT EXISTS whoop_sync_checkpoints \(([\s\S]*?)\n\);/)?.[1]; + + expect(migrationSql).not.toMatch(/(?:ALTER|CREATE\s+TABLE)[\s\S]*apple_health_/i); + expect(migrationSql).toMatch(/CREATE TABLE IF NOT EXISTS whoop_cycles/); + expect(cyclesDefinition).toMatch(/end_at TEXT,/); + expect(migrationSql).toMatch(/credential_version INTEGER NOT NULL DEFAULT 1/); + expect(connectionDefinition).toMatch(/reconcile_generation INTEGER NOT NULL DEFAULT 0/); + expect(migrationSql).toMatch(/initial_backfill_pending INTEGER NOT NULL DEFAULT 0/); + expect(migrationSql).toMatch(/refresh_dispatched_at TEXT/); + expect(migrationSql).toMatch(/status TEXT NOT NULL CHECK \(status IN/); + expect(migrationSql).toMatch(/event_type TEXT NOT NULL CHECK \(event_type IN/); + expect(webhookDefinition).toMatch(/connection_id TEXT NOT NULL/); + expect(seenDefinition).toMatch(/reconcile_generation INTEGER NOT NULL/); + expect(checkpointDefinition).toMatch(/sync_run_id TEXT NOT NULL/); + expect(checkpointDefinition).toMatch(/target_id TEXT NOT NULL/); + expect(checkpointDefinition).toMatch(/reconcile_generation INTEGER NOT NULL/); + expect(checkpointDefinition).toMatch( + /PRIMARY KEY \(whoop_user_id, connection_id, resource, mode, reconcile_generation, sync_run_id, target_id\)/, + ); + }); + + it("pins the deployment account and configures the serialized WHOOP queue binding", async () => { + const wranglerToml = await readProjectFile("wrangler.toml"); + + expect(wranglerToml).toMatch(/^account_id = "313c4e6e881f1e07c880d7230541200a"$/m); + expect(wranglerToml).toMatch(/^WHOOP_REDIRECT_URI = "https:\/\/api\.anuragd\.me\/integrations\/whoop\/callback"$/m); + expect(wranglerToml).toMatch(/^OS_BASE_URL = "https:\/\/os\.anuragd\.me"$/m); + expect(wranglerToml).not.toMatch(/^WHOOP_(?:CLIENT_ID|CLIENT_SECRET|TOKEN_ENCRYPTION_KEY)\s*=/m); + expect(wranglerToml).toMatch(/\[\[queues\.producers\]\][\s\S]*binding = "WHOOP_SYNC_QUEUE"[\s\S]*queue = "whoop-health-sync"/); + expect(wranglerToml).toMatch(/\[\[queues\.consumers\]\][\s\S]*queue = "whoop-health-sync"[\s\S]*dead_letter_queue = "whoop-health-sync-dlq"[\s\S]*max_batch_size = 1[\s\S]*max_batch_timeout = 1[\s\S]*max_concurrency = 1[\s\S]*max_retries = 5/); + }); + + it("constructs a verifiable WHOOP HMAC over the timestamp and unmodified body", async () => { + const requestInit = await signedWebhook(SLEEP_UPDATED); + const headers = new Headers(requestInit.headers); + const body = requestInit.body as string; + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(ENV.WHOOP_CLIENT_SECRET), + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"], + ); + const signature = Uint8Array.from(atob(headers.get("X-WHOOP-Signature")!), (character) => character.charCodeAt(0)); + + await expect(crypto.subtle.verify( + "HMAC", + key, + signature, + new TextEncoder().encode(headers.get("X-WHOOP-Signature-Timestamp")! + body), + )).resolves.toBe(true); + }); +}); diff --git a/src/__tests__/whoop/sync.test.ts b/src/__tests__/whoop/sync.test.ts new file mode 100644 index 0000000..c599199 --- /dev/null +++ b/src/__tests__/whoop/sync.test.ts @@ -0,0 +1,895 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import worker from "../../index"; +import { WhoopRequestError } from "../../services/whoop/client"; +import { + enqueueReconciliation, + handleWhoopQueue, + processWebhook, + type WhoopSyncDependencies, +} from "../../services/whoop/sync"; +import type { Env } from "../../types/env"; +import type { WhoopQueueMessage } from "../../types/whoop"; +import { + BODY_MEASUREMENT, + CONNECTION_ID, + ENV, + NOW, + PROFILE, + RECONCILE_RUN_ID, + RECOVERY, + SLEEP, + WORKOUT, + batchOf, +} from "./fixtures"; + +const createHarness = () => { + const repository = { + upsertSourceRecord: vi.fn().mockResolvedValue(undefined), + tombstoneSourceRecord: vi.fn().mockResolvedValue(undefined), + upsertCheckpoint: vi.fn().mockResolvedValue(undefined), + recordReconciliationSeen: vi.fn().mockResolvedValue(undefined), + finalizeReconciliation: vi.fn().mockResolvedValue(undefined), + cleanupReconciliationSeen: vi.fn().mockResolvedValue(true), + markWebhookProcessed: vi.fn().mockResolvedValue(undefined), + markWebhookFailed: vi.fn().mockResolvedValue(undefined), + getPendingRecoveryCycleIds: vi.fn().mockResolvedValue([]), + beginReconciliation: vi.fn().mockResolvedValue(7), + getCurrentConnection: vi.fn().mockResolvedValue({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + credentialVersion: 1, + reconcileGeneration: 0, + status: "active", + }), + isSyncConnectionCurrent: vi.fn().mockResolvedValue(true), + isReconciliationCurrent: vi.fn().mockResolvedValue(true), + activateCompletedBackfill: vi.fn().mockResolvedValue(false), + createSyncRun: vi.fn().mockResolvedValue(true), + markSyncRunPublicationFailure: vi.fn().mockResolvedValue(true), + refreshSyncRun: vi.fn().mockResolvedValue(true), + recordSyncSuccess: vi.fn().mockResolvedValue(true), + recordSyncFailure: vi.fn().mockResolvedValue(true), + }; + const client = { + getProfile: vi.fn(), + getBodyMeasurements: vi.fn(), + getCollection: vi.fn(), + getCycle: vi.fn(), + getRecovery: vi.fn(), + getSleep: vi.fn(), + getWorkout: vi.fn(), + }; + const env = { + ...ENV, + WHOOP_SYNC_QUEUE: { + send: vi.fn().mockResolvedValue(undefined), + sendBatch: vi.fn().mockResolvedValue(undefined), + } as unknown as Queue, + } as Env; + const dependencies = { + repository, + client, + now: () => new Date(NOW), + } as unknown as WhoopSyncDependencies; + + return { client, dependencies, env, repository }; +}; + +type QueueMessageInput = WhoopQueueMessage extends infer Message + ? Message extends WhoopQueueMessage + ? Omit & { + connectionId?: string; + reconcileGeneration?: number; + reconcileRunId?: string; + } + : never + : never; + +const batchOfMany = (...bodies: QueueMessageInput[]) => ({ + messages: bodies.map((body, index) => ({ + id: `message-${index}`, + timestamp: new Date(NOW), + attempts: 1, + body: { + connectionId: CONNECTION_ID, + ...(body.kind === "reconcile" + ? { reconcileGeneration: 7, reconcileRunId: RECONCILE_RUN_ID } + : {}), + ...body, + }, + ack: vi.fn(), + retry: vi.fn(), + })), +}) as unknown as MessageBatch; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("WHOOP queue synchronization", () => { + it("creates a queued lifecycle-fenced run before publishing reconciliation work", async () => { + const { dependencies, env, repository } = createHarness(); + + await enqueueReconciliation(env, 42, "manual", { + repository: repository as never, + now: () => new Date(NOW), + expectedConnectionId: CONNECTION_ID, + requireActiveConnection: true, + }); + + expect(repository.createSyncRun).toHaveBeenCalledWith(expect.objectContaining({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: 7, + trigger: "manual", + expectedTargetCount: 6, + startedAt: NOW, + })); + expect(repository.createSyncRun.mock.invocationCallOrder[0]) + .toBeLessThan((env.WHOOP_SYNC_QUEUE.sendBatch as ReturnType).mock.invocationCallOrder[0]); + }); + + it("records reconciliation publication failure on the run and connection", async () => { + const { env, repository } = createHarness(); + (env.WHOOP_SYNC_QUEUE.sendBatch as unknown as ReturnType) + .mockRejectedValue(new Error("ambiguous queue outcome")); + + await expect(enqueueReconciliation(env, 42, "manual", { + repository: repository as never, + now: () => new Date(NOW), + })).rejects.toThrow("WHOOP reconciliation queue publication failed"); + + expect(repository.markSyncRunPublicationFailure).toHaveBeenCalledWith( + expect.any(String), 42, CONNECTION_ID, 7, NOW, + ); + expect(repository.recordSyncFailure).toHaveBeenCalledWith( + 42, CONNECTION_ID, NOW, "WHOOP queue publication failed", + ); + }); + + it("advances connection and run health only after durable reconciliation success", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getProfile.mockResolvedValue(PROFILE); + const batch = batchOf({ + kind: "reconcile", + whoopUserId: 42, + resource: "profile", + trigger: "manual", + }); + + await handleWhoopQueue(batch, env, dependencies); + + expect(repository.refreshSyncRun).toHaveBeenCalledWith( + RECONCILE_RUN_ID, 42, CONNECTION_ID, 7, NOW, + ); + expect(repository.recordSyncSuccess).toHaveBeenCalledWith(42, CONNECTION_ID, NOW); + }); + + it("records sanitized lifecycle-fenced retry health without acknowledging the message", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getCollection.mockRejectedValue(new WhoopRequestError("secret payload", 503, true, 30)); + const batch = batchOf({ kind: "reconcile", whoopUserId: 42, resource: "sleep" }); + + await handleWhoopQueue(batch, env, dependencies); + + expect(repository.recordSyncFailure).toHaveBeenCalledWith( + 42, CONNECTION_ID, NOW, "WHOOP request failed with status 503", + ); + expect(repository.refreshSyncRun).toHaveBeenCalledWith( + RECONCILE_RUN_ID, 42, CONNECTION_ID, 7, NOW, + ); + expect(batch.messages[0].retry).toHaveBeenCalled(); + expect(batch.messages[0].ack).not.toHaveBeenCalled(); + }); + it("durably persists one backfill page and follows only its returned cursor", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getCollection.mockResolvedValue({ records: [SLEEP], nextToken: "opaque-next" }); + const batch = batchOf({ kind: "backfill", whoopUserId: 42, resource: "sleep" }); + const message = batch.messages[0]; + + await handleWhoopQueue(batch, env, dependencies); + + expect(client.getCollection).toHaveBeenCalledWith("sleep", { limit: 25 }); + expect(repository.upsertSourceRecord).toHaveBeenCalledWith( + "sleep", + SLEEP, + { tombstonePolicy: "preserve", syncedAt: NOW, whoopUserId: 42, connectionId: CONNECTION_ID }, + ); + expect(repository.upsertCheckpoint).toHaveBeenCalledWith({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: 0, + resource: "sleep", + mode: "backfill", + syncRunId: "initial-backfill", + targetId: "", + windowStart: null, + windowEnd: null, + nextToken: "opaque-next", + status: "running", + pageCount: 1, + recordCount: 1, + createdAt: NOW, + updatedAt: NOW, + lastError: null, + }); + expect(env.WHOOP_SYNC_QUEUE.send).toHaveBeenCalledTimes(1); + expect(env.WHOOP_SYNC_QUEUE.send).toHaveBeenCalledWith({ + kind: "backfill", + whoopUserId: 42, + connectionId: CONNECTION_ID, + resource: "sleep", + nextToken: "opaque-next", + pageCount: 1, + recordCount: 1, + }); + expect(message.ack).toHaveBeenCalledTimes(1); + expect(message.retry).not.toHaveBeenCalled(); + }); + + it("completes a non-paginated profile backfill through the profile endpoint", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getProfile.mockResolvedValue(PROFILE); + const batch = batchOf({ kind: "backfill", whoopUserId: 42, resource: "profile" }); + const message = batch.messages[0]; + + await handleWhoopQueue(batch, env, dependencies); + + expect(client.getProfile).toHaveBeenCalledTimes(1); + expect(client.getCollection).not.toHaveBeenCalled(); + expect(repository.upsertSourceRecord).toHaveBeenCalledWith( + "profile", + PROFILE, + { tombstonePolicy: "preserve", syncedAt: NOW, whoopUserId: 42, connectionId: CONNECTION_ID }, + ); + expect(repository.upsertCheckpoint).toHaveBeenCalledWith(expect.objectContaining({ + whoopUserId: 42, + resource: "profile", + mode: "backfill", + nextToken: null, + status: "complete", + pageCount: 1, + recordCount: 1, + lastError: null, + })); + expect(env.WHOOP_SYNC_QUEUE.send).not.toHaveBeenCalled(); + expect(message.ack).toHaveBeenCalledTimes(1); + }); + + it("adds connection identity before persisting body measurements", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getBodyMeasurements.mockResolvedValue(BODY_MEASUREMENT); + const batch = batchOf({ kind: "backfill", whoopUserId: 42, resource: "body_measurement" }); + const message = batch.messages[0]; + + await handleWhoopQueue(batch, env, dependencies); + + expect(repository.upsertSourceRecord).toHaveBeenCalledWith( + "body_measurement", + { ...BODY_MEASUREMENT, whoop_user_id: 42 }, + { tombstonePolicy: "preserve", syncedAt: NOW, whoopUserId: 42, connectionId: CONNECTION_ID }, + ); + expect(repository.upsertCheckpoint).toHaveBeenCalledWith(expect.objectContaining({ + resource: "body_measurement", + status: "complete", + pageCount: 1, + recordCount: 1, + })); + expect(message.ack).toHaveBeenCalledTimes(1); + }); + + it("reconciles collection results authoritatively over an exact 14-day window", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getCollection.mockResolvedValue({ records: [SLEEP] }); + const batch = batchOf({ kind: "reconcile", whoopUserId: 42, resource: "sleep" }); + const message = batch.messages[0]; + + await handleWhoopQueue(batch, env, dependencies); + + expect(client.getCollection).toHaveBeenCalledWith("sleep", { + limit: 25, + start: "2026-08-05T12:00:00.000Z", + end: NOW, + }); + expect(repository.upsertSourceRecord).toHaveBeenCalledWith( + "sleep", + SLEEP, + { + tombstonePolicy: "reconcile", + syncedAt: NOW, + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: 7, + }, + ); + expect(repository.finalizeReconciliation).toHaveBeenCalledWith(expect.objectContaining({ + mode: "reconcile", + reconcileGeneration: 7, + syncRunId: RECONCILE_RUN_ID, + targetId: "", + windowStart: "2026-08-05T12:00:00.000Z", + windowEnd: NOW, + nextToken: null, + status: "complete", + })); + expect(env.WHOOP_SYNC_QUEUE.send).not.toHaveBeenCalled(); + expect(message.ack).toHaveBeenCalledTimes(1); + }); + + it("retries a pending recovery by its bounded cycle identifier", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getRecovery.mockResolvedValue(RECOVERY); + const batch = batchOf({ + kind: "reconcile", + whoopUserId: 42, + resource: "recovery", + recoveryCycleId: 9, + }); + + await handleWhoopQueue(batch, env, dependencies); + + expect(client.getRecovery).toHaveBeenCalledWith(9); + expect(client.getCollection).not.toHaveBeenCalled(); + expect(repository.upsertSourceRecord).toHaveBeenCalledWith( + "recovery", + RECOVERY, + { + tombstonePolicy: "reconcile", + syncedAt: NOW, + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: 7, + }, + ); + expect(batch.messages[0].ack).toHaveBeenCalledTimes(1); + }); + + it("queues a 14-day reconciliation plus bounded pending recovery retries", async () => { + const { dependencies, env, repository } = createHarness(); + repository.getPendingRecoveryCycleIds.mockResolvedValue([9, 10]); + + await enqueueReconciliation(env, 42, "scheduled", { + repository: repository as never, + now: () => new Date(NOW), + }); + + expect(repository.getPendingRecoveryCycleIds).toHaveBeenCalledWith(42, 25); + expect(repository.beginReconciliation).toHaveBeenCalledWith(42, CONNECTION_ID, NOW); + expect(env.WHOOP_SYNC_QUEUE.sendBatch).toHaveBeenCalledTimes(1); + const queuedBodies = (env.WHOOP_SYNC_QUEUE.sendBatch as unknown as ReturnType) + .mock.calls[0][0].map(({ body }: { body: WhoopQueueMessage }) => body); + const runIds: string[] = queuedBodies.map((body: WhoopQueueMessage) => { + if (body.kind !== "reconcile") throw new Error("Expected reconciliation message"); + return body.reconcileRunId; + }); + expect(new Set(runIds).size).toBe(1); + expect(runIds[0]).toEqual(expect.any(String)); + const reconcileRunId = runIds[0]; + expect(env.WHOOP_SYNC_QUEUE.sendBatch).toHaveBeenCalledWith([ + { body: { kind: "reconcile", whoopUserId: 42, connectionId: CONNECTION_ID, reconcileGeneration: 7, reconcileRunId, resource: "profile", trigger: "scheduled" } }, + { body: { kind: "reconcile", whoopUserId: 42, connectionId: CONNECTION_ID, reconcileGeneration: 7, reconcileRunId, resource: "body_measurement", trigger: "scheduled" } }, + ...(["cycle", "recovery", "sleep", "workout"] as const).map((resource) => ({ + body: { + kind: "reconcile" as const, + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: 7, + reconcileRunId, + resource, + windowStart: "2026-08-05T12:00:00.000Z", + windowEnd: NOW, + trigger: "scheduled", + }, + })), + { body: { kind: "reconcile", whoopUserId: 42, connectionId: CONNECTION_ID, reconcileGeneration: 7, reconcileRunId, resource: "recovery", recoveryCycleId: 9, trigger: "scheduled" } }, + { body: { kind: "reconcile", whoopUserId: 42, connectionId: CONNECTION_ID, reconcileGeneration: 7, reconcileRunId, resource: "recovery", recoveryCycleId: 10, trigger: "scheduled" } }, + ]); + }); + + it("rejects a replacement lifecycle and backfilling status required to stay active", async () => { + const { dependencies, env, repository } = createHarness(); + repository.getCurrentConnection.mockResolvedValue({ + whoopUserId: 42, + connectionId: "connection-c2", + credentialVersion: 2, + reconcileGeneration: 0, + status: "backfilling", + }); + + await expect(enqueueReconciliation(env, 42, "scheduled", { + ...dependencies, + expectedConnectionId: "connection-c1", + requireActiveConnection: true, + } as unknown as Parameters[3])).rejects.toThrow( + "WHOOP connection is not available for reconciliation", + ); + + expect(repository.beginReconciliation).not.toHaveBeenCalled(); + expect(repository.getPendingRecoveryCycleIds).not.toHaveBeenCalled(); + expect(env.WHOOP_SYNC_QUEUE.sendBatch).not.toHaveBeenCalled(); + }); + + it("requires the begin-generation CAS to preserve active status after its reread", async () => { + const { dependencies, env, repository } = createHarness(); + repository.beginReconciliation.mockResolvedValue(null); + + await expect(enqueueReconciliation(env, 42, "scheduled", { + ...dependencies, + expectedConnectionId: CONNECTION_ID, + requireActiveConnection: true, + } as unknown as Parameters[3])).rejects.toThrow( + "WHOOP connection changed before reconciliation began", + ); + + expect(repository.beginReconciliation).toHaveBeenCalledWith(42, CONNECTION_ID, NOW, true); + expect(repository.getPendingRecoveryCycleIds).not.toHaveBeenCalled(); + expect(env.WHOOP_SYNC_QUEUE.sendBatch).not.toHaveBeenCalled(); + }); + + it("records every returned provider ID before atomically finalizing the last reconciliation page", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getCollection.mockResolvedValue({ records: [SLEEP] }); + const batch = batchOf({ kind: "reconcile", whoopUserId: 42, resource: "sleep" }); + + await handleWhoopQueue(batch, env, dependencies); + + expect(repository.recordReconciliationSeen).toHaveBeenCalledWith({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: 7, + reconcileRunId: RECONCILE_RUN_ID, + resource: "sleep", + providerId: SLEEP.id, + seenAt: NOW, + }); + expect(repository.finalizeReconciliation).toHaveBeenCalledWith(expect.objectContaining({ + reconcileGeneration: 7, + syncRunId: RECONCILE_RUN_ID, + targetId: "", + status: "complete", + pageCount: 1, + recordCount: 1, + })); + expect(repository.upsertCheckpoint).not.toHaveBeenCalled(); + expect(batch.messages[0].ack).toHaveBeenCalledTimes(1); + }); + + it("retains the stable reconciliation run on a non-final next-page message", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getCollection.mockResolvedValue({ records: [SLEEP], nextToken: "opaque-next" }); + const batch = batchOf({ kind: "reconcile", whoopUserId: 42, resource: "sleep" }); + + await handleWhoopQueue(batch, env, dependencies); + + expect(repository.recordReconciliationSeen).toHaveBeenCalledTimes(1); + expect(repository.finalizeReconciliation).not.toHaveBeenCalled(); + expect(repository.upsertCheckpoint).toHaveBeenCalledWith(expect.objectContaining({ + reconcileGeneration: 7, + syncRunId: RECONCILE_RUN_ID, + targetId: "", + status: "running", + })); + expect(env.WHOOP_SYNC_QUEUE.send).toHaveBeenCalledWith(expect.objectContaining({ + reconcileGeneration: 7, + reconcileRunId: RECONCILE_RUN_ID, + nextToken: "opaque-next", + })); + }); + + it("resolves recovery updates from sleep UUID to cycle recovery", async () => { + const { client, dependencies, repository } = createHarness(); + client.getSleep.mockResolvedValue(SLEEP); + client.getRecovery.mockResolvedValue(RECOVERY); + + await processWebhook({ + eventType: "recovery.updated", + resourceId: SLEEP.id, + whoopUserId: 42, + connectionId: CONNECTION_ID, + }, dependencies); + + expect(client.getSleep).toHaveBeenCalledWith(SLEEP.id); + expect(client.getRecovery).toHaveBeenCalledWith(SLEEP.cycle_id); + expect(repository.upsertSourceRecord).toHaveBeenCalledWith( + "recovery", + RECOVERY, + { tombstonePolicy: "preserve", syncedAt: NOW, whoopUserId: 42, connectionId: CONNECTION_ID }, + ); + }); + + it("fetches and preserves an authoritative workout update", async () => { + const { client, dependencies, repository } = createHarness(); + client.getWorkout.mockResolvedValue(WORKOUT); + + await processWebhook({ + eventType: "workout.updated", + resourceId: WORKOUT.id, + whoopUserId: 42, + connectionId: CONNECTION_ID, + }, dependencies); + + expect(client.getWorkout).toHaveBeenCalledWith(WORKOUT.id); + expect(repository.upsertSourceRecord).toHaveBeenCalledWith( + "workout", + WORKOUT, + { tombstonePolicy: "preserve", syncedAt: NOW, whoopUserId: 42, connectionId: CONNECTION_ID }, + ); + }); + + it("fetches and preserves an authoritative sleep update", async () => { + const { client, dependencies, repository } = createHarness(); + client.getSleep.mockResolvedValue(SLEEP); + + await processWebhook({ + eventType: "sleep.updated", + resourceId: SLEEP.id, + whoopUserId: 42, + connectionId: CONNECTION_ID, + }, dependencies); + + expect(client.getSleep).toHaveBeenCalledWith(SLEEP.id); + expect(repository.upsertSourceRecord).toHaveBeenCalledWith( + "sleep", + SLEEP, + { tombstonePolicy: "preserve", syncedAt: NOW, whoopUserId: 42, connectionId: CONNECTION_ID }, + ); + }); + + it("tombstones webhook deletions without fetching provider data", async () => { + const { client, dependencies, repository } = createHarness(); + + await processWebhook({ eventType: "workout.deleted", resourceId: WORKOUT.id, whoopUserId: 42, connectionId: CONNECTION_ID }, dependencies); + await processWebhook({ eventType: "sleep.deleted", resourceId: SLEEP.id, whoopUserId: 42, connectionId: CONNECTION_ID }, dependencies); + await processWebhook({ eventType: "recovery.deleted", resourceId: SLEEP.id, whoopUserId: 42, connectionId: CONNECTION_ID }, dependencies); + + expect(repository.tombstoneSourceRecord.mock.calls).toEqual([ + ["workout", WORKOUT.id, NOW, { whoopUserId: 42, connectionId: CONNECTION_ID }], + ["sleep", SLEEP.id, NOW, { whoopUserId: 42, connectionId: CONNECTION_ID }], + ["recovery", SLEEP.id, NOW, { whoopUserId: 42, connectionId: CONNECTION_ID }], + ]); + expect(client.getWorkout).not.toHaveBeenCalled(); + expect(client.getSleep).not.toHaveBeenCalled(); + expect(client.getRecovery).not.toHaveBeenCalled(); + }); + + it("acknowledges a webhook only after source persistence and durable event completion", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getWorkout.mockResolvedValue(WORKOUT); + const batch = batchOf({ + kind: "webhook", + traceId: "trace-workout-update", + whoopUserId: 42, + resourceId: WORKOUT.id, + eventType: "workout.updated", + }); + const message = batch.messages[0]; + + await handleWhoopQueue(batch, env, dependencies); + + expect(repository.upsertSourceRecord).toHaveBeenCalledTimes(1); + expect(repository.markWebhookProcessed).toHaveBeenCalledWith( + "trace-workout-update", + 42, + CONNECTION_ID, + NOW, + ); + expect(repository.upsertSourceRecord.mock.invocationCallOrder[0]) + .toBeLessThan(repository.markWebhookProcessed.mock.invocationCallOrder[0]); + expect(repository.recordSyncSuccess).toHaveBeenCalledWith(42, CONNECTION_ID, NOW); + expect(message.ack).toHaveBeenCalledTimes(1); + expect(message.retry).not.toHaveBeenCalled(); + }); + + it("checkpoints a sanitized retryable failure and retries without acknowledging", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getCollection.mockRejectedValue(new WhoopRequestError("list sleep", 429, true, 30)); + const batch = batchOf({ kind: "backfill", whoopUserId: 42, resource: "sleep" }); + const message = batch.messages[0]; + + await handleWhoopQueue(batch, env, dependencies); + + expect(repository.upsertCheckpoint).toHaveBeenCalledWith(expect.objectContaining({ + whoopUserId: 42, + resource: "sleep", + mode: "backfill", + nextToken: null, + status: "retrying", + pageCount: 0, + recordCount: 0, + lastError: "WHOOP request failed with status 429", + })); + expect(message.retry).toHaveBeenCalledWith({ delaySeconds: 30 }); + expect(message.ack).not.toHaveBeenCalled(); + }); + + it("durably terminates an explicit permanent 4xx without a retry loop", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getCollection.mockRejectedValue(new WhoopRequestError("list workout", 404)); + const batch = batchOf({ + kind: "reconcile", + whoopUserId: 42, + resource: "workout", + pageCount: 2, + recordCount: 25, + }); + const message = batch.messages[0]; + + await handleWhoopQueue(batch, env, dependencies); + + expect(repository.upsertCheckpoint).toHaveBeenCalledWith(expect.objectContaining({ + reconcileGeneration: 7, + status: "error", + pageCount: 2, + recordCount: 25, + lastError: "WHOOP request failed with status 404", + })); + expect(repository.cleanupReconciliationSeen).toHaveBeenCalledWith({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + reconcileGeneration: 7, + reconcileRunId: RECONCILE_RUN_ID, + resource: "workout", + }); + expect(message.ack).toHaveBeenCalledTimes(1); + expect(message.retry).not.toHaveBeenCalled(); + }); + + it("retries a permanent webhook failure until connection health is durable", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getWorkout.mockRejectedValue(new WhoopRequestError("get workout", 404)); + repository.recordSyncFailure + .mockRejectedValueOnce(new Error("health write unavailable")) + .mockResolvedValueOnce(true); + const first = batchOf({ + kind: "webhook", + traceId: "trace-permanent-health", + whoopUserId: 42, + resourceId: WORKOUT.id, + eventType: "workout.updated", + }); + + await handleWhoopQueue(first, env, dependencies); + + expect(first.messages[0].ack).not.toHaveBeenCalled(); + expect(first.messages[0].retry).toHaveBeenCalledWith({ delaySeconds: 30 }); + + const replay = batchOf(first.messages[0].body); + await handleWhoopQueue(replay, env, dependencies); + + expect(repository.markWebhookFailed).toHaveBeenCalledTimes(2); + expect(repository.recordSyncFailure).toHaveBeenCalledTimes(2); + expect(replay.messages[0].ack).toHaveBeenCalledTimes(1); + expect(replay.messages[0].retry).not.toHaveBeenCalled(); + }); + + it("retries a permanent reconciliation failure until run and connection health are durable", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getCollection.mockRejectedValue(new WhoopRequestError("list workout", 404)); + repository.refreshSyncRun + .mockRejectedValueOnce(new Error("run write unavailable")) + .mockResolvedValueOnce(true); + const first = batchOf({ + kind: "reconcile", + whoopUserId: 42, + resource: "workout", + pageCount: 2, + recordCount: 25, + }); + + await handleWhoopQueue(first, env, dependencies); + + expect(first.messages[0].ack).not.toHaveBeenCalled(); + expect(first.messages[0].retry).toHaveBeenCalledWith({ delaySeconds: 30 }); + expect(repository.recordSyncFailure).not.toHaveBeenCalled(); + + const replay = batchOf(first.messages[0].body); + await handleWhoopQueue(replay, env, dependencies); + + expect(repository.upsertCheckpoint).toHaveBeenCalledTimes(2); + expect(repository.refreshSyncRun).toHaveBeenCalledTimes(2); + expect(repository.recordSyncFailure).toHaveBeenCalledTimes(1); + expect(repository.cleanupReconciliationSeen).toHaveBeenCalledTimes(2); + expect(replay.messages[0].ack).toHaveBeenCalledTimes(1); + expect(replay.messages[0].retry).not.toHaveBeenCalled(); + }); + + it("does not clear collection seen IDs when a targeted recovery permanently fails", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getRecovery.mockRejectedValue(new WhoopRequestError("get recovery", 404)); + const batch = batchOf({ + kind: "reconcile", + whoopUserId: 42, + resource: "recovery", + recoveryCycleId: 9, + }); + + await handleWhoopQueue(batch, env, dependencies); + + expect(repository.upsertCheckpoint).toHaveBeenCalledWith(expect.objectContaining({ + targetId: "recovery-cycle:9", + status: "error", + })); + expect(repository.cleanupReconciliationSeen).not.toHaveBeenCalled(); + expect(batch.messages[0].ack).toHaveBeenCalledTimes(1); + expect(batch.messages[0].retry).not.toHaveBeenCalled(); + }); + + it("durably records a sanitized webhook retry before requesting redelivery", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getWorkout.mockRejectedValue(new WhoopRequestError("get workout", 503, true, 15)); + const batch = batchOf({ + kind: "webhook", + traceId: "trace-retry", + whoopUserId: 42, + resourceId: WORKOUT.id, + eventType: "workout.updated", + }); + const message = batch.messages[0]; + + await handleWhoopQueue(batch, env, dependencies); + + expect(repository.markWebhookFailed).toHaveBeenCalledWith( + "trace-retry", + 42, + CONNECTION_ID, + "retrying", + "WHOOP request failed with status 503", + NOW, + ); + expect(repository.recordSyncFailure).toHaveBeenCalledWith( + 42, CONNECTION_ID, NOW, "WHOOP request failed with status 503", + ); + expect(message.retry).toHaveBeenCalledWith({ delaySeconds: 15 }); + expect(message.ack).not.toHaveBeenCalled(); + }); + + it("retries one failed message without suppressing later batch messages", async () => { + const { client, dependencies, env } = createHarness(); + client.getCollection + .mockRejectedValueOnce(new WhoopRequestError("list sleep", 503, true, 20)) + .mockResolvedValueOnce({ records: [WORKOUT] }); + const batch = batchOfMany( + { kind: "backfill", whoopUserId: 42, resource: "sleep" }, + { kind: "backfill", whoopUserId: 42, resource: "workout" }, + ); + + await handleWhoopQueue(batch, env, dependencies); + + expect(batch.messages[0].retry).toHaveBeenCalledWith({ delaySeconds: 20 }); + expect(batch.messages[0].ack).not.toHaveBeenCalled(); + expect(batch.messages[1].ack).toHaveBeenCalledTimes(1); + expect(batch.messages[1].retry).not.toHaveBeenCalled(); + expect(client.getCollection).toHaveBeenCalledTimes(2); + }); + + it("mounts the WHOOP queue consumer on the Worker export", async () => { + const queue = (worker as unknown as { + queue?: (batch: MessageBatch, env: Env) => Promise; + }).queue; + const emptyBatch = batchOfMany(); + + expect(queue).toEqual(expect.any(Function)); + await expect(queue!(emptyBatch, ENV)).resolves.toBeUndefined(); + }); + + it("preserves the returned cursor when next-page publication is ambiguous", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getCollection.mockResolvedValue({ records: [SLEEP], nextToken: "returned-cursor" }); + (env.WHOOP_SYNC_QUEUE.send as unknown as ReturnType) + .mockRejectedValue(new Error("queue outcome unknown")); + const batch = batchOf({ + kind: "backfill", + whoopUserId: 42, + resource: "sleep", + nextToken: "current-cursor", + pageCount: 2, + recordCount: 25, + }); + + await handleWhoopQueue(batch, env, dependencies); + + expect(repository.upsertCheckpoint).toHaveBeenCalledTimes(2); + expect(repository.upsertCheckpoint).toHaveBeenNthCalledWith(1, expect.objectContaining({ + nextToken: "returned-cursor", + pageCount: 3, + recordCount: 26, + })); + expect(repository.upsertCheckpoint).toHaveBeenNthCalledWith(2, expect.objectContaining({ + nextToken: "returned-cursor", + status: "retrying", + lastError: "WHOOP queue publication failed", + })); + expect(batch.messages[0].retry).toHaveBeenCalledTimes(1); + expect(batch.messages[0].ack).not.toHaveBeenCalled(); + }); + + it("processes queued deletion tombstones without loading OAuth credentials", async () => { + const { dependencies, env, repository } = createHarness(); + const batch = batchOf({ + kind: "webhook", + traceId: "trace-delete", + whoopUserId: 42, + resourceId: WORKOUT.id, + eventType: "workout.deleted", + }); + const dependenciesWithoutClient = { + repository: dependencies.repository, + now: dependencies.now, + } as WhoopSyncDependencies; + + await handleWhoopQueue(batch, env, dependenciesWithoutClient); + + expect(repository.tombstoneSourceRecord).toHaveBeenCalledWith( + "workout", + WORKOUT.id, + NOW, + { whoopUserId: 42, connectionId: CONNECTION_ID }, + ); + expect(repository.markWebhookProcessed).toHaveBeenCalledWith( + "trace-delete", + 42, + CONNECTION_ID, + NOW, + ); + expect(batch.messages[0].ack).toHaveBeenCalledTimes(1); + expect(batch.messages[0].retry).not.toHaveBeenCalled(); + }); + + it("acknowledges stale connection work before any provider request or source write", async () => { + const { client, dependencies, env, repository } = createHarness(); + repository.isSyncConnectionCurrent.mockResolvedValue(false); + const batch = batchOf({ + kind: "backfill", + whoopUserId: 42, + connectionId: "stale-connection-id", + resource: "sleep", + } as unknown as WhoopQueueMessage); + + await handleWhoopQueue(batch, env, dependencies); + + expect(repository.isSyncConnectionCurrent).toHaveBeenCalledWith(42, "stale-connection-id"); + expect(client.getCollection).not.toHaveBeenCalled(); + expect(repository.upsertSourceRecord).not.toHaveBeenCalled(); + expect(repository.upsertCheckpoint).not.toHaveBeenCalled(); + expect(batch.messages[0].ack).toHaveBeenCalledTimes(1); + expect(batch.messages[0].retry).not.toHaveBeenCalled(); + }); + + it("acknowledges a superseded reconciliation generation before provider access", async () => { + const { client, dependencies, env, repository } = createHarness(); + repository.isReconciliationCurrent.mockResolvedValue(false); + const batch = batchOf({ + kind: "reconcile", + whoopUserId: 42, + resource: "sleep", + }); + + await handleWhoopQueue(batch, env, dependencies); + + expect(repository.isReconciliationCurrent).toHaveBeenCalledWith(42, CONNECTION_ID, 7); + expect(client.getCollection).not.toHaveBeenCalled(); + expect(repository.upsertSourceRecord).not.toHaveBeenCalled(); + expect(batch.messages[0].ack).toHaveBeenCalledTimes(1); + expect(batch.messages[0].retry).not.toHaveBeenCalled(); + }); + + it("stops delayed work when the lifecycle fence is lost after the provider fetch", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getCollection.mockResolvedValue({ records: [SLEEP] }); + repository.upsertSourceRecord.mockResolvedValue(false); + const batch = batchOf({ + kind: "backfill", + whoopUserId: 42, + resource: "sleep", + }); + + await handleWhoopQueue(batch, env, dependencies); + + expect(client.getCollection).toHaveBeenCalledTimes(1); + expect(repository.upsertSourceRecord).toHaveBeenCalledTimes(1); + expect(repository.upsertCheckpoint).not.toHaveBeenCalled(); + expect(repository.activateCompletedBackfill).not.toHaveBeenCalled(); + expect(batch.messages[0].ack).toHaveBeenCalledTimes(1); + expect(batch.messages[0].retry).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/whoop/webhook.test.ts b/src/__tests__/whoop/webhook.test.ts new file mode 100644 index 0000000..a848588 --- /dev/null +++ b/src/__tests__/whoop/webhook.test.ts @@ -0,0 +1,349 @@ +import { Hono } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import worker from "../../index"; +import { getOpenApiDocument } from "../../schemas/openapi"; +import { + createWhoopIntegrationRoute, + type WhoopIntegrationDependencies, +} from "../../routes/whoop-integration"; +import type { Env } from "../../types/env"; +import type { WhoopWebhookEvent } from "../../types/whoop"; +import { + CONNECTION_ID, + ENV, + NOW, + NOW_MINUS_SIX_MINUTES_MS, + NOW_MS, + SLEEP_UPDATED, + signedWebhook, +} from "./fixtures"; + +const encoder = new TextEncoder(); + +function createDependencies() { + const repository = { + getCurrentConnection: vi.fn().mockResolvedValue({ + whoopUserId: 42, + connectionId: CONNECTION_ID, + credentialVersion: 3, + reconcileGeneration: 7, + status: "active", + }), + recordWebhookEvent: vi.fn().mockResolvedValue(true), + getWebhookEventStatus: vi.fn().mockResolvedValue("queued"), + markWebhookQueued: vi.fn().mockResolvedValue(true), + }; + const dependencies = { + repository, + now: () => new Date(NOW), + } as unknown as WhoopIntegrationDependencies; + return { dependencies, repository }; +} + +function createApp(dependencies: WhoopIntegrationDependencies) { + const app = new Hono<{ Bindings: Env }>(); + app.route("/", createWhoopIntegrationRoute(dependencies)); + return app; +} + +async function signedRaw(body: string, timestamp = NOW_MS): Promise { + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(ENV.WHOOP_CLIENT_SECRET), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(timestamp + body)); + return { + method: "POST", + body, + headers: { + "content-type": "application/json", + "X-WHOOP-Signature": btoa(String.fromCharCode(...new Uint8Array(signature))), + "X-WHOOP-Signature-Timestamp": timestamp, + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + (ENV.WHOOP_SYNC_QUEUE.send as unknown as ReturnType).mockReset(); +}); + +describe("WHOOP public webhook", () => { + it("publishes the exact signature headers and response contract in OpenAPI", () => { + const document = getOpenApiDocument("test", "https://api.example.test") as { + paths: Record }>; + }; + const operation = document.paths["/integrations/whoop/webhook"].post as { + parameters: Array<{ in: string; name: string; required?: boolean }>; + responses: Record; + security?: unknown; + }; + + expect(operation.parameters).toEqual(expect.arrayContaining([ + expect.objectContaining({ in: "header", name: "X-WHOOP-Signature", required: true }), + expect.objectContaining({ in: "header", name: "X-WHOOP-Signature-Timestamp", required: true }), + ])); + expect(Object.keys(operation.responses).sort()).toEqual(["204", "400", "401", "503"]); + expect(operation.security).toBeUndefined(); + }); + + it("mounts outside bearer-protected v1 routes", async () => { + const response = await worker.fetch(new Request( + "https://api.example.test/integrations/whoop/webhook", + { method: "POST" }, + ), ENV); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: "Invalid WHOOP webhook signature" }); + }); + + it("authenticates the exact raw body once and queues the lifecycle-fenced event", async () => { + const { dependencies, repository } = createDependencies(); + const app = createApp(dependencies); + const rawBody = ` { "user_id": 42, "id": "${SLEEP_UPDATED.id}", "type": "sleep.updated", "trace_id": "${SLEEP_UPDATED.trace_id}" } `; + const request = new Request("https://api.example.test/integrations/whoop/webhook", await signedRaw(rawBody)); + const text = vi.spyOn(request, "text"); + + const response = await app.fetch(request, ENV); + + expect(response.status).toBe(204); + expect(text).toHaveBeenCalledTimes(1); + expect(repository.recordWebhookEvent).toHaveBeenCalledWith({ + traceId: SLEEP_UPDATED.trace_id, + whoopUserId: 42, + connectionId: CONNECTION_ID, + resourceId: SLEEP_UPDATED.id, + eventType: "sleep.updated", + receivedAt: NOW, + }); + expect(ENV.WHOOP_SYNC_QUEUE.send).toHaveBeenCalledWith({ + kind: "webhook", + traceId: SLEEP_UPDATED.trace_id, + whoopUserId: 42, + connectionId: CONNECTION_ID, + resourceId: SLEEP_UPDATED.id, + eventType: "sleep.updated", + }); + expect(repository.markWebhookQueued).toHaveBeenCalledWith( + SLEEP_UPDATED.trace_id, + 42, + CONNECTION_ID, + ); + }); + + it("acknowledges an already queued duplicate without publishing it again", async () => { + const { dependencies, repository } = createDependencies(); + repository.recordWebhookEvent.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + repository.getWebhookEventStatus.mockResolvedValue("queued"); + const app = createApp(dependencies); + + const first = await app.request( + "/integrations/whoop/webhook", + await signedWebhook(SLEEP_UPDATED), + ENV, + ); + const duplicate = await app.request( + "/integrations/whoop/webhook", + await signedWebhook(SLEEP_UPDATED), + ENV, + ); + + expect(first.status).toBe(204); + expect(duplicate.status).toBe(204); + expect(ENV.WHOOP_SYNC_QUEUE.send).toHaveBeenCalledTimes(1); + }); + + it("retries publication for a duplicate still durably received after queue failure", async () => { + const { dependencies, repository } = createDependencies(); + repository.recordWebhookEvent.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + repository.getWebhookEventStatus.mockResolvedValue("received"); + (ENV.WHOOP_SYNC_QUEUE.send as unknown as ReturnType) + .mockRejectedValueOnce(new Error("fixture queue secret detail")) + .mockResolvedValueOnce(undefined); + const app = createApp(dependencies); + + const failed = await app.request( + "/integrations/whoop/webhook", + await signedWebhook(SLEEP_UPDATED), + ENV, + ); + const retried = await app.request( + "/integrations/whoop/webhook", + await signedWebhook(SLEEP_UPDATED), + ENV, + ); + + expect(failed.status).toBe(503); + await expect(failed.json()).resolves.toEqual({ error: "WHOOP webhook queue unavailable" }); + expect(retried.status).toBe(204); + expect(ENV.WHOOP_SYNC_QUEUE.send).toHaveBeenCalledTimes(2); + expect(repository.markWebhookQueued).toHaveBeenCalledTimes(1); + }); + + it("never republishes a trace owned by an old connection lifecycle", async () => { + const { dependencies, repository } = createDependencies(); + repository.recordWebhookEvent.mockResolvedValue(false); + repository.getWebhookEventStatus.mockResolvedValue(null); + const app = createApp(dependencies); + + const response = await app.request( + "/integrations/whoop/webhook", + await signedWebhook(SLEEP_UPDATED), + ENV, + ); + + expect(response.status).toBe(204); + expect(repository.getWebhookEventStatus).toHaveBeenCalledWith( + SLEEP_UPDATED.trace_id, + 42, + CONNECTION_ID, + ); + expect(ENV.WHOOP_SYNC_QUEUE.send).not.toHaveBeenCalled(); + }); + + it("acknowledges a lifecycle change after publication so the fenced queue message becomes stale", async () => { + const { dependencies, repository } = createDependencies(); + repository.markWebhookQueued.mockResolvedValue(false); + repository.getWebhookEventStatus.mockResolvedValue(null); + const app = createApp(dependencies); + + const response = await app.request( + "/integrations/whoop/webhook", + await signedWebhook(SLEEP_UPDATED), + ENV, + ); + + expect(response.status).toBe(204); + expect(ENV.WHOOP_SYNC_QUEUE.send).toHaveBeenCalledTimes(1); + expect(repository.markWebhookQueued).toHaveBeenCalledTimes(1); + }); + + it("acknowledges a signed event that does not match an active connection without persistence", async () => { + const { dependencies, repository } = createDependencies(); + repository.getCurrentConnection.mockResolvedValue({ + whoopUserId: 7, + connectionId: "other-connection", + credentialVersion: 1, + reconcileGeneration: 0, + status: "active", + }); + const app = createApp(dependencies); + + const response = await app.request( + "/integrations/whoop/webhook", + await signedWebhook(SLEEP_UPDATED), + ENV, + ); + + expect(response.status).toBe(204); + expect(repository.recordWebhookEvent).not.toHaveBeenCalled(); + expect(ENV.WHOOP_SYNC_QUEUE.send).not.toHaveBeenCalled(); + }); + + it.each([ + ["past", NOW_MINUS_SIX_MINUTES_MS], + ["future", String(Number(NOW_MS) + 6 * 60 * 1000)], + ])("rejects a timestamp more than five minutes in the %s", async (_label, timestamp) => { + const { dependencies, repository } = createDependencies(); + const app = createApp(dependencies); + + const response = await app.request( + "/integrations/whoop/webhook", + await signedWebhook(SLEEP_UPDATED, timestamp), + ENV, + ); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: "Invalid WHOOP webhook signature" }); + expect(repository.recordWebhookEvent).not.toHaveBeenCalled(); + }); + + it("accepts a timestamp exactly five minutes from the current clock", async () => { + const { dependencies } = createDependencies(); + const app = createApp(dependencies); + const timestamp = String(Number(NOW_MS) - 5 * 60 * 1000); + + const response = await app.request( + "/integrations/whoop/webhook", + await signedWebhook(SLEEP_UPDATED, timestamp), + ENV, + ); + + expect(response.status).toBe(204); + }); + + it.each(["", "not-a-number", "1787140800000.5", "1e3", "1787140800 000"])( + "rejects the malformed timestamp header %j", + async (timestamp) => { + const { dependencies, repository } = createDependencies(); + const app = createApp(dependencies); + const init = await signedWebhook(SLEEP_UPDATED); + const headers = new Headers(init.headers); + headers.set("X-WHOOP-Signature-Timestamp", timestamp); + + const response = await app.request( + "/integrations/whoop/webhook", + { ...init, headers }, + ENV, + ); + + expect(response.status).toBe(401); + expect(repository.recordWebhookEvent).not.toHaveBeenCalled(); + }, + ); + + it("rejects missing or malformed base64 signatures before persistence", async () => { + const { dependencies, repository } = createDependencies(); + const app = createApp(dependencies); + const missing = await app.request("/integrations/whoop/webhook", { method: "POST" }, ENV); + const init = await signedWebhook(SLEEP_UPDATED); + const headers = new Headers(init.headers); + headers.set("X-WHOOP-Signature", "%%%not-base64%%%"); + const malformed = await app.request( + "/integrations/whoop/webhook", + { ...init, headers }, + ENV, + ); + + expect(missing.status).toBe(401); + expect(malformed.status).toBe(401); + expect(repository.recordWebhookEvent).not.toHaveBeenCalled(); + }); + + it("rejects a canonical-length but incorrect base64 signature", async () => { + const { dependencies, repository } = createDependencies(); + const app = createApp(dependencies); + const init = await signedWebhook(SLEEP_UPDATED); + const headers = new Headers(init.headers); + headers.set("X-WHOOP-Signature", `${"A".repeat(43)}=`); + + const response = await app.request( + "/integrations/whoop/webhook", + { ...init, headers }, + ENV, + ); + + expect(response.status).toBe(401); + expect(repository.recordWebhookEvent).not.toHaveBeenCalled(); + }); + + it("rejects a validly signed non-strict envelope before persistence", async () => { + const { dependencies, repository } = createDependencies(); + const app = createApp(dependencies); + const payload = { ...SLEEP_UPDATED, unexpected: true } as unknown as WhoopWebhookEvent; + + const response = await app.request( + "/integrations/whoop/webhook", + await signedWebhook(payload), + ENV, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "Invalid WHOOP webhook payload" }); + expect(repository.recordWebhookEvent).not.toHaveBeenCalled(); + expect(ENV.WHOOP_SYNC_QUEUE.send).not.toHaveBeenCalled(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 32f50ff..17d8059 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,8 @@ import { requestLogger } from "./middleware/request-logger"; import { getOpenApiDocument } from "./schemas/openapi"; import { getSwaggerUiHtml } from "./utils/swagger"; import { handleScheduled } from "./scheduled"; +import { handleWhoopQueue } from "./services/whoop/sync"; +import type { WhoopQueueMessage } from "./types/whoop"; // Import route modules import healthRoute from "./routes/health"; @@ -37,6 +39,8 @@ import logsRoute from "./routes/logs"; import mediaRoute from "./routes/media"; import financeRoute from "./routes/finance"; import crowdRoute from "./routes/crowd"; +import whoopIntegrationRoute from "./routes/whoop-integration"; +import whoopHealthRoute from "./routes/whoop-health"; const app = new Hono<{ Bindings: Env }>(); @@ -132,6 +136,7 @@ app.route("/health", healthRoute); // Protected v1 routes app.use("/v1/*", requireAuth); +app.route("/", whoopIntegrationRoute); app.route("/v1/profile", profileRoute); app.route("/v1/now", nowRoute); app.route("/v1/settings", settingsRoute); @@ -153,6 +158,7 @@ app.route("/v1/github", githubRoute); app.route("/v1/wrapped", wrappedRoute); app.route("/v1/refresh", refreshRoute); app.route("/v1/export", exportRoute); +app.route("/v1/health/whoop", whoopHealthRoute); app.route("/v1/health", healthDataRoute); app.route("/v1/location", locationRoute); app.route("/v1/custom", customRoute); @@ -168,4 +174,7 @@ export default { async scheduled(event: ScheduledEvent, env: Env): Promise { return handleScheduled(event, env); }, + async queue(batch: MessageBatch, env: Env): Promise { + return handleWhoopQueue(batch, env); + }, }; diff --git a/src/routes/export.ts b/src/routes/export.ts index 79920fa..d922fc8 100644 --- a/src/routes/export.ts +++ b/src/routes/export.ts @@ -56,6 +56,12 @@ app.get("/", async (c) => { wakaHourly, githubDaily, githubRepos, + whoopProfiles, + whoopBodyMeasurements, + whoopCycles, + whoopRecoveries, + whoopSleeps, + whoopWorkouts, ] = await Promise.all([ c.env.DB.prepare("SELECT * FROM profile WHERE id = 1").all(), c.env.DB.prepare("SELECT * FROM now_state WHERE id = 1").all(), @@ -90,6 +96,61 @@ app.get("/", async (c) => { c.env.DB.prepare( "SELECT * FROM github_repo_totals ORDER BY range_start ASC, count DESC" ).all(), + c.env.DB.prepare(` + SELECT whoop_user_id, first_name, last_name, email, + upstream_created_at, upstream_updated_at, deleted_at, synced_at + FROM whoop_profiles + ORDER BY whoop_user_id ASC + `).all(), + c.env.DB.prepare(` + SELECT whoop_user_id, height_meter, weight_kilogram, max_heart_rate, + upstream_created_at, upstream_updated_at, deleted_at, synced_at + FROM whoop_body_measurements + ORDER BY whoop_user_id ASC + `).all(), + c.env.DB.prepare(` + SELECT cycle_id, whoop_user_id, start_at, end_at, timezone_offset, + score_state, strain, kilojoules, average_heart_rate, + max_heart_rate, upstream_created_at, upstream_updated_at, + deleted_at, synced_at + FROM whoop_cycles + ORDER BY start_at ASC, cycle_id ASC + `).all(), + c.env.DB.prepare(` + SELECT sleep_id, cycle_id, whoop_user_id, score_state, user_calibrating, + recovery_score, resting_heart_rate, hrv_rmssd_milliseconds, + spo2_percentage, skin_temperature_celsius, upstream_created_at, + upstream_updated_at, deleted_at, synced_at + FROM whoop_recoveries + ORDER BY upstream_updated_at ASC, sleep_id ASC + `).all(), + c.env.DB.prepare(` + SELECT sleep_id, cycle_id, whoop_user_id, start_at, end_at, + timezone_offset, nap, score_state, stage_awake_milliseconds, + stage_in_bed_milliseconds, stage_no_data_milliseconds, + stage_light_milliseconds, stage_slow_wave_milliseconds, + stage_rem_milliseconds, sleep_needed_milliseconds, + sleep_debt_milliseconds, sleep_need_recent_strain_milliseconds, + sleep_need_recent_nap_milliseconds, sleep_cycle_count, + disturbance_count, sleep_efficiency_percentage, + sleep_consistency_percentage, sleep_performance_percentage, + respiratory_rate, upstream_created_at, upstream_updated_at, + deleted_at, synced_at + FROM whoop_sleeps + ORDER BY start_at ASC, sleep_id ASC + `).all(), + c.env.DB.prepare(` + SELECT workout_id, whoop_user_id, start_at, end_at, timezone_offset, + sport_id, sport_name, score_state, strain, average_heart_rate, + max_heart_rate, kilojoules, percent_recorded, distance_meter, + elevation_gain_meter, zone_zero_milliseconds, + zone_one_milliseconds, zone_two_milliseconds, + zone_three_milliseconds, zone_four_milliseconds, + zone_five_milliseconds, upstream_created_at, + upstream_updated_at, deleted_at, synced_at + FROM whoop_workouts + ORDER BY start_at ASC, workout_id ASC + `).all(), ]); return c.json({ @@ -138,6 +199,14 @@ app.get("/", async (c) => { daily: githubDaily.results ?? [], repos: githubRepos.results ?? [], }, + whoop: { + profiles: whoopProfiles.results ?? [], + body_measurements: whoopBodyMeasurements.results ?? [], + cycles: whoopCycles.results ?? [], + recoveries: whoopRecoveries.results ?? [], + sleeps: whoopSleeps.results ?? [], + workouts: whoopWorkouts.results ?? [], + }, }); }); diff --git a/src/routes/whoop-health.ts b/src/routes/whoop-health.ts new file mode 100644 index 0000000..6dbf50d --- /dev/null +++ b/src/routes/whoop-health.ts @@ -0,0 +1,252 @@ +import { Hono, type Context } from "hono"; +import { + WhoopHealthReadRepository, + type WhoopReadPage, +} from "../services/whoop/read-repository"; +import { + decodeWhoopReadCursor, + encodeWhoopReadCursor, + WHOOP_READ_CURSOR_MAX_LENGTH, + type WhoopCursorResource, + type WhoopReadAnchor, +} from "../services/whoop/read-cursor"; +import type { Env } from "../types/env"; +import { + authSecurity, + errorResponses, + errorSchema, + okResponses, + openApiRegistry, + openApiResponse, + whoopCycleReadSchema, + whoopHealthCollectionQuerySchema, + whoopHealthTimestampSchema, + whoopHealthPageSchema, + whoopOverviewReadSchema, + whoopProfileReadSchema, + whoopRecoveryReadSchema, + whoopSleepReadSchema, + whoopWorkoutPathSchema, + whoopWorkoutReadSchema, +} from "../schemas/openapi"; + +export interface WhoopHealthDependencies { + readRepository?: WhoopHealthReadRepository; + now?: () => Date; +} + +interface CollectionQuery { + start: string | null; + end: string | null; + limit: number; + cursor: WhoopReadAnchor | null; +} + +const canonicalTimestamp = (value: string | undefined): string | null | undefined => { + if (value === undefined) return null; + if (!whoopHealthTimestampSchema.safeParse(value).success) return undefined; + const milliseconds = Date.parse(value); + if (Number.isNaN(milliseconds)) return undefined; + return new Date(milliseconds).toISOString(); +}; + +const collectionQuery = async ( + query: Record, + secret: string, + resource: WhoopCursorResource, +): Promise => { + const allowed = new Set(["start", "end", "limit", "cursor"]); + if (Object.keys(query).some((key) => !allowed.has(key))) return null; + const start = canonicalTimestamp(query.start); + const end = canonicalTimestamp(query.end); + if (start === undefined || end === undefined || (start !== null && end !== null && start > end)) return null; + const limitText = query.limit ?? "25"; + if (!/^(?:[1-9]|[1-9][0-9]|100)$/.test(limitText)) return null; + const encodedCursor = query.cursor; + if (encodedCursor !== undefined && encodedCursor.length > WHOOP_READ_CURSOR_MAX_LENGTH) return null; + const cursor = encodedCursor === undefined + ? null + : await decodeWhoopReadCursor(encodedCursor, secret, resource, start, end); + if (encodedCursor !== undefined && cursor === null) return null; + return { start, end, limit: Number(limitText), cursor }; +}; + +export function createWhoopHealthRoute(dependencies: WhoopHealthDependencies = {}) { + const app = new Hono<{ Bindings: Env }>(); + const now = dependencies.now ?? (() => new Date()); + const readRepositoryFor = (env: Env): WhoopHealthReadRepository => dependencies.readRepository + ?? new WhoopHealthReadRepository(env.DB); + + app.get("/overview", async (c) => { + const readRepository = readRepositoryFor(c.env); + const context = await readRepository.getReadContext(); + + const overview = context + ? await readRepository.getOverview(context.whoopUserId, now()) + : { + current_cycle: null, + current_recovery: null, + current_sleep: null, + recent_workouts: [], + trends_7_days: [], + trends_30_days: [], + }; + + return c.json({ + ...overview, + synchronization: context + ? { + status: context.status, + last_success_at: context.last_success_at, + last_error_at: context.last_error_at, + consecutive_failure_count: context.consecutive_failure_count, + updated_at: context.updated_at, + progress: context.progress, + runs: context.runs, + } + : { status: "not_connected" as const, progress: [], runs: [] }, + }); + }); + + app.get("/workouts", async (c) => { + const query = await collectionQuery( + c.req.query(), + c.env.WHOOP_TOKEN_ENCRYPTION_KEY, + "workouts", + ); + if (!query) return c.json({ error: "Invalid WHOOP collection query" }, 400); + const readRepository = readRepositoryFor(c.env); + const context = await readRepository.getReadContext(); + if (!context) return c.json({ records: [], next_cursor: null }); + const page = await readRepository.listWorkouts(context.whoopUserId, query); + const nextCursor = page.nextAnchor + ? await encodeWhoopReadCursor( + c.env.WHOOP_TOKEN_ENCRYPTION_KEY, + "workouts", + query.start, + query.end, + page.nextAnchor, + ) + : null; + return c.json({ records: page.records, next_cursor: nextCursor }); + }); + + const collectionResponse = async ( + c: Context<{ Bindings: Env }>, + resource: Exclude, + list: ( + repository: WhoopHealthReadRepository, + whoopUserId: number, + query: CollectionQuery, + ) => Promise>, + ) => { + const query = await collectionQuery(c.req.query(), c.env.WHOOP_TOKEN_ENCRYPTION_KEY, resource); + if (!query) return c.json({ error: "Invalid WHOOP collection query" }, 400); + const readRepository = readRepositoryFor(c.env); + const context = await readRepository.getReadContext(); + if (!context) return c.json({ records: [], next_cursor: null }); + const page = await list(readRepository, context.whoopUserId, query); + const nextCursor = page.nextAnchor + ? await encodeWhoopReadCursor( + c.env.WHOOP_TOKEN_ENCRYPTION_KEY, + resource, + query.start, + query.end, + page.nextAnchor, + ) + : null; + return c.json({ records: page.records, next_cursor: nextCursor }); + }; + + app.get("/cycles", (c) => collectionResponse( + c, + "cycles", + (repository, whoopUserId, query) => repository.listCycles(whoopUserId, query), + )); + app.get("/recoveries", (c) => collectionResponse( + c, + "recoveries", + (repository, whoopUserId, query) => repository.listRecoveries(whoopUserId, query), + )); + app.get("/sleeps", (c) => collectionResponse( + c, + "sleeps", + (repository, whoopUserId, query) => repository.listSleeps(whoopUserId, query), + )); + + app.get("/workouts/:workoutId", async (c) => { + const workoutId = c.req.param("workoutId"); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(workoutId)) { + return c.json({ error: "Invalid workout ID" }, 400); + } + const readRepository = readRepositoryFor(c.env); + const context = await readRepository.getReadContext(); + if (!context) return c.json({ error: "WHOOP workout not found" }, 404); + const workout = await readRepository.getWorkout(context.whoopUserId, workoutId); + return workout + ? c.json(workout) + : c.json({ error: "WHOOP workout not found" }, 404); + }); + + app.get("/profile", async (c) => { + const readRepository = readRepositoryFor(c.env); + const context = await readRepository.getReadContext(); + if (!context) return c.json({ error: "WHOOP profile not found" }, 404); + const profile = await readRepository.getProfile(context.whoopUserId); + return profile + ? c.json(profile) + : c.json({ error: "WHOOP profile not found" }, 404); + }); + + return app; +} + +for (const [resource, responseSchema, summary] of [ + ["cycles", whoopCycleReadSchema, "List WHOOP physiological cycles"], + ["recoveries", whoopRecoveryReadSchema, "List WHOOP recoveries"], + ["sleeps", whoopSleepReadSchema, "List WHOOP sleeps"], + ["workouts", whoopWorkoutReadSchema, "List WHOOP workouts"], +] as const) { + openApiRegistry.registerPath({ + method: "get", + path: `/v1/health/whoop/${resource}`, + summary, + security: authSecurity, + request: { query: whoopHealthCollectionQuerySchema }, + responses: okResponses(whoopHealthPageSchema(responseSchema)), + }); +} + +openApiRegistry.registerPath({ + method: "get", + path: "/v1/health/whoop/overview", + summary: "Get the current WHOOP health overview", + security: authSecurity, + responses: okResponses(whoopOverviewReadSchema), +}); + +openApiRegistry.registerPath({ + method: "get", + path: "/v1/health/whoop/profile", + summary: "Get the connected WHOOP profile", + security: authSecurity, + responses: { + ...okResponses(whoopProfileReadSchema), + 404: openApiResponse(errorSchema, "WHOOP profile not found"), + }, +}); + +openApiRegistry.registerPath({ + method: "get", + path: "/v1/health/whoop/workouts/{workoutId}", + summary: "Get a WHOOP workout", + security: authSecurity, + request: { params: whoopWorkoutPathSchema }, + responses: { + 200: openApiResponse(whoopWorkoutReadSchema, "OK"), + 404: openApiResponse(errorSchema, "WHOOP workout not found"), + ...errorResponses, + }, +}); + +export default createWhoopHealthRoute(); diff --git a/src/routes/whoop-integration.ts b/src/routes/whoop-integration.ts new file mode 100644 index 0000000..fb3e562 --- /dev/null +++ b/src/routes/whoop-integration.ts @@ -0,0 +1,490 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { createOAuthState, encryptWhoopToken, hashOAuthState } from "../services/whoop/crypto"; +import { WhoopClient, type WhoopTokenResponse } from "../services/whoop/client"; +import { + type CurrentWhoopConnection, + type SyncProgressProjection, + type SyncRunProjection, + WhoopRepository, +} from "../services/whoop/repository"; +import { + authSecurity, + errorResponses, + errorSchema, + okSchema, + openApiResponse, + openApiJsonRequestBody, + okResponses, + openApiRegistry, + whoopAuthorizationUrlResponseSchema, + whoopIntegrationStatusResponseSchema, +} from "../schemas/openapi"; +import type { Env } from "../types/env"; +import { WHOOP_SCOPES, type WhoopQueueMessage, type WhoopResource } from "../types/whoop"; +import { enqueueReconciliation } from "../services/whoop/sync"; +import { whoopWebhookSchema } from "../schemas/whoop"; + +const WHOOP_AUTHORIZE_URL = "https://api.prod.whoop.com/oauth/oauth2/auth"; +const OAUTH_STATE_LIFETIME_MILLISECONDS = 10 * 60 * 1000; +const WEBHOOK_MAX_SKEW_MILLISECONDS = 5 * 60 * 1000; +const INITIAL_RESOURCES: readonly WhoopResource[] = [ + "profile", "body_measurement", "cycle", "recovery", "sleep", "workout", +]; + +interface IntegrationRepository { + createOAuthState(stateHash: string, createdAt: string, expiresAt: string): Promise; + consumeOAuthState(stateHash: string, consumedAt: string): Promise; + getCurrentConnection(): Promise; + getSyncProgress(whoopUserId: number): Promise; + getRecentSyncRuns(whoopUserId: number): Promise; + beginReconciliation( + whoopUserId: number, + connectionId: string, + begunAt: string, + ): Promise; + getPendingRecoveryCycleIds(whoopUserId: number, limit?: number): Promise; + createSyncRun(input: Parameters[0]): Promise; + markSyncRunPublicationFailure(...input: Parameters): Promise; + recordSyncFailure(...input: Parameters): Promise; + claimAndUpsertConnection(input: Parameters[0]): Promise; + markInitialBackfillQueued( + whoopUserId: number, + connectionId: string, + credentialVersion: number, + queuedAt: string, + ): Promise; + recordWebhookEvent(input: Parameters[0]): Promise; + getWebhookEventStatus( + traceId: string, + whoopUserId: number, + connectionId: string, + ): ReturnType; + markWebhookQueued(traceId: string, whoopUserId: number, connectionId: string): Promise; + withWhoopAccessToken( + whoopUserId: number, + request: (accessToken: string, credentialVersion: number) => Promise, + refresh: (refreshToken: string, options: { signal: AbortSignal }) => Promise, + ): Promise; + disconnect(whoopUserId: number, credentialVersion: number, disconnectedAt: string): Promise; + deleteLocalData(whoopUserId: number, credentialVersion: number): Promise; +} + +interface IntegrationClient { + exchangeAuthorizationCode(code: string): ReturnType; + getProfile(): ReturnType; + revokeAccess(accessToken: string): ReturnType; + refreshToken(refreshToken: string, options?: { signal?: AbortSignal }): ReturnType; +} + +export interface WhoopIntegrationDependencies { + repository?: IntegrationRepository; + clientFactory?: (env: Env, accessToken: string) => IntegrationClient; + now?: () => Date; +} + +const configured = (env: Env): boolean => { + if (!env.DB) return false; + if (!env.WHOOP_SYNC_QUEUE + || typeof env.WHOOP_SYNC_QUEUE.send !== "function" + || typeof env.WHOOP_SYNC_QUEUE.sendBatch !== "function") return false; + const requiredStrings = [ + env.WHOOP_CLIENT_ID, + env.WHOOP_CLIENT_SECRET, + env.WHOOP_TOKEN_ENCRYPTION_KEY, + env.WHOOP_REDIRECT_URI, + env.OS_BASE_URL, + ]; + if (requiredStrings.some((value) => typeof value !== "string" || value.length === 0)) return false; + try { + return new URL(env.WHOOP_REDIRECT_URI).protocol === "https:" + && new URL(env.OS_BASE_URL).protocol === "https:"; + } catch { + return false; + } +}; + +const resultRedirect = (env: Env, result: "connected" | "failed"): string => + new URL(`/health/source?result=${result}`, env.OS_BASE_URL).toString(); + +const connectionCanSync = (connection: CurrentWhoopConnection | null): connection is CurrentWhoopConnection => + connection !== null && (connection.status === "active" || connection.status === "backfilling"); + +const backfillMessagesFor = ( + whoopUserId: number, + connectionId: string, +): WhoopQueueMessage[] => INITIAL_RESOURCES.map((resource) => ({ + kind: "backfill", + whoopUserId, + connectionId, + resource, +})); + +const decodeCanonicalBase64 = (value: string): Uint8Array => { + if (!/^[A-Za-z0-9+/]{43}=$/.test(value)) { + return new Uint8Array(); + } + try { + const decoded = atob(value); + if (btoa(decoded) !== value) return new Uint8Array(); + return Uint8Array.from(decoded, (character) => character.charCodeAt(0)); + } catch { + return new Uint8Array(); + } +}; + +const constantTimeBytesEqual = (left: Uint8Array, right: Uint8Array): boolean => { + let difference = left.length ^ right.length; + const paddedLength = Math.max(left.length, right.length); + for (let index = 0; index < paddedLength; index += 1) { + difference |= (left[index] ?? 0) ^ (right[index] ?? 0); + } + return difference === 0; +}; + +const validWebhookSignature = async ( + secret: string, + timestampHeader: string, + signatureHeader: string, + rawBody: string, + now: Date, +): Promise => { + if (!/^(?:0|[1-9][0-9]*)$/.test(timestampHeader)) return false; + const timestamp = Number(timestampHeader); + if (!Number.isSafeInteger(timestamp) + || Math.abs(now.getTime() - timestamp) > WEBHOOK_MAX_SKEW_MILLISECONDS) return false; + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const expected = new Uint8Array(await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(timestampHeader + rawBody), + )); + return constantTimeBytesEqual(decodeCanonicalBase64(signatureHeader), expected); +}; + +export function createWhoopIntegrationRoute(dependencies: WhoopIntegrationDependencies = {}) { + const app = new Hono<{ Bindings: Env }>(); + const now = dependencies.now ?? (() => new Date()); + const repositoryFor = (env: Env): IntegrationRepository => dependencies.repository + ?? new WhoopRepository(env.DB, env.WHOOP_TOKEN_ENCRYPTION_KEY); + const clientFor = (env: Env, accessToken: string): IntegrationClient => dependencies.clientFactory?.(env, accessToken) + ?? new WhoopClient(env, accessToken); + const revokeIssuedAccessToken = async (env: Env, accessToken: string): Promise => { + try { + await clientFor(env, accessToken).revokeAccess(accessToken); + } catch { + // A rejected connection claim must not expose a provider revocation failure. + } + }; + const callbackFailure = (env: Env) => new Response(null, { + status: 302, + headers: { location: resultRedirect(env, "failed") }, + }); + + app.get("/v1/integrations/whoop", async (c) => { + const repository = repositoryFor(c.env); + const connection = await repository.getCurrentConnection(); + if (!connection) return c.json({ status: "not_connected", progress: [], runs: [] }); + const [progress, runs] = await Promise.all([ + repository.getSyncProgress(connection.whoopUserId), + repository.getRecentSyncRuns(connection.whoopUserId), + ]); + const { + whoopUserId: _whoopUserId, + connectionId: _connectionId, + credentialVersion: _credentialVersion, + reconcileGeneration: _reconcileGeneration, + ...status + } = connection; + return c.json({ ...status, progress, runs }); + }); + + app.post("/v1/integrations/whoop/connect", async (c) => { + if (!configured(c.env)) return c.json({ error: "WHOOP integration is not configured" }, 503); + const repository = repositoryFor(c.env); + const createdAt = now(); + const state = await createOAuthState(); + const stateHash = await hashOAuthState(state); + await repository.createOAuthState( + stateHash, + createdAt.toISOString(), + new Date(createdAt.getTime() + OAUTH_STATE_LIFETIME_MILLISECONDS).toISOString(), + ); + const url = new URL(WHOOP_AUTHORIZE_URL); + url.searchParams.set("client_id", c.env.WHOOP_CLIENT_ID); + url.searchParams.set("redirect_uri", c.env.WHOOP_REDIRECT_URI); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", WHOOP_SCOPES.join(" ")); + url.searchParams.set("state", state); + return c.json({ authorization_url: url.toString() }); + }); + + app.get("/integrations/whoop/callback", async (c) => { + if (!configured(c.env)) return callbackFailure(c.env); + const state = c.req.query("state"); + if (!state) return callbackFailure(c.env); + let issuedAccessToken: string | null = null; + let claimAttempted = false; + try { + const repository = repositoryFor(c.env); + const consumed = await repository.consumeOAuthState(await hashOAuthState(state), now().toISOString()); + if (!consumed) return callbackFailure(c.env); + const code = c.req.query("code"); + if (!code) return callbackFailure(c.env); + const unauthenticatedClient = clientFor(c.env, ""); + const tokens = await unauthenticatedClient.exchangeAuthorizationCode(code); + issuedAccessToken = tokens.access_token; + const authenticatedClient = clientFor(c.env, tokens.access_token); + const profile = await authenticatedClient.getProfile(); + const connectedAt = now(); + const connectionId = crypto.randomUUID(); + const [accessToken, refreshToken] = await Promise.all([ + encryptWhoopToken(c.env.WHOOP_TOKEN_ENCRYPTION_KEY, profile.user_id, "access", tokens.access_token), + encryptWhoopToken(c.env.WHOOP_TOKEN_ENCRYPTION_KEY, profile.user_id, "refresh", tokens.refresh_token), + ]); + claimAttempted = true; + const credentialVersion = await repository.claimAndUpsertConnection({ + whoopUserId: profile.user_id, + connectionId, + status: "backfilling", + accessToken, + accessTokenExpiresAt: new Date(connectedAt.getTime() + tokens.expires_in * 1000).toISOString(), + refreshToken, + grantedScopes: tokens.scope?.split(/\s+/).filter(Boolean) ?? WHOOP_SCOPES, + connectedAt: connectedAt.toISOString(), + initialBackfillPending: true, + }); + if (credentialVersion === null) { + await revokeIssuedAccessToken(c.env, tokens.access_token); + return callbackFailure(c.env); + } + await c.env.WHOOP_SYNC_QUEUE.sendBatch( + backfillMessagesFor(profile.user_id, connectionId).map((body) => ({ body })), + ); + const markedQueued = await repository.markInitialBackfillQueued( + profile.user_id, + connectionId, + credentialVersion, + now().toISOString(), + ); + if (!markedQueued) return callbackFailure(c.env); + return c.redirect(resultRedirect(c.env, "connected")); + } catch { + if (issuedAccessToken !== null && !claimAttempted) { + await revokeIssuedAccessToken(c.env, issuedAccessToken); + } + return callbackFailure(c.env); + } + }); + + app.post("/integrations/whoop/webhook", async (c) => { + const signature = c.req.header("X-WHOOP-Signature"); + const timestamp = c.req.header("X-WHOOP-Signature-Timestamp"); + if (!signature || !timestamp || typeof c.env.WHOOP_CLIENT_SECRET !== "string" + || c.env.WHOOP_CLIENT_SECRET.length === 0) { + return c.json({ error: "Invalid WHOOP webhook signature" }, 401); + } + const rawBody = await c.req.raw.text(); + if (!await validWebhookSignature(c.env.WHOOP_CLIENT_SECRET, timestamp, signature, rawBody, now())) { + return c.json({ error: "Invalid WHOOP webhook signature" }, 401); + } + let json: unknown; + try { + json = JSON.parse(rawBody); + } catch { + return c.json({ error: "Invalid WHOOP webhook payload" }, 400); + } + const parsed = whoopWebhookSchema.safeParse(json); + if (!parsed.success) return c.json({ error: "Invalid WHOOP webhook payload" }, 400); + + const repository = repositoryFor(c.env); + const connection = await repository.getCurrentConnection(); + if (!connectionCanSync(connection) || connection.whoopUserId !== parsed.data.user_id) { + return c.body(null, 204); + } + const receipt = { + traceId: parsed.data.trace_id, + whoopUserId: parsed.data.user_id, + connectionId: connection.connectionId, + resourceId: parsed.data.id, + eventType: parsed.data.type, + receivedAt: now().toISOString(), + }; + const inserted = await repository.recordWebhookEvent(receipt); + if (!inserted) { + const status = await repository.getWebhookEventStatus( + receipt.traceId, + receipt.whoopUserId, + receipt.connectionId, + ); + if (status !== "received") return c.body(null, 204); + } + try { + await c.env.WHOOP_SYNC_QUEUE.send({ + kind: "webhook", + traceId: receipt.traceId, + whoopUserId: receipt.whoopUserId, + connectionId: receipt.connectionId, + resourceId: receipt.resourceId, + eventType: receipt.eventType, + }); + } catch { + return c.json({ error: "WHOOP webhook queue unavailable" }, 503); + } + let markedQueued: boolean; + try { + markedQueued = await repository.markWebhookQueued( + receipt.traceId, + receipt.whoopUserId, + receipt.connectionId, + ); + } catch { + return c.json({ error: "WHOOP webhook queue unavailable" }, 503); + } + if (!markedQueued) { + const status = await repository.getWebhookEventStatus( + receipt.traceId, + receipt.whoopUserId, + receipt.connectionId, + ); + if (status === "received") { + return c.json({ error: "WHOOP webhook queue unavailable" }, 503); + } + } + return c.body(null, 204); + }); + + app.post("/v1/integrations/whoop/sync", async (c) => { + if (!configured(c.env)) return c.json({ error: "WHOOP integration is not configured" }, 503); + const repository = repositoryFor(c.env); + const connection = await repository.getCurrentConnection(); + if (!connectionCanSync(connection)) return c.json({ error: "WHOOP is not connected" }, 409); + await enqueueReconciliation(c.env, connection.whoopUserId, "manual", { + repository, + now, + expectedConnectionId: connection.connectionId, + requireActiveConnection: false, + }); + return c.json({ ok: true }, 202); + }); + + app.delete("/v1/integrations/whoop", async (c) => { + if (!configured(c.env)) return c.json({ error: "WHOOP integration is not configured" }, 503); + const repository = repositoryFor(c.env); + const connection = await repository.getCurrentConnection(); + if (!connectionCanSync(connection)) return c.json({ error: "WHOOP is not connected" }, 409); + try { + let revokedCredentialVersion: number | null = null; + await repository.withWhoopAccessToken( + connection.whoopUserId, + async (accessToken, credentialVersion) => { + await clientFor(c.env, accessToken).revokeAccess(accessToken); + revokedCredentialVersion = credentialVersion; + }, + (refreshToken, options) => clientFor(c.env, "").refreshToken(refreshToken, options), + ); + if (revokedCredentialVersion === null) return c.json({ error: "WHOOP connection changed before disconnect" }, 409); + const disconnected = await repository.disconnect( + connection.whoopUserId, + revokedCredentialVersion, + now().toISOString(), + ); + if (!disconnected) return c.json({ error: "WHOOP connection changed before disconnect" }, 409); + return c.json({ ok: true }); + } catch { + return c.json({ error: "WHOOP disconnect failed" }, 502); + } + }); + + app.delete("/v1/integrations/whoop/data", async (c) => { + const repository = repositoryFor(c.env); + const connection = await repository.getCurrentConnection(); + if (!connection || connection.status !== "disconnected") { + return c.json({ error: "Disconnect WHOOP before deleting local data" }, 409); + } + const deleted = await repository.deleteLocalData(connection.whoopUserId, connection.credentialVersion); + if (!deleted) return c.json({ error: "WHOOP connection changed before data deletion" }, 409); + return c.json({ ok: true }); + }); + + return app; +} + +openApiRegistry.registerPath({ + method: "get", + path: "/v1/integrations/whoop", + summary: "Get WHOOP connection and sync status", + security: authSecurity, + responses: okResponses(whoopIntegrationStatusResponseSchema), +}); + +const whoopWebhookHeadersSchema = z.object({ + "X-WHOOP-Signature": z.string(), + "X-WHOOP-Signature-Timestamp": z.string(), +}); + +openApiRegistry.registerPath({ + method: "post", + path: "/integrations/whoop/webhook", + summary: "Receive a signed WHOOP webhook", + request: { + headers: whoopWebhookHeadersSchema, + body: openApiJsonRequestBody(whoopWebhookSchema), + }, + responses: { + 204: { description: "Webhook accepted" }, + 400: openApiResponse(errorSchema, "Invalid webhook payload"), + 401: openApiResponse(errorSchema, "Invalid webhook signature"), + 503: openApiResponse(errorSchema, "Webhook queue unavailable"), + }, +}); + +openApiRegistry.registerPath({ + method: "post", + path: "/v1/integrations/whoop/connect", + summary: "Create a WHOOP authorization URL", + security: authSecurity, + responses: okResponses(whoopAuthorizationUrlResponseSchema), +}); + +openApiRegistry.registerPath({ + method: "post", + path: "/v1/integrations/whoop/sync", + summary: "Queue WHOOP reconciliation", + security: authSecurity, + responses: { + 202: openApiResponse(okSchema, "Reconciliation queued"), + ...errorResponses, + }, +}); + +for (const [method, path, summary] of [ + ["delete", "/v1/integrations/whoop", "Revoke WHOOP access and disconnect"], + ["delete", "/v1/integrations/whoop/data", "Delete disconnected local WHOOP data"], +] as const) { + openApiRegistry.registerPath({ + method, + path, + summary, + security: authSecurity, + responses: okResponses(okSchema), + }); +} + +openApiRegistry.registerPath({ + method: "get", + path: "/integrations/whoop/callback", + summary: "Complete WHOOP OAuth authorization", + responses: { + 302: { description: "Fixed OS connection result redirect" }, + }, +}); + +export default createWhoopIntegrationRoute(); diff --git a/src/scheduled.ts b/src/scheduled.ts index 625e50e..f075547 100644 --- a/src/scheduled.ts +++ b/src/scheduled.ts @@ -9,19 +9,62 @@ import { markRefreshed, } from "./services/wakatime"; import { refreshGitHub } from "./services/github"; +import { + WhoopRepository, + type CurrentWhoopConnection, + type PendingInitialBackfill, +} from "./services/whoop/repository"; +import { WhoopClient } from "./services/whoop/client"; +import { enqueueReconciliation } from "./services/whoop/sync"; +import type { WhoopQueueMessage, WhoopResource } from "./types/whoop"; + +const WHOOP_RESOURCES: readonly WhoopResource[] = [ + "profile", "body_measurement", "cycle", "recovery", "sleep", "workout", +]; +const WHOOP_REFRESH_WINDOW_MILLISECONDS = 5 * 60 * 1000; + +type ScheduledWhoopRepository = Pick; + +interface ScheduledRefreshJobs { + lanyard: () => Promise; + wakatime: () => Promise; + github: () => Promise; +} + +export interface ScheduledDependencies { + repository?: ScheduledWhoopRepository; + enqueueReconciliation?: typeof enqueueReconciliation; + now?: () => Date; + refreshJobs?: ScheduledRefreshJobs; + refreshClientFactory?: (env: Env) => Pick; +} export async function handleScheduled( _event: ScheduledEvent, - env: Env + env: Env, + dependencies: ScheduledDependencies = {}, ): Promise { // Each refresh runs independently. Previously the WakaTime block had // early `return`s that exited the whole handler, so GitHub would never // refresh on cron ticks where today's WakaTime row already existed — // i.e. most of the day. const jobs = [ - { name: "lanyard", task: () => refreshLanyard(env) }, - { name: "wakatime", task: () => refreshWakaTimeIfDue(env) }, - { name: "github", task: () => refreshGitHubIfDue(env) }, + { name: "lanyard", task: dependencies.refreshJobs?.lanyard ?? (() => refreshLanyard(env)) }, + { name: "wakatime", task: dependencies.refreshJobs?.wakatime ?? (() => refreshWakaTimeIfDue(env)) }, + { name: "github", task: dependencies.refreshJobs?.github ?? (() => refreshGitHubIfDue(env)) }, + { name: "whoop", task: () => refreshWhoop(env, dependencies) }, + { name: "whoop-retention", task: () => pruneWhoopOperations(env, dependencies) }, ]; const results = await Promise.allSettled( @@ -35,6 +78,66 @@ export async function handleScheduled( }); } +async function pruneWhoopOperations(env: Env, dependencies: ScheduledDependencies): Promise { + const repository = dependencies.repository + ?? new WhoopRepository(env.DB, env.WHOOP_TOKEN_ENCRYPTION_KEY); + const now = dependencies.now ?? (() => new Date()); + await repository.pruneOperationalData(now().toISOString()); +} + +const backfillMessagesFor = (intent: PendingInitialBackfill): WhoopQueueMessage[] => + WHOOP_RESOURCES.map((resource) => ({ + kind: "backfill", + whoopUserId: intent.whoopUserId, + connectionId: intent.connectionId, + resource, + })); + +const activeConnection = ( + connection: CurrentWhoopConnection | null, +): connection is CurrentWhoopConnection => connection?.status === "active"; + +async function refreshWhoop(env: Env, dependencies: ScheduledDependencies): Promise { + const repository = dependencies.repository + ?? new WhoopRepository(env.DB, env.WHOOP_TOKEN_ENCRYPTION_KEY); + const now = dependencies.now ?? (() => new Date()); + const publishReconciliation = dependencies.enqueueReconciliation ?? enqueueReconciliation; + const pendingBackfills = await repository.getPendingInitialBackfills(); + + for (const intent of pendingBackfills) { + await env.WHOOP_SYNC_QUEUE.sendBatch( + backfillMessagesFor(intent).map((body) => ({ body })), + ); + const marked = await repository.markInitialBackfillQueued( + intent.whoopUserId, + intent.connectionId, + intent.credentialVersion, + now().toISOString(), + ); + if (!marked) throw new Error("WHOOP initial backfill lifecycle changed"); + } + + const connection = await repository.getCurrentConnection(); + if (!activeConnection(connection)) return; + await repository.withWhoopAccessToken( + connection.whoopUserId, + async () => undefined, + (refreshToken, options) => ( + dependencies.refreshClientFactory?.(env) ?? new WhoopClient(env, "") + ).refreshToken(refreshToken, options), + { + expectedConnectionId: connection.connectionId, + refreshBeforeExpirationMilliseconds: WHOOP_REFRESH_WINDOW_MILLISECONDS, + }, + ); + await publishReconciliation(env, connection.whoopUserId, "scheduled", { + repository, + now, + expectedConnectionId: connection.connectionId, + requireActiveConnection: true, + }); +} + export async function runRefreshJob( env: Env, name: string, diff --git a/src/schemas/openapi.ts b/src/schemas/openapi.ts index 305683c..d8e66d6 100644 --- a/src/schemas/openapi.ts +++ b/src/schemas/openapi.ts @@ -106,6 +106,260 @@ export const healthWorkoutsRangeResponseSchema = z.object({ export const healthSummaryResponseSchema = genericObjectSchema; +export const whoopAuthorizationUrlResponseSchema = z.object({ + authorization_url: z.string().url(), +}); + +export const whoopIntegrationStatusResponseSchema = z.object({ + status: z.enum([ + "not_connected", + "connecting", + "backfilling", + "active", + "needs_reauth", + "disconnected", + "error", + ]), + granted_scopes: z.array(z.string()).optional(), + connected_at: dateTimeSchema.nullable().optional(), + refreshed_at: dateTimeSchema.nullable().optional(), + last_success_at: dateTimeSchema.nullable().optional(), + last_error_at: dateTimeSchema.nullable().optional(), + disconnected_at: dateTimeSchema.nullable().optional(), + last_error: z.string().nullable().optional(), + consecutive_failure_count: z.number().optional(), + updated_at: dateTimeSchema.optional(), + progress: z.array(z.object({ + resource: z.enum(["profile", "body_measurement", "cycle", "recovery", "sleep", "workout"]), + mode: z.enum(["backfill", "reconcile", "webhook"]), + status: z.enum(["queued", "running", "retrying", "complete", "error"]), + page_count: z.number().int().nonnegative(), + record_count: z.number().int().nonnegative(), + updated_at: dateTimeSchema, + last_error: z.string().nullable(), + })), + runs: z.array(z.object({ + run_id: z.string().uuid(), + trigger: z.string(), + status: z.enum(["queued", "running", "retrying", "complete", "error"]), + page_count: z.number().int().nonnegative(), + record_count: z.number().int().nonnegative(), + expected_target_count: z.number().int().nonnegative(), + completed_target_count: z.number().int().nonnegative(), + started_at: dateTimeSchema, + succeeded_at: dateTimeSchema.nullable(), + failed_at: dateTimeSchema.nullable(), + last_error: z.string().nullable(), + }).strict()), +}); + +const whoopReadScoreStateSchema = z.enum(["scored", "pending", "unscorable"]); +const nullableNumberSchema = z.number().nullable(); +const nullableDateTimeSchema = dateTimeSchema.nullable(); + +const hasValidCalendarDate = (value: string): boolean => { + const date = /^(\d{4})-(\d{2})-(\d{2})T/.exec(value); + if (!date) return false; + const year = Number(date[1]); + const month = Number(date[2]); + const day = Number(date[3]); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth[month - 1]; +}; + +export const whoopHealthTimestampSchema = z.string() + .datetime({ offset: true }) + .refine(hasValidCalendarDate) + .openapi({ format: "date-time" }); + +export const whoopHealthCollectionQuerySchema = z.object({ + start: whoopHealthTimestampSchema.optional().openapi({ + param: { name: "start", in: "query" }, + description: "Inclusive provider timestamp lower bound", + example: "2026-08-01T00:00:00.000Z", + }), + end: whoopHealthTimestampSchema.optional().openapi({ + param: { name: "end", in: "query" }, + description: "Inclusive provider timestamp upper bound", + example: "2026-08-20T23:59:59.999Z", + }), + limit: z.number().int().min(1).max(100).optional().openapi({ + param: { name: "limit", in: "query" }, + description: "Page size from 1 through 100", + example: 25, + }), + cursor: z.string().min(1).max(1024).regex(/^[A-Za-z0-9_-]+$/).optional().openapi({ + param: { name: "cursor", in: "query" }, + description: "Opaque local continuation cursor bound to this resource and date window", + }), +}).strict(); + +export const whoopWorkoutPathSchema = z.object({ + workoutId: z.string().uuid().openapi({ + param: { name: "workoutId", in: "path" }, + }), +}); + +export const whoopCycleReadSchema = z.object({ + cycle_id: z.number().int().positive(), + start_at: dateTimeSchema, + end_at: nullableDateTimeSchema, + timezone_offset: z.string().nullable(), + score_state: whoopReadScoreStateSchema, + strain: nullableNumberSchema, + kilojoules: nullableNumberSchema, + energy_kcal_estimate: nullableNumberSchema, + average_heart_rate: nullableNumberSchema, + max_heart_rate: nullableNumberSchema, + created_at: dateTimeSchema, + updated_at: dateTimeSchema, + synced_at: dateTimeSchema, +}).strict(); + +export const whoopRecoveryReadSchema = z.object({ + sleep_id: z.string().uuid(), + cycle_id: z.number().int().positive(), + score_state: whoopReadScoreStateSchema, + user_calibrating: z.boolean().nullable(), + score: nullableNumberSchema, + resting_heart_rate: nullableNumberSchema, + hrv_rmssd_milliseconds: nullableNumberSchema, + spo2_percentage: nullableNumberSchema, + skin_temperature_celsius: nullableNumberSchema, + created_at: dateTimeSchema, + updated_at: dateTimeSchema, + synced_at: dateTimeSchema, +}).strict(); + +export const whoopSleepReadSchema = z.object({ + sleep_id: z.string().uuid(), + cycle_id: z.number().int().positive(), + start_at: nullableDateTimeSchema, + end_at: nullableDateTimeSchema, + timezone_offset: z.string().nullable(), + nap: z.boolean().nullable(), + score_state: whoopReadScoreStateSchema, + stage_durations_seconds: z.object({ + in_bed_seconds: nullableNumberSchema, + awake_seconds: nullableNumberSchema, + no_data_seconds: nullableNumberSchema, + light_seconds: nullableNumberSchema, + slow_wave_seconds: nullableNumberSchema, + rem_seconds: nullableNumberSchema, + }).strict(), + sleep_need_seconds: z.object({ + baseline_seconds: nullableNumberSchema, + debt_seconds: nullableNumberSchema, + recent_strain_seconds: nullableNumberSchema, + recent_nap_seconds: nullableNumberSchema, + }).strict(), + sleep_cycle_count: z.number().int().nonnegative().nullable(), + disturbance_count: z.number().int().nonnegative().nullable(), + sleep_efficiency_percentage: nullableNumberSchema, + sleep_consistency_percentage: nullableNumberSchema, + sleep_performance_percentage: nullableNumberSchema, + respiratory_rate: nullableNumberSchema, + created_at: dateTimeSchema, + updated_at: dateTimeSchema, + synced_at: dateTimeSchema, +}).strict(); + +const whoopZoneDurationsSchema = z.object({ + zone_zero_seconds: nullableNumberSchema, + zone_one_seconds: nullableNumberSchema, + zone_two_seconds: nullableNumberSchema, + zone_three_seconds: nullableNumberSchema, + zone_four_seconds: nullableNumberSchema, + zone_five_seconds: nullableNumberSchema, +}).strict(); + +export const whoopWorkoutReadSchema = z.object({ + workout_id: z.string().uuid(), + start_at: nullableDateTimeSchema, + end_at: nullableDateTimeSchema, + timezone_offset: z.string().nullable(), + sport_id: z.number().int().nullable(), + sport_name: z.string().nullable(), + score_state: whoopReadScoreStateSchema, + strain: nullableNumberSchema, + average_heart_rate: nullableNumberSchema, + max_heart_rate: nullableNumberSchema, + kilojoules: nullableNumberSchema, + energy_kcal_estimate: nullableNumberSchema, + percent_recorded: nullableNumberSchema, + distance_meter: nullableNumberSchema, + elevation_gain_meter: nullableNumberSchema, + zone_durations_seconds: whoopZoneDurationsSchema, + created_at: dateTimeSchema, + updated_at: dateTimeSchema, + synced_at: dateTimeSchema, +}).strict(); + +export const whoopProfileReadSchema = z.object({ + whoop_user_id: z.number().int().positive(), + first_name: z.string().nullable(), + last_name: z.string().nullable(), + email: z.string().nullable(), + created_at: nullableDateTimeSchema, + updated_at: nullableDateTimeSchema, + synced_at: dateTimeSchema, +}).strict(); + +export const whoopHealthPageSchema = (record: T) => z.object({ + records: z.array(record), + next_cursor: z.string().nullable(), +}).strict(); + +const whoopTrendPointSchema = z.object({ + date: dateSchema, + recovery_score: nullableNumberSchema, + strain: nullableNumberSchema, + sleep_performance_percentage: nullableNumberSchema, +}).strict(); + +const whoopSynchronizationSchema = z.object({ + status: z.enum([ + "not_connected", "connecting", "backfilling", "active", + "needs_reauth", "disconnected", "error", + ]), + last_success_at: nullableDateTimeSchema.optional(), + last_error_at: nullableDateTimeSchema.optional(), + consecutive_failure_count: z.number().int().nonnegative().optional(), + updated_at: nullableDateTimeSchema.optional(), + progress: z.array(z.object({ + resource: z.enum(["profile", "body_measurement", "cycle", "recovery", "sleep", "workout"]), + mode: z.enum(["backfill", "reconcile", "webhook"]), + status: z.enum(["queued", "running", "retrying", "complete", "error"]), + page_count: z.number().int().nonnegative(), + record_count: z.number().int().nonnegative(), + updated_at: dateTimeSchema, + }).strict()), + runs: z.array(z.object({ + run_id: z.string().uuid(), + trigger: z.string(), + status: z.enum(["queued", "running", "retrying", "complete", "error"]), + page_count: z.number().int().nonnegative(), + record_count: z.number().int().nonnegative(), + expected_target_count: z.number().int().nonnegative(), + completed_target_count: z.number().int().nonnegative(), + started_at: dateTimeSchema, + succeeded_at: dateTimeSchema.nullable(), + failed_at: dateTimeSchema.nullable(), + last_error: z.string().nullable(), + }).strict()), +}).strict(); + +export const whoopOverviewReadSchema = z.object({ + current_cycle: whoopCycleReadSchema.nullable(), + current_recovery: whoopRecoveryReadSchema.nullable(), + current_sleep: whoopSleepReadSchema.nullable(), + recent_workouts: z.array(whoopWorkoutReadSchema), + trends_7_days: z.array(whoopTrendPointSchema), + trends_30_days: z.array(whoopTrendPointSchema), + synchronization: whoopSynchronizationSchema, +}).strict(); + // OpenAPI helper functions export const openApiJsonContent = (schema: z.ZodTypeAny) => ({ "application/json": { schema }, diff --git a/src/schemas/whoop.ts b/src/schemas/whoop.ts new file mode 100644 index 0000000..916f1e0 --- /dev/null +++ b/src/schemas/whoop.ts @@ -0,0 +1,118 @@ +import { z } from "zod"; + +const whoopDateTime = z.string().datetime({ offset: true }); +const whoopUuid = z.string().uuid(); +const scoreStateSchema = z.enum(["SCORED", "PENDING_SCORE", "UNSCORABLE"]); + +export const whoopWebhookSchema = z.object({ + user_id: z.number().int().positive(), + id: whoopUuid, + type: z.enum([ + "workout.updated", + "workout.deleted", + "sleep.updated", + "sleep.deleted", + "recovery.updated", + "recovery.deleted", + ]), + trace_id: whoopUuid, +}).strict(); + +export const whoopCollectionQuerySchema = z.object({ + start: whoopDateTime.optional(), + end: whoopDateTime.optional(), + limit: z.string().regex(/^(?:[1-9]|[1-9][0-9]|100)$/).optional(), + cursor: z.string().regex(/^[A-Za-z0-9_-]+$/).refine((value) => value.length % 4 !== 1, { + message: "Invalid URL-safe base64 cursor", + }).optional(), +}).strict(); + +export const whoopProfileSchema = z.object({ + user_id: z.number().int().positive(), + email: z.string(), + first_name: z.string(), + last_name: z.string(), +}).passthrough(); + +export const whoopBodyMeasurementSchema = z.object({ + height_meter: z.number(), + weight_kilogram: z.number(), + max_heart_rate: z.number().int(), +}).passthrough(); + +export const whoopCycleSchema = z.object({ + id: z.number().int().positive(), + user_id: z.number().int().positive(), + start: whoopDateTime, + end: whoopDateTime.nullish(), + created_at: whoopDateTime, + updated_at: whoopDateTime, + timezone_offset: z.string(), + score_state: scoreStateSchema, +}).passthrough(); + +export const whoopRecoverySchema = z.object({ + sleep_id: whoopUuid, + cycle_id: z.number().int().positive(), + user_id: z.number().int().positive(), + created_at: whoopDateTime, + updated_at: whoopDateTime, + score_state: scoreStateSchema, +}).passthrough(); + +export const whoopSleepSchema = z.object({ + id: whoopUuid, + cycle_id: z.number().int().positive(), + user_id: z.number().int().positive(), + start: whoopDateTime, + end: whoopDateTime, + created_at: whoopDateTime, + updated_at: whoopDateTime, + timezone_offset: z.string(), + nap: z.boolean(), + score_state: scoreStateSchema, + score: z.object({ + stage_summary: z.object({ + total_in_bed_time_milli: z.number().int().optional(), + total_awake_time_milli: z.number().int().optional(), + total_no_data_time_milli: z.number().int().optional(), + total_light_sleep_time_milli: z.number().int().optional(), + total_slow_wave_sleep_time_milli: z.number().int().optional(), + total_rem_sleep_time_milli: z.number().int().optional(), + sleep_cycle_count: z.number().int().nonnegative().optional(), + disturbance_count: z.number().int().nonnegative().optional(), + }).passthrough().optional(), + sleep_needed: z.object({ + baseline_milli: z.number().int().optional(), + need_from_sleep_debt_milli: z.number().int().optional(), + need_from_recent_strain_milli: z.number().int().optional(), + need_from_recent_nap_milli: z.number().int().optional(), + }).passthrough().optional(), + }).passthrough().nullish(), +}).passthrough(); + +export const whoopWorkoutSchema = z.object({ + id: whoopUuid, + user_id: z.number().int().positive(), + start: whoopDateTime, + end: whoopDateTime, + created_at: whoopDateTime, + updated_at: whoopDateTime, + timezone_offset: z.string(), + sport_name: z.string(), + score_state: scoreStateSchema, +}).passthrough(); + +export const whoopCollectionResponseSchema = (recordSchema: T) => z.object({ + records: z.array(recordSchema), + next_token: z.string().optional(), +}).passthrough(); + +export type WhoopWebhook = z.infer; +export type WhoopCollectionQuery = z.infer; +export type WhoopProfile = z.infer; +export type WhoopBodyMeasurement = z.infer; +export type WhoopCycle = z.infer; +export type WhoopRecovery = z.infer; +export type WhoopSleep = z.infer; +export type WhoopWorkout = z.infer; diff --git a/src/services/whoop/client.ts b/src/services/whoop/client.ts new file mode 100644 index 0000000..a76e6c3 --- /dev/null +++ b/src/services/whoop/client.ts @@ -0,0 +1,352 @@ +import { z } from "zod"; +import { + whoopBodyMeasurementSchema, + whoopCollectionResponseSchema, + whoopCycleSchema, + whoopProfileSchema, + whoopRecoverySchema, + whoopSleepSchema, + whoopWorkoutSchema, +} from "../../schemas/whoop"; +import type { + WhoopBodyMeasurement, + WhoopCycle, + WhoopProfile, + WhoopRecovery, + WhoopSleep, + WhoopWorkout, +} from "../../schemas/whoop"; +import type { Env } from "../../types/env"; + +const WHOOP_BASE_URL = "https://api.prod.whoop.com"; +const WHOOP_DEVELOPER_V2_PATH = "/developer/v2"; +const WHOOP_TOKEN_PATH = "/oauth/oauth2/token"; + +const tokenResponseSchema = z.object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1), + expires_in: z.number().finite().nonnegative(), + token_type: z.string().min(1), + scope: z.string().optional(), +}).passthrough(); + +type WhoopCollectionResource = "cycle" | "recovery" | "sleep" | "workout"; + +interface WhoopCollectionRecordMap { + cycle: WhoopCycle; + recovery: WhoopRecovery; + sleep: WhoopSleep; + workout: WhoopWorkout; +} + +const collectionDefinitions = { + cycle: { path: "/cycle", schema: whoopCycleSchema }, + recovery: { path: "/recovery", schema: whoopRecoverySchema }, + sleep: { path: "/activity/sleep", schema: whoopSleepSchema }, + workout: { path: "/activity/workout", schema: whoopWorkoutSchema }, +} as const; + +export interface WhoopCollectionParams { + start?: string; + end?: string; + limit?: number; + nextToken?: string; +} + +export type WhoopProviderRecord = T & { rawJson: string }; + +export interface WhoopCollectionPage { + records: Array>; + nextToken?: string; +} + +export type WhoopTokenResponse = z.infer; + +export interface WhoopRefreshOptions { + signal?: AbortSignal; +} + +export class WhoopRequestError extends Error { + readonly name: string = "WhoopRequestError"; + + constructor( + readonly operation: string, + readonly status?: number, + readonly retryable = false, + readonly retryAfterSeconds?: number, + ) { + super(status === undefined + ? `WHOOP ${operation} request failed` + : `WHOOP ${operation} request failed with status ${status}`); + } +} + +export class WhoopUnauthorizedError extends WhoopRequestError { + readonly name = "WhoopUnauthorizedError"; + + constructor(operation: string) { + super(operation, 401); + } +} + +export class WhoopRefreshAmbiguousError extends WhoopRequestError { + readonly name = "WhoopRefreshAmbiguousError"; + readonly refreshOutcome = "ambiguous" as const; + + constructor( + operation: string, + status?: number, + retryable = false, + retryAfterSeconds?: number, + ) { + super(operation, status, retryable, retryAfterSeconds); + this.message = "WHOOP token refresh outcome is unknown"; + } +} + +export class WhoopRefreshDefiniteError extends WhoopRequestError { + readonly name = "WhoopRefreshDefiniteError"; + readonly refreshOutcome = "definite" as const; +} + +const retryAfterSeconds = (response: Response): number | undefined => { + const retryAfter = response.headers.get("retry-after"); + if (retryAfter) { + const seconds = Number(retryAfter); + if (Number.isFinite(seconds) && seconds >= 0) { + return Math.ceil(seconds); + } + + const retryAt = Date.parse(retryAfter); + if (!Number.isNaN(retryAt)) { + return Math.max(0, Math.ceil((retryAt - Date.now()) / 1000)); + } + } + + const rateLimitReset = response.headers.get("x-ratelimit-reset"); + if (!rateLimitReset) { + return undefined; + } + + const reset = Number(rateLimitReset); + if (!Number.isFinite(reset)) { + return undefined; + } + + const resetAtMilliseconds = reset > 1_000_000_000_000 ? reset : reset * 1000; + return Math.max(0, Math.ceil((resetAtMilliseconds - Date.now()) / 1000)); +}; + +const asProviderRecord = (payload: T, rawJson: string): WhoopProviderRecord => ({ + ...payload, + rawJson, +}); + +const parseProviderPayload = (schema: z.ZodType, payload: unknown, operation: string): WhoopProviderRecord => { + const rawJson = JSON.stringify(payload); + const parsed = schema.safeParse(payload); + if (!parsed.success) { + throw new Error(`WHOOP ${operation} response did not match the provider schema`); + } + return asProviderRecord(parsed.data, rawJson); +}; + +export class WhoopClient { + constructor( + private readonly env: Pick, + private readonly accessToken: string, + ) {} + + async exchangeAuthorizationCode(code: string): Promise { + return this.requestToken("token exchange", new URLSearchParams([ + ["grant_type", "authorization_code"], + ["code", code], + ["redirect_uri", this.env.WHOOP_REDIRECT_URI], + ["client_id", this.env.WHOOP_CLIENT_ID], + ["client_secret", this.env.WHOOP_CLIENT_SECRET], + ])); + } + + async refreshToken(refreshToken: string, options: WhoopRefreshOptions = {}): Promise { + return this.requestToken("token refresh", new URLSearchParams([ + ["grant_type", "refresh_token"], + ["refresh_token", refreshToken], + ["scope", "offline"], + ["client_id", this.env.WHOOP_CLIENT_ID], + ["client_secret", this.env.WHOOP_CLIENT_SECRET], + ]), options); + } + + async revokeAccess(accessToken: string): Promise { + await this.request("revoke access", "/user/access", { + method: "DELETE", + headers: { authorization: `Bearer ${accessToken}` }, + }); + } + + async getProfile(): Promise> { + return parseProviderPayload(whoopProfileSchema, await this.requestJson("profile", "/user/profile/basic"), "profile"); + } + + async getBodyMeasurements(): Promise> { + return parseProviderPayload( + whoopBodyMeasurementSchema, + await this.requestJson("body measurement", "/user/measurement/body"), + "body measurement", + ); + } + + async getCollection( + resource: R, + params: WhoopCollectionParams = {}, + ): Promise> { + const definition = collectionDefinitions[resource]; + const normalizedParams = { ...params, limit: params.limit ?? 25 }; + if (!Number.isInteger(normalizedParams.limit) || normalizedParams.limit < 1 || normalizedParams.limit > 25) { + throw new Error("WHOOP collection limit must be an integer from 1 to 25"); + } + const payload = await this.requestJson(`list ${resource}`, `${definition.path}${this.query(normalizedParams)}`); + const responseRecords = typeof payload === "object" && payload !== null + ? (payload as { records?: unknown }).records + : undefined; + const rawRecords = Array.isArray(responseRecords) + ? responseRecords.map((record) => JSON.stringify(record)) + : []; + const parsed = whoopCollectionResponseSchema(definition.schema).safeParse(payload); + if (!parsed.success) { + throw new Error(`WHOOP list ${resource} response did not match the provider schema`); + } + + return { + records: parsed.data.records.map((record, index) => asProviderRecord( + record, + rawRecords[index], + )) as Array>, + nextToken: parsed.data.next_token, + }; + } + + async getCycle(cycleId: number): Promise> { + this.assertCycleId(cycleId); + return parseProviderPayload(whoopCycleSchema, await this.requestJson("cycle", `/cycle/${cycleId}`), "cycle"); + } + + async getRecovery(cycleId: number): Promise> { + this.assertCycleId(cycleId); + return parseProviderPayload(whoopRecoverySchema, await this.requestJson("recovery", `/cycle/${cycleId}/recovery`), "recovery"); + } + + async getSleep(sleepId: string): Promise> { + this.assertActivityId(sleepId); + return parseProviderPayload(whoopSleepSchema, await this.requestJson("sleep", `/activity/sleep/${sleepId}`), "sleep"); + } + + async getWorkout(workoutId: string): Promise> { + this.assertActivityId(workoutId); + return parseProviderPayload(whoopWorkoutSchema, await this.requestJson("workout", `/activity/workout/${workoutId}`), "workout"); + } + + private async requestToken( + operation: string, + body: URLSearchParams, + options: WhoopRefreshOptions = {}, + ): Promise { + let payload: unknown; + try { + payload = await this.requestJson(operation, WHOOP_TOKEN_PATH, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: body.toString(), + signal: options.signal, + }, false); + } catch (error) { + if (operation !== "token refresh") throw error; + if (error instanceof WhoopRequestError + && error.status !== undefined + && error.status >= 400 + && error.status < 500) { + throw new WhoopRefreshDefiniteError( + operation, + error.status, + error.retryable, + error.retryAfterSeconds, + ); + } + if (error instanceof WhoopRequestError) { + throw new WhoopRefreshAmbiguousError( + operation, + error.status, + error.retryable, + error.retryAfterSeconds, + ); + } + throw new WhoopRefreshAmbiguousError(operation); + } + const parsed = tokenResponseSchema.safeParse(payload); + if (!parsed.success) { + if (operation === "token refresh") { + throw new WhoopRefreshAmbiguousError(operation, 200); + } + throw new Error(`WHOOP ${operation} response did not match the provider schema`); + } + return parsed.data; + } + + private async requestJson(operation: string, path: string, init?: RequestInit, includeBearer = true): Promise { + const response = await this.request(operation, path, init, includeBearer); + try { + return await response.json(); + } catch { + throw new Error(`WHOOP ${operation} response was not valid JSON`); + } + } + + private async request(operation: string, path: string, init: RequestInit = {}, includeBearer = true): Promise { + const headers = new Headers(init.headers); + if (includeBearer && !headers.has("authorization")) { + headers.set("authorization", `Bearer ${this.accessToken}`); + } + + let response: Response; + try { + response = await fetch(`${WHOOP_BASE_URL}${path.startsWith("/oauth/") ? path : `${WHOOP_DEVELOPER_V2_PATH}${path}`}`, { + ...init, + headers: Object.fromEntries(headers), + }); + } catch { + throw new WhoopRequestError(operation); + } + + if (response.ok) { + return response; + } + if (response.status === 401) { + throw new WhoopUnauthorizedError(operation); + } + + const retryable = response.status === 429 || (response.status >= 500 && response.status <= 599); + throw new WhoopRequestError(operation, response.status, retryable, retryAfterSeconds(response)); + } + + private query(params: WhoopCollectionParams): string { + const query = new URLSearchParams(); + if (params.start) query.set("start", params.start); + if (params.end) query.set("end", params.end); + if (params.limit !== undefined) query.set("limit", String(params.limit)); + if (params.nextToken) query.set("nextToken", params.nextToken); + const serialized = query.toString(); + return serialized ? `?${serialized}` : ""; + } + + private assertCycleId(cycleId: number): void { + if (!Number.isInteger(cycleId) || cycleId <= 0) { + throw new Error("WHOOP cycle ID must be a positive integer"); + } + } + + private assertActivityId(activityId: string): void { + if (!z.string().uuid().safeParse(activityId).success) { + throw new Error("WHOOP activity ID must be a UUID"); + } + } +} diff --git a/src/services/whoop/crypto.ts b/src/services/whoop/crypto.ts new file mode 100644 index 0000000..4d0c218 --- /dev/null +++ b/src/services/whoop/crypto.ts @@ -0,0 +1,126 @@ +const STATE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +const STATE_LENGTH = 8; +const STATE_REJECTION_LIMIT = Math.floor(256 / STATE_ALPHABET.length) * STATE_ALPHABET.length; +const AES_GCM_NONCE_BYTES = 12; +const AES_KEY_BYTES = 32; +const textEncoder = new TextEncoder(); + +export interface EncryptedToken { + ciphertext: string; + nonce: string; +} + +const bytesToBase64Url = (bytes: Uint8Array): string => + btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + +const base64UrlToBytes = (value: string): Uint8Array => { + if (!/^[A-Za-z0-9_-]*={0,2}$/.test(value)) { + throw new Error("invalid base64url value"); + } + + const unpadded = value.replace(/=+$/, ""); + if (unpadded.length % 4 === 1) { + throw new Error("invalid base64url value"); + } + + const padded = unpadded.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (unpadded.length % 4)) % 4); + const decoded = atob(padded); + return Uint8Array.from(decoded, (character) => character.charCodeAt(0)); +}; + +export const decodeWhoopTokenEncryptionKey = (keyMaterial: unknown): Uint8Array => { + if (typeof keyMaterial !== "string") { + throw new Error("WHOOP token encryption key must be 32 bytes"); + } + let keyBytes: Uint8Array; + try { + keyBytes = base64UrlToBytes(keyMaterial); + } catch { + throw new Error("WHOOP token encryption key must be 32 bytes"); + } + + if (keyBytes.byteLength !== AES_KEY_BYTES) { + throw new Error("WHOOP token encryption key must be 32 bytes"); + } + return keyBytes; +}; + +const importEncryptionKey = async (keyMaterial: string): Promise => { + return crypto.subtle.importKey( + "raw", + decodeWhoopTokenEncryptionKey(keyMaterial), + { name: "AES-GCM" }, + false, + ["encrypt", "decrypt"], + ); +}; + +const additionalData = (whoopUserId: number, kind: "access" | "refresh"): Uint8Array => + textEncoder.encode(`${whoopUserId}:${kind}`); + +export async function hashOAuthState(state: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", textEncoder.encode(state)); + return bytesToBase64Url(new Uint8Array(digest)); +} + +export async function createOAuthState(): Promise { + let state = ""; + + while (state.length < STATE_LENGTH) { + const randomBytes = crypto.getRandomValues(new Uint8Array(STATE_LENGTH - state.length)); + for (const randomByte of randomBytes) { + if (randomByte < STATE_REJECTION_LIMIT) { + state += STATE_ALPHABET[randomByte % STATE_ALPHABET.length]; + } + } + } + + return state; +} + +export async function encryptWhoopToken( + keyMaterial: string, + whoopUserId: number, + kind: "access" | "refresh", + plaintext: string, +): Promise { + const key = await importEncryptionKey(keyMaterial); + const nonce = crypto.getRandomValues(new Uint8Array(AES_GCM_NONCE_BYTES)); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: nonce, additionalData: additionalData(whoopUserId, kind) }, + key, + textEncoder.encode(plaintext), + ); + + return { + ciphertext: bytesToBase64Url(new Uint8Array(ciphertext)), + nonce: bytesToBase64Url(nonce), + }; +} + +export async function decryptWhoopToken( + keyMaterial: string, + whoopUserId: number, + kind: "access" | "refresh", + encrypted: EncryptedToken, +): Promise { + try { + const key = await importEncryptionKey(keyMaterial); + const plaintext = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: base64UrlToBytes(encrypted.nonce), + additionalData: additionalData(whoopUserId, kind), + }, + key, + base64UrlToBytes(encrypted.ciphertext), + ); + + return new TextDecoder().decode(plaintext); + } catch { + throw new Error("WHOOP token decryption failed"); + } +} diff --git a/src/services/whoop/read-cursor.ts b/src/services/whoop/read-cursor.ts new file mode 100644 index 0000000..a5fcfbc --- /dev/null +++ b/src/services/whoop/read-cursor.ts @@ -0,0 +1,172 @@ +import { decodeWhoopTokenEncryptionKey } from "./crypto"; + +export const WHOOP_READ_CURSOR_MAX_LENGTH = 1024; +export const WHOOP_READ_ORDER = "provider_time_desc_id_desc" as const; + +export type WhoopCursorResource = "cycles" | "recoveries" | "sleeps" | "workouts"; + +export interface WhoopReadAnchor { + sortAt: string; + id: string; +} + +interface CursorPayload { + version: 1; + resource: WhoopCursorResource; + start: string | null; + end: string | null; + order: typeof WHOOP_READ_ORDER; + anchor: { + sort_at: string; + id: string; + }; +} + +interface CursorEnvelope { + payload: CursorPayload; + signature: string; +} + +const encoder = new TextEncoder(); + +const base64UrlEncode = (bytes: Uint8Array): string => + btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); + +const base64UrlDecode = (value: string): Uint8Array | null => { + if (!/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return null; + try { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/") + + "=".repeat((4 - (value.length % 4)) % 4); + const decoded = atob(base64); + const bytes = Uint8Array.from(decoded, (character) => character.charCodeAt(0)); + if (base64UrlEncode(bytes) !== value) return null; + return bytes; + } catch { + return null; + } +}; + +const sign = async (secret: string, payload: string): Promise => { + const key = await crypto.subtle.importKey( + "raw", + decodeWhoopTokenEncryptionKey(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return base64UrlEncode(new Uint8Array(await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(`whoop-read-cursor:v1:${payload}`), + ))); +}; + +const sameString = (left: string, right: string): boolean => { + let difference = left.length ^ right.length; + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + difference |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0); + } + return difference === 0; +}; + +const exactKeys = (value: Record, keys: readonly string[]): boolean => { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length + && actual.every((key, index) => key === expected[index]); +}; + +const recordOf = (value: unknown): Record | null => + typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : null; + +const isCanonicalTimestamp = (value: unknown): value is string => { + if (typeof value !== "string" || value.length > 30) return false; + const milliseconds = Date.parse(value); + return !Number.isNaN(milliseconds) && new Date(milliseconds).toISOString() === value; +}; + +const validId = (resource: WhoopCursorResource, value: unknown): value is string => { + if (typeof value !== "string" || value.length > 64) return false; + if (resource === "cycles") return /^(?:[1-9][0-9]*)$/.test(value); + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +}; + +const parsePayload = (value: unknown): CursorPayload | null => { + const payload = recordOf(value); + if (!payload || !exactKeys(payload, ["version", "resource", "start", "end", "order", "anchor"])) return null; + if (payload.version !== 1 + || !["cycles", "recoveries", "sleeps", "workouts"].includes(String(payload.resource)) + || payload.order !== WHOOP_READ_ORDER) return null; + const resource = payload.resource as WhoopCursorResource; + const start = payload.start; + const end = payload.end; + if (!(start === null || isCanonicalTimestamp(start)) + || !(end === null || isCanonicalTimestamp(end))) return null; + const anchor = recordOf(payload.anchor); + if (!anchor || !exactKeys(anchor, ["sort_at", "id"]) + || !isCanonicalTimestamp(anchor.sort_at) + || !validId(resource, anchor.id)) return null; + return { + version: 1, + resource, + start: start as string | null, + end: end as string | null, + order: WHOOP_READ_ORDER, + anchor: { sort_at: anchor.sort_at, id: anchor.id }, + }; +}; + +export const encodeWhoopReadCursor = async ( + secret: string, + resource: WhoopCursorResource, + start: string | null, + end: string | null, + anchor: WhoopReadAnchor, +): Promise => { + const payload: CursorPayload = { + version: 1, + resource, + start, + end, + order: WHOOP_READ_ORDER, + anchor: { sort_at: anchor.sortAt, id: anchor.id }, + }; + const serializedPayload = JSON.stringify(payload); + const envelope: CursorEnvelope = { + payload, + signature: await sign(secret, serializedPayload), + }; + return base64UrlEncode(encoder.encode(JSON.stringify(envelope))); +}; + +export const decodeWhoopReadCursor = async ( + value: string, + secret: string, + resource: WhoopCursorResource, + start: string | null, + end: string | null, +): Promise => { + if (value.length === 0 || value.length > WHOOP_READ_CURSOR_MAX_LENGTH) return null; + const decoded = base64UrlDecode(value); + if (!decoded) return null; + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(decoded)); + } catch { + return null; + } + const envelope = recordOf(parsed); + if (!envelope || !exactKeys(envelope, ["payload", "signature"]) + || typeof envelope.signature !== "string" || envelope.signature.length !== 43) return null; + const payload = parsePayload(envelope.payload); + if (!payload || payload.resource !== resource || payload.start !== start || payload.end !== end) return null; + const expected = await sign(secret, JSON.stringify(payload)); + if (!sameString(envelope.signature, expected)) return null; + return { sortAt: payload.anchor.sort_at, id: payload.anchor.id }; +}; diff --git a/src/services/whoop/read-repository.ts b/src/services/whoop/read-repository.ts new file mode 100644 index 0000000..87e0327 --- /dev/null +++ b/src/services/whoop/read-repository.ts @@ -0,0 +1,651 @@ +export type WhoopReadScoreState = "scored" | "pending" | "unscorable"; + +export interface WhoopWorkoutReadModel { + workout_id: string; + start_at: string | null; + end_at: string | null; + timezone_offset: string | null; + sport_id: number | null; + sport_name: string | null; + score_state: WhoopReadScoreState; + strain: number | null; + average_heart_rate: number | null; + max_heart_rate: number | null; + kilojoules: number | null; + energy_kcal_estimate: number | null; + percent_recorded: number | null; + distance_meter: number | null; + elevation_gain_meter: number | null; + zone_durations_seconds: { + zone_zero_seconds: number | null; + zone_one_seconds: number | null; + zone_two_seconds: number | null; + zone_three_seconds: number | null; + zone_four_seconds: number | null; + zone_five_seconds: number | null; + }; + created_at: string; + updated_at: string; + synced_at: string; +} + +export interface WhoopCycleReadModel { + cycle_id: number; + start_at: string; + end_at: string | null; + timezone_offset: string | null; + score_state: WhoopReadScoreState; + strain: number | null; + kilojoules: number | null; + energy_kcal_estimate: number | null; + average_heart_rate: number | null; + max_heart_rate: number | null; + created_at: string; + updated_at: string; + synced_at: string; +} + +export interface WhoopRecoveryReadModel { + sleep_id: string; + cycle_id: number; + score_state: WhoopReadScoreState; + user_calibrating: boolean | null; + score: number | null; + resting_heart_rate: number | null; + hrv_rmssd_milliseconds: number | null; + spo2_percentage: number | null; + skin_temperature_celsius: number | null; + created_at: string; + updated_at: string; + synced_at: string; +} + +export interface WhoopSleepReadModel { + sleep_id: string; + cycle_id: number; + start_at: string | null; + end_at: string | null; + timezone_offset: string | null; + nap: boolean | null; + score_state: WhoopReadScoreState; + stage_durations_seconds: { + in_bed_seconds: number | null; + awake_seconds: number | null; + no_data_seconds: number | null; + light_seconds: number | null; + slow_wave_seconds: number | null; + rem_seconds: number | null; + }; + sleep_need_seconds: { + baseline_seconds: number | null; + debt_seconds: number | null; + recent_strain_seconds: number | null; + recent_nap_seconds: number | null; + }; + sleep_cycle_count: number | null; + disturbance_count: number | null; + sleep_efficiency_percentage: number | null; + sleep_consistency_percentage: number | null; + sleep_performance_percentage: number | null; + respiratory_rate: number | null; + created_at: string; + updated_at: string; + synced_at: string; +} + +export interface WhoopProfileReadModel { + whoop_user_id: number; + first_name: string | null; + last_name: string | null; + email: string | null; + created_at: string | null; + updated_at: string | null; + synced_at: string; +} + +export interface WhoopTrendPoint { + date: string; + recovery_score: number | null; + strain: number | null; + sleep_performance_percentage: number | null; +} + +export interface WhoopOverviewReadModel { + current_cycle: WhoopCycleReadModel | null; + current_recovery: WhoopRecoveryReadModel | null; + current_sleep: WhoopSleepReadModel | null; + recent_workouts: WhoopWorkoutReadModel[]; + trends_7_days: WhoopTrendPoint[]; + trends_30_days: WhoopTrendPoint[]; +} + +export interface WhoopReadContext { + whoopUserId: number; + status: "connecting" | "backfilling" | "active" | "needs_reauth" | "disconnected" | "error"; + last_success_at: string | null; + last_error_at: string | null; + consecutive_failure_count: number; + updated_at: string; + progress: Array<{ + resource: "profile" | "body_measurement" | "cycle" | "recovery" | "sleep" | "workout"; + mode: "backfill" | "reconcile" | "webhook"; + status: "queued" | "running" | "retrying" | "complete" | "error"; + page_count: number; + record_count: number; + updated_at: string; + }>; + runs: Array<{ + run_id: string; + trigger: string; + status: "queued" | "running" | "retrying" | "complete" | "error"; + page_count: number; + record_count: number; + expected_target_count: number; + completed_target_count: number; + started_at: string; + succeeded_at: string | null; + failed_at: string | null; + last_error: string | null; + }>; +} + +export interface WhoopReadPage { + records: T[]; + nextAnchor: { sortAt: string; id: string } | null; +} + +interface WorkoutRow { + workout_id: string; + start_at: string | null; + end_at: string | null; + timezone_offset: string | null; + sport_id: number | null; + sport_name: string | null; + score_state: string; + strain: number | null; + average_heart_rate: number | null; + max_heart_rate: number | null; + kilojoules: number | null; + percent_recorded: number | null; + distance_meter: number | null; + elevation_gain_meter: number | null; + zone_zero_milliseconds: number | null; + zone_one_milliseconds: number | null; + zone_two_milliseconds: number | null; + zone_three_milliseconds: number | null; + zone_four_milliseconds: number | null; + zone_five_milliseconds: number | null; + upstream_created_at: string; + upstream_updated_at: string; + synced_at: string; + sort_at: string; +} + +interface CycleRow { + cycle_id: number; + start_at: string; + end_at: string | null; + timezone_offset: string | null; + score_state: string; + strain: number | null; + kilojoules: number | null; + average_heart_rate: number | null; + max_heart_rate: number | null; + upstream_created_at: string; + upstream_updated_at: string; + synced_at: string; + sort_at: string; +} + +interface RecoveryRow { + sleep_id: string; + cycle_id: number; + score_state: string; + user_calibrating: number | null; + recovery_score: number | null; + resting_heart_rate: number | null; + hrv_rmssd_milliseconds: number | null; + spo2_percentage: number | null; + skin_temperature_celsius: number | null; + upstream_created_at: string; + upstream_updated_at: string; + synced_at: string; + sort_at: string; +} + +interface SleepRow { + sleep_id: string; + cycle_id: number; + start_at: string | null; + end_at: string | null; + timezone_offset: string | null; + nap: number | null; + score_state: string; + stage_awake_milliseconds: number | null; + stage_in_bed_milliseconds: number | null; + stage_no_data_milliseconds: number | null; + stage_light_milliseconds: number | null; + stage_slow_wave_milliseconds: number | null; + stage_rem_milliseconds: number | null; + sleep_needed_milliseconds: number | null; + sleep_debt_milliseconds: number | null; + sleep_need_recent_strain_milliseconds: number | null; + sleep_need_recent_nap_milliseconds: number | null; + sleep_cycle_count: number | null; + disturbance_count: number | null; + sleep_efficiency_percentage: number | null; + sleep_consistency_percentage: number | null; + sleep_performance_percentage: number | null; + respiratory_rate: number | null; + upstream_created_at: string; + upstream_updated_at: string; + synced_at: string; + sort_at: string; +} + +interface CollectionOptions { + start: string | null; + end: string | null; + limit: number; + cursor: { sortAt: string; id: string } | null; +} + +const scoreState = (value: string): WhoopReadScoreState => { + if (value === "SCORED") return "scored"; + if (value === "PENDING_SCORE") return "pending"; + return "unscorable"; +}; + +const seconds = (milliseconds: number | null): number | null => + milliseconds === null ? null : Math.round(milliseconds / 1_000); + +const kcalEstimate = (kilojoules: number | null): number | null => + kilojoules === null ? null : Math.round((kilojoules / 4.184) * 100) / 100; + +const scoredValue = (state: string, value: T | null): T | null => + state === "SCORED" ? value : null; + +const workoutReadModel = (row: WorkoutRow): WhoopWorkoutReadModel => { + const kilojoules = scoredValue(row.score_state, row.kilojoules); + return { + workout_id: row.workout_id, + start_at: row.start_at, + end_at: row.end_at, + timezone_offset: row.timezone_offset, + sport_id: row.sport_id, + sport_name: row.sport_name, + score_state: scoreState(row.score_state), + strain: scoredValue(row.score_state, row.strain), + average_heart_rate: scoredValue(row.score_state, row.average_heart_rate), + max_heart_rate: scoredValue(row.score_state, row.max_heart_rate), + kilojoules, + energy_kcal_estimate: kcalEstimate(kilojoules), + percent_recorded: scoredValue(row.score_state, row.percent_recorded), + distance_meter: scoredValue(row.score_state, row.distance_meter), + elevation_gain_meter: scoredValue(row.score_state, row.elevation_gain_meter), + zone_durations_seconds: { + zone_zero_seconds: seconds(scoredValue(row.score_state, row.zone_zero_milliseconds)), + zone_one_seconds: seconds(scoredValue(row.score_state, row.zone_one_milliseconds)), + zone_two_seconds: seconds(scoredValue(row.score_state, row.zone_two_milliseconds)), + zone_three_seconds: seconds(scoredValue(row.score_state, row.zone_three_milliseconds)), + zone_four_seconds: seconds(scoredValue(row.score_state, row.zone_four_milliseconds)), + zone_five_seconds: seconds(scoredValue(row.score_state, row.zone_five_milliseconds)), + }, + created_at: row.upstream_created_at, + updated_at: row.upstream_updated_at, + synced_at: row.synced_at, + }; +}; + +const cycleReadModel = (row: CycleRow): WhoopCycleReadModel => ({ + cycle_id: row.cycle_id, + start_at: row.start_at, + end_at: row.end_at, + timezone_offset: row.timezone_offset, + score_state: scoreState(row.score_state), + strain: scoredValue(row.score_state, row.strain), + kilojoules: scoredValue(row.score_state, row.kilojoules), + energy_kcal_estimate: kcalEstimate(scoredValue(row.score_state, row.kilojoules)), + average_heart_rate: scoredValue(row.score_state, row.average_heart_rate), + max_heart_rate: scoredValue(row.score_state, row.max_heart_rate), + created_at: row.upstream_created_at, + updated_at: row.upstream_updated_at, + synced_at: row.synced_at, +}); + +const recoveryReadModel = (row: RecoveryRow): WhoopRecoveryReadModel => ({ + sleep_id: row.sleep_id, + cycle_id: row.cycle_id, + score_state: scoreState(row.score_state), + user_calibrating: row.user_calibrating === null ? null : row.user_calibrating === 1, + score: scoredValue(row.score_state, row.recovery_score), + resting_heart_rate: scoredValue(row.score_state, row.resting_heart_rate), + hrv_rmssd_milliseconds: scoredValue(row.score_state, row.hrv_rmssd_milliseconds), + spo2_percentage: scoredValue(row.score_state, row.spo2_percentage), + skin_temperature_celsius: scoredValue(row.score_state, row.skin_temperature_celsius), + created_at: row.upstream_created_at, + updated_at: row.upstream_updated_at, + synced_at: row.synced_at, +}); + +const sleepReadModel = (row: SleepRow): WhoopSleepReadModel => ({ + sleep_id: row.sleep_id, + cycle_id: row.cycle_id, + start_at: row.start_at, + end_at: row.end_at, + timezone_offset: row.timezone_offset, + nap: row.nap === null ? null : row.nap === 1, + score_state: scoreState(row.score_state), + stage_durations_seconds: { + in_bed_seconds: seconds(scoredValue(row.score_state, row.stage_in_bed_milliseconds)), + awake_seconds: seconds(scoredValue(row.score_state, row.stage_awake_milliseconds)), + no_data_seconds: seconds(scoredValue(row.score_state, row.stage_no_data_milliseconds)), + light_seconds: seconds(scoredValue(row.score_state, row.stage_light_milliseconds)), + slow_wave_seconds: seconds(scoredValue(row.score_state, row.stage_slow_wave_milliseconds)), + rem_seconds: seconds(scoredValue(row.score_state, row.stage_rem_milliseconds)), + }, + sleep_need_seconds: { + baseline_seconds: seconds(scoredValue(row.score_state, row.sleep_needed_milliseconds)), + debt_seconds: seconds(scoredValue(row.score_state, row.sleep_debt_milliseconds)), + recent_strain_seconds: seconds(scoredValue(row.score_state, row.sleep_need_recent_strain_milliseconds)), + recent_nap_seconds: seconds(scoredValue(row.score_state, row.sleep_need_recent_nap_milliseconds)), + }, + sleep_cycle_count: scoredValue(row.score_state, row.sleep_cycle_count), + disturbance_count: scoredValue(row.score_state, row.disturbance_count), + sleep_efficiency_percentage: scoredValue(row.score_state, row.sleep_efficiency_percentage), + sleep_consistency_percentage: scoredValue(row.score_state, row.sleep_consistency_percentage), + sleep_performance_percentage: scoredValue(row.score_state, row.sleep_performance_percentage), + respiratory_rate: scoredValue(row.score_state, row.respiratory_rate), + created_at: row.upstream_created_at, + updated_at: row.upstream_updated_at, + synced_at: row.synced_at, +}); + +export class WhoopHealthReadRepository { + constructor(private readonly db: D1Database) {} + + async getReadContext(): Promise { + const connection = await this.db.prepare(` + SELECT whoop_user_id, status, last_success_at, last_error_at, + consecutive_failure_count, updated_at + FROM whoop_connections + ORDER BY CASE WHEN status = 'disconnected' THEN 1 ELSE 0 END, + connected_at DESC, whoop_user_id DESC + LIMIT 1 + `).first<{ + whoop_user_id: number; + status: WhoopReadContext["status"]; + last_success_at: string | null; + last_error_at: string | null; + consecutive_failure_count: number; + updated_at: string; + }>(); + if (!connection) return null; + const [progress, runs] = await Promise.all([this.db.prepare(` + SELECT resource, mode, status, page_count, record_count, updated_at + FROM ( + SELECT checkpoint.resource, checkpoint.mode, checkpoint.status, + checkpoint.page_count, checkpoint.record_count, checkpoint.updated_at, + ROW_NUMBER() OVER ( + PARTITION BY checkpoint.resource, checkpoint.mode + ORDER BY checkpoint.reconcile_generation DESC, + checkpoint.created_at DESC, + checkpoint.page_count DESC, + checkpoint.record_count DESC + ) AS row_number + FROM whoop_sync_checkpoints AS checkpoint + INNER JOIN whoop_connections AS current_connection + ON current_connection.whoop_user_id = checkpoint.whoop_user_id + AND current_connection.connection_id = checkpoint.connection_id + WHERE checkpoint.whoop_user_id = ? AND checkpoint.target_id = '' + ) + WHERE row_number = 1 + ORDER BY resource ASC, mode ASC + `).bind(connection.whoop_user_id).all(), this.db.prepare(` + SELECT run.run_id, run.trigger, run.status, run.page_count, run.record_count, + run.expected_target_count, run.completed_target_count, run.started_at, + run.succeeded_at, run.failed_at, run.last_error + FROM whoop_sync_runs AS run + INNER JOIN whoop_connections AS current_connection + ON current_connection.whoop_user_id = run.whoop_user_id + AND current_connection.connection_id = run.connection_id + WHERE run.whoop_user_id = ? + ORDER BY run.started_at DESC, run.run_id DESC + LIMIT 10 + `).bind(connection.whoop_user_id).all()]); + return { + whoopUserId: connection.whoop_user_id, + status: connection.status, + last_success_at: connection.last_success_at, + last_error_at: connection.last_error_at, + consecutive_failure_count: connection.consecutive_failure_count, + updated_at: connection.updated_at, + progress: progress.results, + runs: runs.results.map((run) => ({ + ...run, + last_error: run.last_error === null + ? null + : run.last_error.replace(/[\r\n\t]+/g, " ").slice(0, 240), + })), + }; + } + + private async listRows(input: { + table: string; + columns: string; + keyColumn: string; + timeExpression: string; + whoopUserId: number; + options: CollectionOptions; + }): Promise<{ rows: T[]; nextAnchor: { sortAt: string; id: string } | null }> { + const instantExpression = `julianday(${input.timeExpression})`; + const canonicalSortExpression = `strftime('%Y-%m-%dT%H:%M:%fZ', ${input.timeExpression})`; + const filters = ["whoop_user_id = ?", "deleted_at IS NULL"]; + const bindings: unknown[] = [input.whoopUserId]; + if (input.options.start !== null) { + filters.push(`${instantExpression} >= julianday(?)`); + bindings.push(input.options.start); + } + if (input.options.end !== null) { + filters.push(`${instantExpression} <= julianday(?)`); + bindings.push(input.options.end); + } + if (input.options.cursor !== null) { + filters.push(`(${instantExpression} < julianday(?) OR (${instantExpression} = julianday(?) AND ${input.keyColumn} < ?))`); + bindings.push( + input.options.cursor.sortAt, + input.options.cursor.sortAt, + input.keyColumn === "cycle_id" ? Number(input.options.cursor.id) : input.options.cursor.id, + ); + } + bindings.push(input.options.limit + 1); + const result = await this.db.prepare(` + SELECT ${input.columns}, ${canonicalSortExpression} AS sort_at + FROM ${input.table} + WHERE ${filters.join(" AND ")} + ORDER BY ${instantExpression} DESC, ${input.keyColumn} DESC + LIMIT ? + `).bind(...bindings).all>(); + const rows = result.results.slice(0, input.options.limit) as T[]; + const last = rows.at(-1) as (T & Record) | undefined; + return { + rows, + nextAnchor: result.results.length > input.options.limit && last + ? { sortAt: last.sort_at, id: String(last[input.keyColumn]) } + : null, + }; + } + + async listCycles( + whoopUserId: number, + options: CollectionOptions, + ): Promise> { + const page = await this.listRows({ + table: "whoop_cycles", + columns: `cycle_id, start_at, end_at, timezone_offset, score_state, strain, + kilojoules, average_heart_rate, max_heart_rate, upstream_created_at, + upstream_updated_at, synced_at`, + keyColumn: "cycle_id", + timeExpression: "start_at", + whoopUserId, + options, + }); + return { records: page.rows.map(cycleReadModel), nextAnchor: page.nextAnchor }; + } + + async listRecoveries( + whoopUserId: number, + options: CollectionOptions, + ): Promise> { + const page = await this.listRows({ + table: "whoop_recoveries", + columns: `sleep_id, cycle_id, score_state, user_calibrating, recovery_score, + resting_heart_rate, hrv_rmssd_milliseconds, spo2_percentage, + skin_temperature_celsius, upstream_created_at, upstream_updated_at, synced_at`, + keyColumn: "sleep_id", + timeExpression: "upstream_created_at", + whoopUserId, + options, + }); + return { records: page.rows.map(recoveryReadModel), nextAnchor: page.nextAnchor }; + } + + async listSleeps( + whoopUserId: number, + options: CollectionOptions, + ): Promise> { + const page = await this.listRows({ + table: "whoop_sleeps", + columns: `sleep_id, cycle_id, start_at, end_at, timezone_offset, nap, score_state, + stage_awake_milliseconds, stage_light_milliseconds, stage_slow_wave_milliseconds, + stage_rem_milliseconds, stage_in_bed_milliseconds, stage_no_data_milliseconds, + sleep_needed_milliseconds, sleep_debt_milliseconds, + sleep_need_recent_strain_milliseconds, sleep_need_recent_nap_milliseconds, + sleep_cycle_count, disturbance_count, + sleep_efficiency_percentage, sleep_consistency_percentage, + sleep_performance_percentage, respiratory_rate, upstream_created_at, + upstream_updated_at, synced_at`, + keyColumn: "sleep_id", + timeExpression: "COALESCE(start_at, upstream_created_at)", + whoopUserId, + options, + }); + return { records: page.rows.map(sleepReadModel), nextAnchor: page.nextAnchor }; + } + + async listWorkouts( + whoopUserId: number, + options: CollectionOptions, + ): Promise> { + const page = await this.listRows({ + table: "whoop_workouts", + columns: `workout_id, start_at, end_at, timezone_offset, sport_id, sport_name, + score_state, strain, average_heart_rate, max_heart_rate, kilojoules, + percent_recorded, distance_meter, elevation_gain_meter, + zone_zero_milliseconds, zone_one_milliseconds, zone_two_milliseconds, + zone_three_milliseconds, zone_four_milliseconds, zone_five_milliseconds, + upstream_created_at, upstream_updated_at, synced_at`, + keyColumn: "workout_id", + timeExpression: "COALESCE(start_at, upstream_created_at)", + whoopUserId, + options, + }); + return { records: page.rows.map(workoutReadModel), nextAnchor: page.nextAnchor }; + } + + async getWorkout(whoopUserId: number, workoutId: string): Promise { + const row = await this.db.prepare(` + SELECT workout_id, start_at, end_at, timezone_offset, sport_id, sport_name, + score_state, strain, average_heart_rate, max_heart_rate, kilojoules, + percent_recorded, distance_meter, elevation_gain_meter, + zone_zero_milliseconds, zone_one_milliseconds, zone_two_milliseconds, + zone_three_milliseconds, zone_four_milliseconds, zone_five_milliseconds, + upstream_created_at, upstream_updated_at, synced_at, + COALESCE(start_at, upstream_created_at) AS sort_at + FROM whoop_workouts + WHERE whoop_user_id = ? AND workout_id = ? AND deleted_at IS NULL + `).bind(whoopUserId, workoutId).first(); + return row ? workoutReadModel(row) : null; + } + + async getProfile(whoopUserId: number): Promise { + const row = await this.db.prepare(` + SELECT whoop_user_id, first_name, last_name, email, upstream_created_at, + upstream_updated_at, synced_at + FROM whoop_profiles + WHERE whoop_user_id = ? AND deleted_at IS NULL + `).bind(whoopUserId).first<{ + whoop_user_id: number; + first_name: string | null; + last_name: string | null; + email: string | null; + upstream_created_at: string | null; + upstream_updated_at: string | null; + synced_at: string; + }>(); + return row ? { + whoop_user_id: row.whoop_user_id, + first_name: row.first_name, + last_name: row.last_name, + email: row.email, + created_at: row.upstream_created_at, + updated_at: row.upstream_updated_at, + synced_at: row.synced_at, + } : null; + } + + async getOverview(whoopUserId: number, now: Date): Promise { + const end = now.toISOString(); + const currentUtcDate = new Date(Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + )); + const thirtyDayStartDate = new Date(currentUtcDate); + thirtyDayStartDate.setUTCDate(thirtyDayStartDate.getUTCDate() - 29); + const thirtyDayStart = thirtyDayStartDate.toISOString(); + const noCursor = { end, limit: 100, cursor: null } as const; + const [currentCycles, cycles, recoveries, sleeps, workouts] = await Promise.all([ + this.listCycles(whoopUserId, { start: null, end, limit: 1, cursor: null }), + this.listCycles(whoopUserId, { ...noCursor, start: thirtyDayStart }), + this.listRecoveries(whoopUserId, { ...noCursor, start: thirtyDayStart }), + this.listSleeps(whoopUserId, { ...noCursor, start: thirtyDayStart }), + this.listWorkouts(whoopUserId, { start: null, end, limit: 5, cursor: null }), + ]); + const currentCycle = currentCycles.records[0] ?? null; + const currentRecovery = currentCycle + ? recoveries.records.find((record) => record.cycle_id === currentCycle.cycle_id) ?? null + : recoveries.records[0] ?? null; + const currentSleep = currentCycle && currentRecovery + ? sleeps.records.find((record) => + record.cycle_id === currentCycle.cycle_id && record.sleep_id === currentRecovery.sleep_id + ) ?? null + : null; + const recoveryByCycle = new Map(); + for (const recovery of recoveries.records) { + if (!recoveryByCycle.has(recovery.cycle_id)) recoveryByCycle.set(recovery.cycle_id, recovery); + } + const sleepByCycle = new Map(); + for (const sleep of sleeps.records) { + if (sleep.nap !== true && !sleepByCycle.has(sleep.cycle_id)) sleepByCycle.set(sleep.cycle_id, sleep); + } + const trends30 = cycles.records.slice().reverse().map((cycle) => ({ + date: new Date(cycle.start_at).toISOString().slice(0, 10), + recovery_score: recoveryByCycle.get(cycle.cycle_id)?.score ?? null, + strain: cycle.strain, + sleep_performance_percentage: + sleepByCycle.get(cycle.cycle_id)?.sleep_performance_percentage ?? null, + })); + const sevenDayStartDate = new Date(currentUtcDate); + sevenDayStartDate.setUTCDate(sevenDayStartDate.getUTCDate() - 6); + const sevenDayDate = sevenDayStartDate.toISOString().slice(0, 10); + return { + current_cycle: currentCycle, + current_recovery: currentRecovery, + current_sleep: currentSleep, + recent_workouts: workouts.records, + trends_7_days: trends30.filter((point) => point.date >= sevenDayDate), + trends_30_days: trends30, + }; + } +} diff --git a/src/services/whoop/repository.ts b/src/services/whoop/repository.ts new file mode 100644 index 0000000..b6e872c --- /dev/null +++ b/src/services/whoop/repository.ts @@ -0,0 +1,2106 @@ +import type { + WhoopBodyMeasurement, + WhoopCycle, + WhoopProfile, + WhoopRecovery, + WhoopSleep, + WhoopWorkout, +} from "../../schemas/whoop"; +import type { + WhoopConnectionStatus, + WhoopResource, + WhoopWebhookEventType, +} from "../../types/whoop"; +import { + WhoopRefreshAmbiguousError, + WhoopUnauthorizedError, + type WhoopTokenResponse, +} from "./client"; +import { + decryptWhoopToken, + encryptWhoopToken, + type EncryptedToken, +} from "./crypto"; + +const REFRESH_LEASE_MILLISECONDS = 30_000; +const REFRESH_ABORT_MILLISECONDS = 20_000; +const REFRESH_WAIT_MILLISECONDS = 100; +export const WHOOP_OPERATIONAL_RETENTION = { + oauthStateMilliseconds: 24 * 60 * 60 * 1_000, + reconcileSeenMilliseconds: 24 * 60 * 60 * 1_000, + checkpointMilliseconds: 30 * 24 * 60 * 60 * 1_000, + syncRunMilliseconds: 30 * 24 * 60 * 60 * 1_000, + processedWebhookMilliseconds: 30 * 24 * 60 * 60 * 1_000, + abandonedWorkMilliseconds: 24 * 60 * 60 * 1_000, + deleteLimit: 100, +} as const; + +export type TombstonePolicy = "preserve" | "reconcile"; + +type PersistableProviderRecord = T & { rawJson?: string }; +type ProfileRecord = PersistableProviderRecord; +type BodyMeasurementRecord = PersistableProviderRecord & { + whoop_user_id?: number; + user_id?: number; +}; + +interface SourceRecordMap { + profile: ProfileRecord; + body_measurement: BodyMeasurementRecord; + cycle: PersistableProviderRecord; + recovery: PersistableProviderRecord; + sleep: PersistableProviderRecord; + workout: PersistableProviderRecord; +} + +export interface UpsertConnectionInput { + whoopUserId: number; + connectionId: string; + status: Exclude; + accessToken: EncryptedToken; + accessTokenExpiresAt: string; + refreshToken: EncryptedToken; + grantedScopes: readonly string[]; + connectedAt: string; + updatedAt?: string; + initialBackfillPending?: boolean; +} + +export interface RotatedTokenInput { + accessToken: EncryptedToken; + accessTokenExpiresAt: string; + refreshToken: EncryptedToken; + grantedScopes: readonly string[]; + refreshedAt: string; +} + +export interface SyncRunInput { + runId: string; + whoopUserId: number; + connectionId: string; + reconcileGeneration: number; + trigger: string; + expectedTargetCount: number; + startedAt: string; +} + +export interface SyncRunProjection { + run_id: string; + trigger: string; + status: "queued" | "running" | "retrying" | "complete" | "error"; + page_count: number; + record_count: number; + expected_target_count: number; + completed_target_count: number; + started_at: string; + succeeded_at: string | null; + failed_at: string | null; + last_error: string | null; +} + +export interface CheckpointInput { + whoopUserId: number; + connectionId: string; + reconcileGeneration: number; + resource: WhoopResource; + mode: string; + syncRunId: string; + targetId: string; + windowStart?: string | null; + windowEnd?: string | null; + nextToken?: string | null; + status: string; + pageCount: number; + recordCount: number; + createdAt: string; + updatedAt: string; + lastError?: string | null; +} + +export interface WebhookEventInput { + traceId: string; + whoopUserId: number; + connectionId: string; + resourceId: string; + eventType: WhoopWebhookEventType; + receivedAt: string; +} + +export type WebhookEventStatus = "received" | "queued" | "processed" | "retrying" | "error"; + +export interface ConnectionStatusProjection { + status: WhoopConnectionStatus; + granted_scopes?: string[]; + connected_at?: string | null; + refreshed_at?: string | null; + last_success_at?: string | null; + last_error_at?: string | null; + disconnected_at?: string | null; + last_error?: string | null; + consecutive_failure_count?: number; + updated_at?: string; +} + +export interface SyncProgressProjection { + resource: WhoopResource; + mode: string; + status: string; + page_count: number; + record_count: number; + updated_at: string; + last_error: string | null; +} + +export interface CurrentWhoopConnection extends ConnectionStatusProjection { + whoopUserId: number; + connectionId: string; + credentialVersion: number; + reconcileGeneration: number; +} + +export interface PendingInitialBackfill { + whoopUserId: number; + connectionId: string; + credentialVersion: number; +} + +interface TokenConnectionRow { + whoop_user_id: number; + connection_id: string; + status: Exclude; + access_token_ciphertext: string | null; + access_token_nonce: string | null; + access_token_expires_at: string | null; + refresh_token_ciphertext: string | null; + refresh_token_nonce: string | null; + granted_scopes: string; + credential_version: number; +} + +interface AccessTokenOptions { + now?: () => Date; + leaseId?: () => string; + sleep?: (milliseconds: number) => Promise; + expectedConnectionId?: string; + refreshBeforeExpirationMilliseconds?: number; +} + +type AccessTokenRequest = (accessToken: string, credentialVersion: number) => Promise; +type RefreshTokenRequest = ( + refreshToken: string, + options: { signal: AbortSignal }, +) => Promise; + +interface SourceDefinition { + table: string; + keyColumn: string; + columns: readonly string[]; + values: (record: SourceRecordMap[WhoopResource], syncedAt: string) => unknown[]; +} + +interface WebhookTombstoneLookup { + whoopUserId: number; + providerId: string | number; + eventType: WhoopWebhookEventType; +} + +interface ReconciliationSeenInput { + whoopUserId: number; + connectionId: string; + reconcileGeneration: number; + reconcileRunId: string; + resource: "cycle" | "recovery" | "sleep" | "workout"; + providerId: string | number; + seenAt: string; +} + +const objectAt = (value: unknown, key: string): Record => { + if (typeof value !== "object" || value === null) return {}; + const nested = (value as Record)[key]; + return typeof nested === "object" && nested !== null ? nested as Record : {}; +}; + +const nullableNumber = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +const nullableString = (value: unknown): string | null => + typeof value === "string" ? value : null; + +const canonicalTimestamp = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const milliseconds = Date.parse(value); + if (Number.isNaN(milliseconds)) throw new Error("Invalid WHOOP timestamp"); + return new Date(milliseconds).toISOString(); +}; + +const firstNumber = (...values: unknown[]): number | null => { + for (const value of values) { + const number = nullableNumber(value); + if (number !== null) return number; + } + return null; +}; + +const rawJson = (record: Record): string => { + if (typeof record.rawJson === "string") return record.rawJson; + const { rawJson: _rawJson, ...providerRecord } = record; + return JSON.stringify(providerRecord); +}; + +const scoreOf = (record: Record): Record => objectAt(record, "score"); + +const webhookTombstoneLookup = ( + resource: WhoopResource, + record: SourceRecordMap[WhoopResource], +): WebhookTombstoneLookup | null => { + if (resource !== "recovery" && resource !== "sleep" && resource !== "workout") return null; + const source = record as unknown as Record; + const providerId = resource === "recovery" ? source.sleep_id : source.id; + if (typeof source.user_id !== "number" || (typeof providerId !== "string" && typeof providerId !== "number")) { + throw new Error("WHOOP webhook source identity is not available"); + } + return { + whoopUserId: source.user_id, + providerId, + eventType: `${resource}.deleted`, + }; +}; + +const sourceDefinitions: Record = { + profile: { + table: "whoop_profiles", + keyColumn: "whoop_user_id", + columns: [ + "whoop_user_id", "first_name", "last_name", "email", "upstream_created_at", + "upstream_updated_at", "deleted_at", "synced_at", "raw_json", + ], + values: (record, syncedAt) => { + const profile = record as unknown as Record; + return [ + profile.user_id, nullableString(profile.first_name), nullableString(profile.last_name), + nullableString(profile.email), canonicalTimestamp(profile.created_at), canonicalTimestamp(profile.updated_at), + null, syncedAt, rawJson(profile), + ]; + }, + }, + body_measurement: { + table: "whoop_body_measurements", + keyColumn: "whoop_user_id", + columns: [ + "whoop_user_id", "height_meter", "weight_kilogram", "max_heart_rate", + "upstream_created_at", "upstream_updated_at", "deleted_at", "synced_at", "raw_json", + ], + values: (record, syncedAt) => { + const body = record as unknown as Record; + const whoopUserId = body.whoop_user_id ?? body.user_id; + if (typeof whoopUserId !== "number") { + throw new Error("WHOOP body measurement requires connection user context"); + } + return [ + whoopUserId, nullableNumber(body.height_meter), nullableNumber(body.weight_kilogram), + nullableNumber(body.max_heart_rate), canonicalTimestamp(body.created_at), canonicalTimestamp(body.updated_at), + null, syncedAt, rawJson(body), + ]; + }, + }, + cycle: { + table: "whoop_cycles", + keyColumn: "cycle_id", + columns: [ + "cycle_id", "whoop_user_id", "start_at", "end_at", "timezone_offset", "score_state", + "strain", "kilojoules", "average_heart_rate", "max_heart_rate", "upstream_created_at", + "upstream_updated_at", "deleted_at", "synced_at", "raw_json", + ], + values: (record, syncedAt) => { + const cycle = record as unknown as Record; + const score = scoreOf(cycle); + return [ + cycle.id, cycle.user_id, cycle.start, cycle.end ?? null, cycle.timezone_offset, cycle.score_state, + nullableNumber(score.strain), nullableNumber(score.kilojoule), nullableNumber(score.average_heart_rate), + nullableNumber(score.max_heart_rate), canonicalTimestamp(cycle.created_at), canonicalTimestamp(cycle.updated_at), + null, syncedAt, rawJson(cycle), + ]; + }, + }, + recovery: { + table: "whoop_recoveries", + keyColumn: "sleep_id", + columns: [ + "sleep_id", "cycle_id", "whoop_user_id", "score_state", "user_calibrating", "recovery_score", + "resting_heart_rate", "hrv_rmssd_milliseconds", "spo2_percentage", "skin_temperature_celsius", + "upstream_created_at", "upstream_updated_at", "deleted_at", "synced_at", "raw_json", + ], + values: (record, syncedAt) => { + const recovery = record as unknown as Record; + const score = scoreOf(recovery); + return [ + recovery.sleep_id, recovery.cycle_id, recovery.user_id, recovery.score_state, + typeof score.user_calibrating === "boolean" ? Number(score.user_calibrating) : null, + nullableNumber(score.recovery_score), nullableNumber(score.resting_heart_rate), + firstNumber(score.hrv_rmssd_milli, score.hrv_rmssd_milliseconds), nullableNumber(score.spo2_percentage), + firstNumber(score.skin_temp_celsius, score.skin_temperature_celsius), canonicalTimestamp(recovery.created_at), + canonicalTimestamp(recovery.updated_at), null, syncedAt, rawJson(recovery), + ]; + }, + }, + sleep: { + table: "whoop_sleeps", + keyColumn: "sleep_id", + columns: [ + "sleep_id", "cycle_id", "whoop_user_id", "start_at", "end_at", "timezone_offset", "nap", + "score_state", "stage_awake_milliseconds", "stage_light_milliseconds", "stage_slow_wave_milliseconds", + "stage_rem_milliseconds", "stage_in_bed_milliseconds", "stage_no_data_milliseconds", + "sleep_needed_milliseconds", "sleep_debt_milliseconds", "sleep_need_recent_strain_milliseconds", + "sleep_need_recent_nap_milliseconds", "sleep_cycle_count", "disturbance_count", + "sleep_efficiency_percentage", "sleep_consistency_percentage", "sleep_performance_percentage", + "respiratory_rate", "upstream_created_at", "upstream_updated_at", "deleted_at", "synced_at", "raw_json", + ], + values: (record, syncedAt) => { + const sleep = record as unknown as Record; + const score = scoreOf(sleep); + const stages = objectAt(score, "stage_summary"); + const needed = objectAt(score, "sleep_needed"); + return [ + sleep.id, sleep.cycle_id, sleep.user_id, sleep.start ?? null, sleep.end ?? null, sleep.timezone_offset, + typeof sleep.nap === "boolean" ? Number(sleep.nap) : null, sleep.score_state, + firstNumber(stages.total_awake_time_milli, stages.awake_milli), + firstNumber(stages.total_light_sleep_time_milli, stages.light_milli), + firstNumber(stages.total_slow_wave_sleep_time_milli, stages.slow_wave_milli), + firstNumber(stages.total_rem_sleep_time_milli, stages.rem_milli), + nullableNumber(stages.total_in_bed_time_milli), + nullableNumber(stages.total_no_data_time_milli), + firstNumber(needed.baseline_milli, needed.sleep_needed_milli), + firstNumber(needed.need_from_sleep_debt_milli, needed.sleep_debt_milli), + nullableNumber(needed.need_from_recent_strain_milli), + nullableNumber(needed.need_from_recent_nap_milli), + nullableNumber(stages.sleep_cycle_count), nullableNumber(stages.disturbance_count), + nullableNumber(score.sleep_efficiency_percentage), nullableNumber(score.sleep_consistency_percentage), + nullableNumber(score.sleep_performance_percentage), nullableNumber(score.respiratory_rate), + canonicalTimestamp(sleep.created_at), canonicalTimestamp(sleep.updated_at), null, syncedAt, rawJson(sleep), + ]; + }, + }, + workout: { + table: "whoop_workouts", + keyColumn: "workout_id", + columns: [ + "workout_id", "whoop_user_id", "start_at", "end_at", "timezone_offset", "sport_id", "sport_name", + "score_state", "strain", "average_heart_rate", "max_heart_rate", "kilojoules", "percent_recorded", + "distance_meter", "elevation_gain_meter", "zone_zero_milliseconds", "zone_one_milliseconds", + "zone_two_milliseconds", "zone_three_milliseconds", "zone_four_milliseconds", "zone_five_milliseconds", + "upstream_created_at", "upstream_updated_at", "deleted_at", "synced_at", "raw_json", + ], + values: (record, syncedAt) => { + const workout = record as unknown as Record; + const score = scoreOf(workout); + const zones = objectAt(score, "zone_duration"); + return [ + workout.id, workout.user_id, workout.start ?? null, workout.end ?? null, workout.timezone_offset, + nullableNumber(workout.sport_id), workout.sport_name, workout.score_state, nullableNumber(score.strain), + nullableNumber(score.average_heart_rate), nullableNumber(score.max_heart_rate), nullableNumber(score.kilojoule), + nullableNumber(score.percent_recorded), nullableNumber(score.distance_meter), nullableNumber(score.altitude_gain_meter), + firstNumber(zones.zone_zero_milli, zones.zone_zero_milliseconds), + firstNumber(zones.zone_one_milli, zones.zone_one_milliseconds), + firstNumber(zones.zone_two_milli, zones.zone_two_milliseconds), + firstNumber(zones.zone_three_milli, zones.zone_three_milliseconds), + firstNumber(zones.zone_four_milli, zones.zone_four_milliseconds), + firstNumber(zones.zone_five_milli, zones.zone_five_milliseconds), + canonicalTimestamp(workout.created_at), canonicalTimestamp(workout.updated_at), + null, syncedAt, rawJson(workout), + ]; + }, + }, +}; + +const reconciliationDefinitions = { + cycle: { table: "whoop_cycles", keyColumn: "cycle_id", windowColumn: "start_at", syncedColumn: "synced_at" }, + recovery: { table: "whoop_recoveries", keyColumn: "sleep_id", windowColumn: "upstream_created_at", syncedColumn: "synced_at" }, + sleep: { table: "whoop_sleeps", keyColumn: "sleep_id", windowColumn: "start_at", syncedColumn: "synced_at" }, + workout: { table: "whoop_workouts", keyColumn: "workout_id", windowColumn: "start_at", syncedColumn: "synced_at" }, +} as const; + +const changedRows = (result: D1Result): number => Number(result.meta.changes ?? 0); + +const sanitizedError = (value: string | null | undefined): string | null => { + if (!value) return null; + return value.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 500); +}; + +const defaultSleep = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const isDefiniteRefreshFailure = (error: unknown): error is Error & { refreshOutcome: "definite" } => + error instanceof Error + && "refreshOutcome" in error + && error.refreshOutcome === "definite"; + +const ambiguousRefreshFailure = (error: unknown): WhoopRefreshAmbiguousError => + error instanceof WhoopRefreshAmbiguousError + ? error + : new WhoopRefreshAmbiguousError("token refresh"); + +export class WhoopStaleConnectionError extends Error { + constructor() { + super("WHOOP queue connection is stale"); + } +} + +export class WhoopRepository { + constructor( + private readonly db: D1Database, + private readonly tokenEncryptionKey: string, + ) {} + + async consumeOAuthState(stateHash: string, consumedAt: string): Promise { + const canonicalConsumedAt = canonicalTimestamp(consumedAt)!; + const result = await this.db.prepare(` + UPDATE whoop_oauth_states + SET consumed_at = ? + WHERE state_hash = ? AND consumed_at IS NULL AND expires_at > ? + `).bind(canonicalConsumedAt, stateHash, canonicalConsumedAt).run(); + return changedRows(result) === 1; + } + + async createOAuthState(stateHash: string, createdAt: string, expiresAt: string): Promise { + const canonicalCreatedAt = canonicalTimestamp(createdAt)!; + const canonicalExpiresAt = canonicalTimestamp(expiresAt)!; + await this.db.prepare(` + INSERT INTO whoop_oauth_states (state_hash, created_at, expires_at, consumed_at) + VALUES (?, ?, ?, NULL) + `).bind(stateHash, canonicalCreatedAt, canonicalExpiresAt).run(); + } + + async upsertConnection(input: UpsertConnectionInput): Promise { + const updatedAt = input.updatedAt ?? input.connectedAt; + await this.db.prepare(` + INSERT INTO whoop_connections ( + whoop_user_id, connection_id, status, access_token_ciphertext, access_token_nonce, access_token_expires_at, + refresh_token_ciphertext, refresh_token_nonce, granted_scopes, credential_version, reconcile_generation, refresh_lease_id, + refresh_lease_expires_at, refresh_dispatched_at, connected_at, refreshed_at, last_success_at, last_error_at, + disconnected_at, last_error, consecutive_failure_count, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, NULL, NULL, NULL, ?, NULL, NULL, NULL, NULL, NULL, 0, ?, ?) + ON CONFLICT(whoop_user_id) DO UPDATE SET + connection_id = excluded.connection_id, + status = excluded.status, + access_token_ciphertext = excluded.access_token_ciphertext, + access_token_nonce = excluded.access_token_nonce, + access_token_expires_at = excluded.access_token_expires_at, + refresh_token_ciphertext = excluded.refresh_token_ciphertext, + refresh_token_nonce = excluded.refresh_token_nonce, + granted_scopes = excluded.granted_scopes, + credential_version = whoop_connections.credential_version + 1, + reconcile_generation = 0, + refresh_lease_id = NULL, + refresh_lease_expires_at = NULL, + refresh_dispatched_at = NULL, + connected_at = excluded.connected_at, + disconnected_at = NULL, + last_error = NULL, + consecutive_failure_count = 0, + updated_at = excluded.updated_at + `).bind( + input.whoopUserId, + input.connectionId, + input.status, + input.accessToken.ciphertext, + input.accessToken.nonce, + input.accessTokenExpiresAt, + input.refreshToken.ciphertext, + input.refreshToken.nonce, + input.grantedScopes.join(" "), + input.connectedAt, + input.connectedAt, + updatedAt, + ).run(); + } + + async claimAndUpsertConnection(input: UpsertConnectionInput): Promise { + const updatedAt = input.updatedAt ?? input.connectedAt; + const row = await this.db.prepare(` + INSERT INTO whoop_connections ( + whoop_user_id, connection_id, status, access_token_ciphertext, access_token_nonce, access_token_expires_at, + refresh_token_ciphertext, refresh_token_nonce, granted_scopes, credential_version, reconcile_generation, + initial_backfill_pending, refresh_lease_id, refresh_lease_expires_at, refresh_dispatched_at, + connected_at, refreshed_at, last_success_at, last_error_at, disconnected_at, last_error, + consecutive_failure_count, created_at, updated_at + ) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, NULL, NULL, NULL, ?, NULL, NULL, NULL, NULL, NULL, 0, ?, ? + WHERE NOT EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id != ? AND status != 'disconnected' + ) + ON CONFLICT(whoop_user_id) DO UPDATE SET + connection_id = excluded.connection_id, + status = excluded.status, + access_token_ciphertext = excluded.access_token_ciphertext, + access_token_nonce = excluded.access_token_nonce, + access_token_expires_at = excluded.access_token_expires_at, + refresh_token_ciphertext = excluded.refresh_token_ciphertext, + refresh_token_nonce = excluded.refresh_token_nonce, + granted_scopes = excluded.granted_scopes, + credential_version = whoop_connections.credential_version + 1, + reconcile_generation = 0, + initial_backfill_pending = excluded.initial_backfill_pending, + refresh_lease_id = NULL, + refresh_lease_expires_at = NULL, + refresh_dispatched_at = NULL, + connected_at = excluded.connected_at, + disconnected_at = NULL, + last_error = NULL, + consecutive_failure_count = 0, + updated_at = excluded.updated_at + RETURNING credential_version + `).bind( + input.whoopUserId, + input.connectionId, + input.status, + input.accessToken.ciphertext, + input.accessToken.nonce, + input.accessTokenExpiresAt, + input.refreshToken.ciphertext, + input.refreshToken.nonce, + input.grantedScopes.join(" "), + input.initialBackfillPending ? 1 : 0, + input.connectedAt, + input.connectedAt, + updatedAt, + input.whoopUserId, + ).first<{ credential_version: number }>(); + return row?.credential_version ?? null; + } + + async markInitialBackfillQueued( + whoopUserId: number, + connectionId: string, + credentialVersion: number, + queuedAt: string, + ): Promise { + const timestamp = canonicalTimestamp(queuedAt)!; + const result = await this.db.prepare(` + UPDATE whoop_connections + SET initial_backfill_pending = 0, + status = CASE WHEN 6 = ( + SELECT COUNT(DISTINCT resource) + FROM whoop_sync_checkpoints + WHERE whoop_user_id = ? AND connection_id = ? + AND mode = 'backfill' AND reconcile_generation = 0 + AND target_id = '' AND status = 'complete' + AND resource IN ('profile', 'body_measurement', 'cycle', 'recovery', 'sleep', 'workout') + ) THEN 'active' ELSE status END, + last_success_at = CASE WHEN 6 = ( + SELECT COUNT(DISTINCT resource) + FROM whoop_sync_checkpoints + WHERE whoop_user_id = ? AND connection_id = ? + AND mode = 'backfill' AND reconcile_generation = 0 + AND target_id = '' AND status = 'complete' + AND resource IN ('profile', 'body_measurement', 'cycle', 'recovery', 'sleep', 'workout') + ) THEN ? ELSE last_success_at END, + updated_at = ? + WHERE whoop_user_id = ? AND connection_id = ? AND credential_version = ? + AND status = 'backfilling' AND initial_backfill_pending = 1 + `).bind( + whoopUserId, + connectionId, + whoopUserId, + connectionId, + timestamp, + timestamp, + whoopUserId, + connectionId, + credentialVersion, + ).run(); + return changedRows(result) === 1; + } + + async getPendingInitialBackfills(): Promise { + const result = await this.db.prepare(` + SELECT whoop_user_id, connection_id, credential_version + FROM whoop_connections + WHERE status = 'backfilling' AND initial_backfill_pending = 1 + ORDER BY connected_at ASC, whoop_user_id ASC + `).all<{ whoop_user_id: number; connection_id: string; credential_version: number }>(); + return result.results.map((row) => ({ + whoopUserId: row.whoop_user_id, + connectionId: row.connection_id, + credentialVersion: row.credential_version, + })); + } + + async acquireRefreshLease( + whoopUserId: number, + leaseId: string, + now: string, + credentialVersion: number, + ): Promise { + const canonicalNow = canonicalTimestamp(now)!; + const expiresAt = new Date(Date.parse(canonicalNow) + REFRESH_LEASE_MILLISECONDS).toISOString(); + const result = await this.db.prepare(` + UPDATE whoop_connections + SET refresh_lease_id = ?, refresh_lease_expires_at = ? + WHERE whoop_user_id = ? + AND credential_version = ? + AND status IN ('active', 'backfilling') + AND refresh_dispatched_at IS NULL + AND (refresh_lease_id IS NULL OR refresh_lease_expires_at <= ?) + `).bind(leaseId, expiresAt, whoopUserId, credentialVersion, canonicalNow).run(); + return changedRows(result) === 1; + } + + async releaseRefreshLease( + whoopUserId: number, + leaseId: string, + credentialVersion: number, + updatedAt = new Date().toISOString(), + ): Promise { + const result = await this.db.prepare(` + UPDATE whoop_connections + SET refresh_lease_id = NULL, refresh_lease_expires_at = NULL, updated_at = ? + WHERE whoop_user_id = ? AND refresh_lease_id = ? AND credential_version = ? + AND refresh_dispatched_at IS NULL + `).bind(updatedAt, whoopUserId, leaseId, credentialVersion).run(); + return changedRows(result) === 1; + } + + private async markRefreshDispatched( + whoopUserId: number, + leaseId: string, + credentialVersion: number, + dispatchedAt: string, + ): Promise { + const canonicalDispatchedAt = canonicalTimestamp(dispatchedAt)!; + const result = await this.db.prepare(` + UPDATE whoop_connections + SET refresh_dispatched_at = ? + WHERE whoop_user_id = ? AND refresh_lease_id = ? AND credential_version = ? + AND status IN ('active', 'backfilling') + AND refresh_dispatched_at IS NULL + AND refresh_lease_expires_at > ? + `).bind( + canonicalDispatchedAt, + whoopUserId, + leaseId, + credentialVersion, + canonicalDispatchedAt, + ).run(); + return changedRows(result) === 1; + } + + private async clearDefiniteRefreshFailure( + whoopUserId: number, + leaseId: string, + credentialVersion: number, + updatedAt: string, + ): Promise { + const result = await this.db.prepare(` + UPDATE whoop_connections + SET refresh_dispatched_at = NULL, + refresh_lease_id = NULL, + refresh_lease_expires_at = NULL, + updated_at = ? + WHERE whoop_user_id = ? AND refresh_lease_id = ? AND credential_version = ? + AND refresh_dispatched_at IS NOT NULL + `).bind(updatedAt, whoopUserId, leaseId, credentialVersion).run(); + return changedRows(result) === 1; + } + + async storeRotatedTokens( + whoopUserId: number, + leaseId: string, + credentialVersion: number, + input: RotatedTokenInput, + ): Promise { + const result = await this.db.prepare(` + UPDATE whoop_connections + SET access_token_ciphertext = ?, + access_token_nonce = ?, + access_token_expires_at = ?, + refresh_token_ciphertext = ?, + refresh_token_nonce = ?, + granted_scopes = ?, + refreshed_at = ?, + updated_at = ?, + refresh_lease_id = NULL, + refresh_lease_expires_at = NULL, + refresh_dispatched_at = NULL, + credential_version = credential_version + 1 + WHERE whoop_user_id = ? AND refresh_lease_id = ? AND credential_version = ? + AND refresh_dispatched_at IS NOT NULL + `).bind( + input.accessToken.ciphertext, + input.accessToken.nonce, + input.accessTokenExpiresAt, + input.refreshToken.ciphertext, + input.refreshToken.nonce, + input.grantedScopes.join(" "), + input.refreshedAt, + input.refreshedAt, + whoopUserId, + leaseId, + credentialVersion, + ).run(); + return changedRows(result) === 1; + } + + async upsertSourceRecord( + resource: R, + record: SourceRecordMap[R], + options: { + tombstonePolicy: TombstonePolicy; + syncedAt?: string; + whoopUserId?: number; + connectionId?: string; + reconcileGeneration?: number; + }, + ): Promise { + if (options.tombstonePolicy !== "preserve" && options.tombstonePolicy !== "reconcile") { + throw new Error("Invalid WHOOP tombstone policy"); + } + const definition = sourceDefinitions[resource]; + const values = definition.values( + record as SourceRecordMap[WhoopResource], + canonicalTimestamp(options.syncedAt ?? new Date().toISOString())!, + ); + const tombstoneLookup = options.tombstonePolicy === "preserve" && options.connectionId !== undefined + ? webhookTombstoneLookup(resource, record as SourceRecordMap[WhoopResource]) + : null; + const updates = definition.columns + .filter((column) => column !== definition.keyColumn) + .map((column) => { + if (column === "deleted_at") { + if (options.tombstonePolicy === "reconcile") return "deleted_at = NULL"; + if (!tombstoneLookup) return `deleted_at = ${definition.table}.deleted_at`; + return `deleted_at = CASE + WHEN ${definition.table}.deleted_at IS NULL THEN excluded.deleted_at + WHEN excluded.deleted_at IS NULL THEN ${definition.table}.deleted_at + WHEN ${definition.table}.deleted_at >= excluded.deleted_at THEN ${definition.table}.deleted_at + ELSE excluded.deleted_at + END`; + } + return `${column} = excluded.${column}`; + }) + .join(",\n "); + const bindings: unknown[] = []; + const valueExpressions = definition.columns.map((column, index) => { + if (column === "deleted_at" && tombstoneLookup) { + bindings.push( + tombstoneLookup.whoopUserId, + options.connectionId, + tombstoneLookup.providerId, + tombstoneLookup.eventType, + ); + return `(SELECT MAX(received_at) + FROM whoop_webhook_events + WHERE whoop_user_id = ? AND connection_id = ? + AND resource_id = CAST(? AS TEXT) AND event_type = ?)`; + } + bindings.push(values[index]); + return "?"; + }); + const hasConnectionFence = options.whoopUserId !== undefined && options.connectionId !== undefined; + const generationClause = options.reconcileGeneration === undefined + ? "" + : "AND reconcile_generation = ?"; + const insertExpression = hasConnectionFence + ? `SELECT ${valueExpressions.join(", ")} + WHERE EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? + AND status IN ('active', 'backfilling') + ${generationClause} + )` + : `VALUES (${valueExpressions.join(", ")})`; + if (hasConnectionFence) { + bindings.push(options.whoopUserId, options.connectionId); + if (options.reconcileGeneration !== undefined) bindings.push(options.reconcileGeneration); + } + const updateCondition = hasConnectionFence + ? `(excluded.upstream_updated_at >= ${definition.table}.upstream_updated_at + OR ${definition.table}.upstream_updated_at IS NULL) + AND EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? + AND status IN ('active', 'backfilling') + ${generationClause} + )` + : `excluded.upstream_updated_at >= ${definition.table}.upstream_updated_at + OR ${definition.table}.upstream_updated_at IS NULL`; + if (hasConnectionFence) { + bindings.push(options.whoopUserId, options.connectionId); + if (options.reconcileGeneration !== undefined) bindings.push(options.reconcileGeneration); + } + + const result = await this.db.prepare(` + INSERT INTO ${definition.table} (${definition.columns.join(", ")}) + ${insertExpression} + ON CONFLICT(${definition.keyColumn}) DO UPDATE SET + ${updates} + WHERE ${updateCondition} + `).bind(...bindings).run(); + if (hasConnectionFence && changedRows(result) === 0) { + return options.reconcileGeneration === undefined + ? this.isSyncConnectionCurrent(options.whoopUserId!, options.connectionId!) + : this.isReconciliationCurrent( + options.whoopUserId!, + options.connectionId!, + options.reconcileGeneration, + ); + } + return true; + } + + async tombstoneSourceRecord( + resource: WhoopResource, + providerId: string | number, + deletedAt: string, + connection?: { whoopUserId: number; connectionId: string }, + ): Promise { + const definition = sourceDefinitions[resource]; + const canonicalDeletedAt = canonicalTimestamp(deletedAt)!; + const result = await this.db.prepare(` + UPDATE ${definition.table} + SET deleted_at = ?, synced_at = ? + WHERE ${definition.keyColumn} = ? + ${connection ? `AND EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? + AND status IN ('active', 'backfilling') + )` : ""} + `).bind( + canonicalDeletedAt, + canonicalDeletedAt, + providerId, + ...(connection ? [connection.whoopUserId, connection.connectionId] : []), + ).run(); + if (connection && changedRows(result) === 0) { + return this.isSyncConnectionCurrent(connection.whoopUserId, connection.connectionId); + } + return true; + } + + async createSyncRun(input: SyncRunInput): Promise { + const result = await this.db.prepare(` + INSERT INTO whoop_sync_runs ( + run_id, whoop_user_id, connection_id, reconcile_generation, trigger, status, + expected_target_count, completed_target_count, page_count, record_count, + started_at, succeeded_at, failed_at, last_error + ) SELECT ?, ?, ?, ?, ?, 'queued', ?, 0, 0, 0, ?, NULL, NULL, NULL + WHERE EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND status IN ('active', 'backfilling') + ) + `).bind( + input.runId, + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + input.trigger, + input.expectedTargetCount, + canonicalTimestamp(input.startedAt)!, + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + ).run(); + return changedRows(result) === 1; + } + + async refreshSyncRun( + runId: string, + whoopUserId: number, + connectionId: string, + reconcileGeneration: number, + updatedAt: string, + ): Promise { + const timestamp = canonicalTimestamp(updatedAt)!; + const result = await this.db.prepare(` + UPDATE whoop_sync_runs + SET page_count = COALESCE(( + SELECT SUM(page_count) FROM whoop_sync_checkpoints + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND sync_run_id = ? AND mode = 'reconcile' + ), 0), + record_count = COALESCE(( + SELECT SUM(record_count) FROM whoop_sync_checkpoints + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND sync_run_id = ? AND mode = 'reconcile' + ), 0), + completed_target_count = COALESCE(( + SELECT SUM(CASE WHEN status = 'complete' THEN 1 ELSE 0 END) + FROM whoop_sync_checkpoints + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND sync_run_id = ? AND mode = 'reconcile' + ), 0), + status = CASE + WHEN (SELECT COUNT(*) FROM whoop_sync_checkpoints + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND sync_run_id = ? AND mode = 'reconcile' AND status = 'error') > 0 THEN 'error' + WHEN (SELECT COUNT(*) FROM whoop_sync_checkpoints + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND sync_run_id = ? AND mode = 'reconcile' AND status = 'complete') >= expected_target_count + THEN 'complete' + WHEN (SELECT COUNT(*) FROM whoop_sync_checkpoints + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND sync_run_id = ? AND mode = 'reconcile' AND status = 'retrying') > 0 THEN 'retrying' + WHEN (SELECT COUNT(*) FROM whoop_sync_checkpoints + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND sync_run_id = ? AND mode = 'reconcile') > 0 THEN 'running' + ELSE 'queued' + END, + succeeded_at = CASE + WHEN (SELECT COUNT(*) FROM whoop_sync_checkpoints + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND sync_run_id = ? AND mode = 'reconcile' AND status = 'complete') >= expected_target_count + THEN COALESCE(succeeded_at, ?) ELSE NULL END, + failed_at = CASE + WHEN (SELECT COUNT(*) FROM whoop_sync_checkpoints + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND sync_run_id = ? AND mode = 'reconcile' AND status = 'error') > 0 + THEN COALESCE(failed_at, ?) ELSE NULL END, + last_error = ( + SELECT last_error FROM whoop_sync_checkpoints + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND sync_run_id = ? AND mode = 'reconcile' AND last_error IS NOT NULL + ORDER BY updated_at DESC LIMIT 1 + ) + WHERE run_id = ? AND whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND status IN ('active', 'backfilling') + ) + `).bind( + whoopUserId, connectionId, reconcileGeneration, runId, + whoopUserId, connectionId, reconcileGeneration, runId, + whoopUserId, connectionId, reconcileGeneration, runId, + whoopUserId, connectionId, reconcileGeneration, runId, + whoopUserId, connectionId, reconcileGeneration, runId, + whoopUserId, connectionId, reconcileGeneration, runId, + whoopUserId, connectionId, reconcileGeneration, runId, + whoopUserId, connectionId, reconcileGeneration, runId, timestamp, + whoopUserId, connectionId, reconcileGeneration, runId, timestamp, + whoopUserId, connectionId, reconcileGeneration, runId, + runId, whoopUserId, connectionId, reconcileGeneration, + whoopUserId, connectionId, reconcileGeneration, + ).run(); + return changedRows(result) === 1; + } + + async markSyncRunPublicationFailure( + runId: string, + whoopUserId: number, + connectionId: string, + reconcileGeneration: number, + failedAt: string, + ): Promise { + const timestamp = canonicalTimestamp(failedAt)!; + const result = await this.db.prepare(` + UPDATE whoop_sync_runs + SET status = 'error', failed_at = ?, last_error = 'WHOOP queue publication failed' + WHERE run_id = ? AND whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND status = 'queued' + AND EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND status IN ('active', 'backfilling') + ) + `).bind( + timestamp, runId, whoopUserId, connectionId, reconcileGeneration, + whoopUserId, connectionId, reconcileGeneration, + ).run(); + return changedRows(result) === 1; + } + + async beginReconciliation( + whoopUserId: number, + connectionId: string, + begunAt: string, + requireActiveConnection = false, + ): Promise { + const canonicalBegunAt = canonicalTimestamp(begunAt)!; + const statusCondition = requireActiveConnection + ? "status = 'active'" + : "status IN ('active', 'backfilling')"; + const row = await this.db.prepare(` + UPDATE whoop_connections + SET reconcile_generation = reconcile_generation + 1, + updated_at = ? + WHERE whoop_user_id = ? AND connection_id = ? + AND ${statusCondition} + RETURNING reconcile_generation + `).bind(canonicalBegunAt, whoopUserId, connectionId) + .first<{ reconcile_generation: number }>(); + if (!row) return null; + await this.db.prepare(` + DELETE FROM whoop_reconcile_seen + WHERE whoop_user_id = ? AND connection_id = ? + AND reconcile_generation < ? + `).bind(whoopUserId, connectionId, row.reconcile_generation).run(); + return row.reconcile_generation; + } + + async upsertCheckpoint(input: CheckpointInput): Promise { + const result = await this.checkpointStatement(input).run(); + if (changedRows(result) === 0) { + return input.mode === "reconcile" + ? this.isReconciliationCurrent( + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + ) + : this.isSyncConnectionCurrent(input.whoopUserId, input.connectionId); + } + return true; + } + + private checkpointStatement(input: CheckpointInput): D1PreparedStatement { + return this.db.prepare(` + INSERT INTO whoop_sync_checkpoints ( + whoop_user_id, connection_id, resource, mode, reconcile_generation, sync_run_id, target_id, + window_start, window_end, next_token, status, + page_count, record_count, created_at, updated_at, last_error + ) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? + AND status IN ('active', 'backfilling') + AND (? != 'reconcile' OR reconcile_generation = ?) + ) + ON CONFLICT(whoop_user_id, connection_id, resource, mode, reconcile_generation, sync_run_id, target_id) DO UPDATE SET + window_start = excluded.window_start, + window_end = excluded.window_end, + next_token = excluded.next_token, + status = excluded.status, + page_count = excluded.page_count, + record_count = excluded.record_count, + updated_at = excluded.updated_at, + last_error = excluded.last_error + WHERE EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? + AND status IN ('active', 'backfilling') + AND (? != 'reconcile' OR reconcile_generation = ?) + ) + AND ( + excluded.page_count > whoop_sync_checkpoints.page_count + OR ( + excluded.page_count = whoop_sync_checkpoints.page_count + AND excluded.record_count > whoop_sync_checkpoints.record_count + ) + OR ( + excluded.page_count = whoop_sync_checkpoints.page_count + AND excluded.record_count = whoop_sync_checkpoints.record_count + AND CASE excluded.status + WHEN 'complete' THEN 3 + WHEN 'error' THEN 2 + WHEN 'retrying' THEN 1 + ELSE 0 + END > CASE whoop_sync_checkpoints.status + WHEN 'complete' THEN 3 + WHEN 'error' THEN 2 + WHEN 'retrying' THEN 1 + ELSE 0 + END + ) + ) + `).bind( + input.whoopUserId, + input.connectionId, + input.resource, + input.mode, + input.reconcileGeneration, + input.syncRunId, + input.targetId, + canonicalTimestamp(input.windowStart), + canonicalTimestamp(input.windowEnd), + input.nextToken ?? null, + input.status, + input.pageCount, + input.recordCount, + canonicalTimestamp(input.createdAt)!, + canonicalTimestamp(input.updatedAt)!, + sanitizedError(input.lastError), + input.whoopUserId, + input.connectionId, + input.mode, + input.reconcileGeneration, + input.whoopUserId, + input.connectionId, + input.mode, + input.reconcileGeneration, + ); + } + + async recordReconciliationSeen(input: ReconciliationSeenInput): Promise { + const seenAt = canonicalTimestamp(input.seenAt)!; + const result = await this.db.prepare(` + INSERT INTO whoop_reconcile_seen ( + whoop_user_id, connection_id, reconcile_generation, + reconcile_run_id, resource, provider_id, seen_at + ) SELECT ?, ?, ?, ?, ?, CAST(? AS TEXT), ? + WHERE EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? + AND status IN ('active', 'backfilling') + AND reconcile_generation = ? + ) + ON CONFLICT(whoop_user_id, connection_id, reconcile_generation, reconcile_run_id, resource, provider_id) + DO UPDATE SET seen_at = excluded.seen_at + WHERE EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? + AND status IN ('active', 'backfilling') + AND reconcile_generation = ? + ) + `).bind( + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + input.reconcileRunId, + input.resource, + input.providerId, + seenAt, + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + ).run(); + if (changedRows(result) === 0) { + return this.isReconciliationCurrent( + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + ); + } + return true; + } + + async cleanupReconciliationSeen(input: { + whoopUserId: number; + connectionId: string; + reconcileGeneration: number; + reconcileRunId: string; + resource: WhoopResource; + }): Promise { + const current = await this.isReconciliationCurrent( + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + ); + if (!current) return false; + await this.db.prepare(` + DELETE FROM whoop_reconcile_seen + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND reconcile_run_id = ? AND resource = ? + AND EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND status IN ('active', 'backfilling') + ) + `).bind( + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + input.reconcileRunId, + input.resource, + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + ).run(); + return this.isReconciliationCurrent( + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + ); + } + + async finalizeReconciliation(input: CheckpointInput): Promise { + if (input.mode !== "reconcile" + || input.targetId !== "" + || input.windowStart == null + || input.windowEnd == null + || !(input.resource in reconciliationDefinitions)) { + throw new Error("Invalid WHOOP reconciliation finalization"); + } + const resource = input.resource as keyof typeof reconciliationDefinitions; + const definition = reconciliationDefinitions[resource]; + const completedAt = canonicalTimestamp(input.updatedAt)!; + const windowStart = canonicalTimestamp(input.windowStart)!; + const windowEnd = canonicalTimestamp(input.windowEnd)!; + const tombstone = this.db.prepare(` + UPDATE ${definition.table} + SET deleted_at = ?, synced_at = ? + WHERE whoop_user_id = ? AND deleted_at IS NULL + AND ${definition.windowColumn} >= ? AND ${definition.windowColumn} <= ? + AND ${definition.syncedColumn} <= ? + AND CAST(${definition.keyColumn} AS TEXT) NOT IN ( + SELECT provider_id + FROM whoop_reconcile_seen + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND reconcile_run_id = ? AND resource = ? + ) + AND EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND status IN ('active', 'backfilling') + ) + AND NOT EXISTS ( + SELECT 1 FROM whoop_sync_checkpoints + WHERE whoop_user_id = ? AND connection_id = ? AND resource = ? + AND mode = 'reconcile' AND reconcile_generation = ? + AND sync_run_id = ? AND target_id = '' + AND ( + page_count > ? + OR (page_count = ? AND record_count > ?) + OR (page_count = ? AND record_count = ? AND status = 'complete') + ) + ) + `).bind( + completedAt, + completedAt, + input.whoopUserId, + windowStart, + windowEnd, + windowEnd, + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + input.syncRunId, + resource, + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + input.whoopUserId, + input.connectionId, + resource, + input.reconcileGeneration, + input.syncRunId, + input.pageCount, + input.pageCount, + input.recordCount, + input.pageCount, + input.recordCount, + ); + const cleanup = this.db.prepare(` + DELETE FROM whoop_reconcile_seen + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND reconcile_run_id = ? AND resource = ? + AND EXISTS ( + SELECT 1 FROM whoop_sync_checkpoints + WHERE whoop_user_id = ? AND connection_id = ? AND resource = ? + AND mode = 'reconcile' AND reconcile_generation = ? + AND sync_run_id = ? AND target_id = '' + AND status = 'complete' AND page_count >= ? + ) + AND EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND status IN ('active', 'backfilling') + ) + `).bind( + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + input.syncRunId, + resource, + input.whoopUserId, + input.connectionId, + resource, + input.reconcileGeneration, + input.syncRunId, + input.pageCount, + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + ); + const completed = this.db.prepare(` + UPDATE whoop_sync_checkpoints + SET updated_at = updated_at + WHERE whoop_user_id = ? AND connection_id = ? AND resource = ? + AND mode = 'reconcile' AND reconcile_generation = ? + AND sync_run_id = ? AND target_id = '' + AND status = 'complete' AND page_count >= ? + AND EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND status IN ('active', 'backfilling') + ) + `).bind( + input.whoopUserId, + input.connectionId, + resource, + input.reconcileGeneration, + input.syncRunId, + input.pageCount, + input.whoopUserId, + input.connectionId, + input.reconcileGeneration, + ); + const results = await this.db.batch([ + tombstone, + this.checkpointStatement(input), + cleanup, + completed, + ]); + return changedRows(results.at(-1)!) > 0; + } + + async recordWebhookEvent(input: WebhookEventInput): Promise { + const result = await this.db.prepare(` + INSERT INTO whoop_webhook_events ( + trace_id, whoop_user_id, connection_id, resource_id, event_type, received_at, + processed_at, status, attempts, last_error + ) SELECT ?, ?, ?, ?, ?, ?, NULL, 'received', 0, NULL + WHERE EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? + AND status IN ('active', 'backfilling') + ) + ON CONFLICT(trace_id) DO NOTHING + `).bind( + input.traceId, + input.whoopUserId, + input.connectionId, + input.resourceId, + input.eventType, + canonicalTimestamp(input.receivedAt)!, + input.whoopUserId, + input.connectionId, + ).run(); + return changedRows(result) === 1; + } + + async markWebhookQueued( + traceId: string, + whoopUserId: number, + connectionId: string, + ): Promise { + const result = await this.db.prepare(` + UPDATE whoop_webhook_events + SET status = 'queued' + WHERE trace_id = ? AND whoop_user_id = ? AND connection_id = ? AND status = 'received' + AND EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? + AND status IN ('active', 'backfilling') + ) + `).bind(traceId, whoopUserId, connectionId, whoopUserId, connectionId).run(); + return changedRows(result) === 1; + } + + async getWebhookEventStatus( + traceId: string, + whoopUserId: number, + connectionId: string, + ): Promise { + const row = await this.db.prepare(` + SELECT event.status + FROM whoop_webhook_events AS event + WHERE event.trace_id = ? AND event.whoop_user_id = ? AND event.connection_id = ? + AND EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? + AND status IN ('active', 'backfilling') + ) + `).bind(traceId, whoopUserId, connectionId, whoopUserId, connectionId) + .first<{ status: WebhookEventStatus }>(); + return row?.status ?? null; + } + + async markWebhookProcessed( + traceId: string, + whoopUserId: number, + connectionId: string, + processedAt: string, + ): Promise { + const timestamp = canonicalTimestamp(processedAt)!; + const result = await this.db.prepare(` + UPDATE whoop_webhook_events + SET status = 'processed', processed_at = ?, attempts = attempts + 1, last_error = NULL + WHERE trace_id = ? AND whoop_user_id = ? AND connection_id = ? + AND EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? + AND status IN ('active', 'backfilling') + ) + `).bind( + timestamp, + traceId, + whoopUserId, + connectionId, + whoopUserId, + connectionId, + ).run(); + return changedRows(result) === 1; + } + + async markWebhookFailed( + traceId: string, + whoopUserId: number, + connectionId: string, + status: "retrying" | "error", + lastError: string, + failedAt: string, + ): Promise { + const timestamp = canonicalTimestamp(failedAt)!; + const result = await this.db.prepare(` + UPDATE whoop_webhook_events + SET status = ?, + processed_at = CASE WHEN ? = 'error' THEN ? ELSE processed_at END, + attempts = attempts + 1, + last_error = ? + WHERE trace_id = ? AND whoop_user_id = ? AND connection_id = ? + AND EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? + AND status IN ('active', 'backfilling') + ) + `).bind( + status, + status, + timestamp, + sanitizedError(lastError), + traceId, + whoopUserId, + connectionId, + whoopUserId, + connectionId, + ).run(); + return changedRows(result) === 1; + } + + async getConnectionStatus(whoopUserId: number): Promise { + const row = await this.db.prepare(` + SELECT status, granted_scopes, connected_at, refreshed_at, last_success_at, + last_error_at, disconnected_at, last_error, consecutive_failure_count, updated_at + FROM whoop_connections + WHERE whoop_user_id = ? + `).bind(whoopUserId).first<{ + status: Exclude; + granted_scopes: string; + connected_at: string | null; + refreshed_at: string | null; + last_success_at: string | null; + last_error_at: string | null; + disconnected_at: string | null; + last_error: string | null; + consecutive_failure_count: number; + updated_at: string; + }>(); + if (!row) return { status: "not_connected" }; + return { + ...row, + granted_scopes: row.granted_scopes.split(/\s+/).filter(Boolean), + last_error: sanitizedError(row.last_error), + }; + } + + async getCurrentConnection(): Promise { + const row = await this.db.prepare(` + SELECT whoop_user_id, connection_id, status, granted_scopes, credential_version, + reconcile_generation, connected_at, refreshed_at, last_success_at, + last_error_at, disconnected_at, last_error, consecutive_failure_count, updated_at + FROM whoop_connections + ORDER BY CASE WHEN status = 'disconnected' THEN 1 ELSE 0 END, + connected_at DESC, whoop_user_id DESC + LIMIT 1 + `).first<{ + whoop_user_id: number; + connection_id: string; + status: Exclude; + granted_scopes: string; + credential_version: number; + reconcile_generation: number; + connected_at: string | null; + refreshed_at: string | null; + last_success_at: string | null; + last_error_at: string | null; + disconnected_at: string | null; + last_error: string | null; + consecutive_failure_count: number; + updated_at: string; + }>(); + if (!row) return null; + return { + whoopUserId: row.whoop_user_id, + connectionId: row.connection_id, + credentialVersion: row.credential_version, + reconcileGeneration: row.reconcile_generation, + status: row.status, + granted_scopes: row.granted_scopes.split(/\s+/).filter(Boolean), + connected_at: row.connected_at, + refreshed_at: row.refreshed_at, + last_success_at: row.last_success_at, + last_error_at: row.last_error_at, + disconnected_at: row.disconnected_at, + last_error: sanitizedError(row.last_error), + consecutive_failure_count: row.consecutive_failure_count, + updated_at: row.updated_at, + }; + } + + async disconnect( + whoopUserId: number, + credentialVersion: number, + disconnectedAt: string, + ): Promise { + const timestamp = canonicalTimestamp(disconnectedAt)!; + const result = await this.db.prepare(` + UPDATE whoop_connections + SET status = 'disconnected', + access_token_ciphertext = NULL, + access_token_nonce = NULL, + access_token_expires_at = NULL, + refresh_token_ciphertext = NULL, + refresh_token_nonce = NULL, + refresh_lease_id = NULL, + refresh_lease_expires_at = NULL, + refresh_dispatched_at = NULL, + disconnected_at = ?, + updated_at = ? + WHERE whoop_user_id = ? AND credential_version = ? AND status != 'disconnected' + `).bind(timestamp, timestamp, whoopUserId, credentialVersion).run(); + return changedRows(result) === 1; + } + + async deleteLocalData(whoopUserId: number, credentialVersion: number): Promise { + const guard = `EXISTS ( + SELECT 1 FROM whoop_connections + WHERE whoop_user_id = ? AND credential_version = ? AND status = 'disconnected' + )`; + const userTables = [ + "whoop_profiles", + "whoop_body_measurements", + "whoop_cycles", + "whoop_recoveries", + "whoop_sleeps", + "whoop_workouts", + "whoop_webhook_events", + "whoop_reconcile_seen", + "whoop_sync_checkpoints", + "whoop_sync_runs", + ]; + const results = await this.db.batch([ + this.db.prepare(`DELETE FROM whoop_oauth_states WHERE ${guard}`) + .bind(whoopUserId, credentialVersion), + ...userTables.map((table) => this.db.prepare( + `DELETE FROM ${table} WHERE whoop_user_id = ? AND ${guard}`, + ).bind(whoopUserId, whoopUserId, credentialVersion)), + this.db.prepare(` + DELETE FROM whoop_connections + WHERE whoop_user_id = ? AND credential_version = ? AND status = 'disconnected' + `).bind(whoopUserId, credentialVersion), + ]); + return changedRows(results.at(-1)!) === 1; + } + + async getSyncProgress(whoopUserId: number): Promise { + const result = await this.db.prepare(` + SELECT resource, mode, status, page_count, record_count, updated_at, last_error + FROM ( + SELECT checkpoint.resource, checkpoint.mode, checkpoint.status, + checkpoint.page_count, checkpoint.record_count, checkpoint.updated_at, + checkpoint.last_error, + ROW_NUMBER() OVER ( + PARTITION BY checkpoint.resource, checkpoint.mode + ORDER BY checkpoint.reconcile_generation DESC, + checkpoint.created_at DESC, + checkpoint.page_count DESC, + checkpoint.record_count DESC + ) AS row_number + FROM whoop_sync_checkpoints AS checkpoint + INNER JOIN whoop_connections AS connection + ON connection.whoop_user_id = checkpoint.whoop_user_id + AND connection.connection_id = checkpoint.connection_id + WHERE checkpoint.whoop_user_id = ? AND checkpoint.target_id = '' + ) + WHERE row_number = 1 + ORDER BY resource ASC, mode ASC + `).bind(whoopUserId).all(); + return result.results.map((row) => ({ ...row, last_error: sanitizedError(row.last_error) })); + } + + async getRecentSyncRuns(whoopUserId: number, limit = 10): Promise { + const boundedLimit = Math.max(1, Math.min(25, Math.trunc(limit))); + const result = await this.db.prepare(` + SELECT run.run_id, run.trigger, run.status, run.page_count, run.record_count, + run.expected_target_count, run.completed_target_count, run.started_at, + run.succeeded_at, run.failed_at, run.last_error + FROM whoop_sync_runs AS run + INNER JOIN whoop_connections AS connection + ON connection.whoop_user_id = run.whoop_user_id + AND connection.connection_id = run.connection_id + WHERE run.whoop_user_id = ? + ORDER BY run.started_at DESC, run.run_id DESC + LIMIT ? + `).bind(whoopUserId, boundedLimit).all(); + return result.results.map((run) => ({ ...run, last_error: sanitizedError(run.last_error) })); + } + + async recordSyncSuccess( + whoopUserId: number, + connectionId: string, + succeededAt: string, + ): Promise { + const timestamp = canonicalTimestamp(succeededAt)!; + const result = await this.db.prepare(` + UPDATE whoop_connections + SET last_success_at = ?, last_error_at = NULL, last_error = NULL, + consecutive_failure_count = 0, updated_at = ? + WHERE whoop_user_id = ? AND connection_id = ? + AND status IN ('active', 'backfilling') + `).bind(timestamp, timestamp, whoopUserId, connectionId).run(); + return changedRows(result) === 1; + } + + async recordSyncFailure( + whoopUserId: number, + connectionId: string, + failedAt: string, + lastError: string, + ): Promise { + const timestamp = canonicalTimestamp(failedAt)!; + const result = await this.db.prepare(` + UPDATE whoop_connections + SET last_error_at = ?, last_error = ?, + consecutive_failure_count = consecutive_failure_count + 1, updated_at = ? + WHERE whoop_user_id = ? AND connection_id = ? + AND status IN ('active', 'backfilling') + `).bind( + timestamp, + sanitizedError(lastError) ?? "WHOOP synchronization failed", + timestamp, + whoopUserId, + connectionId, + ).run(); + return changedRows(result) === 1; + } + + async pruneOperationalData(now: string): Promise<{ + oauthStates: number; + checkpoints: number; + runs: number; + seen: number; + webhookReceipts: number; + }> { + const nowMilliseconds = Date.parse(canonicalTimestamp(now)!); + const cutoff = (milliseconds: number) => new Date(nowMilliseconds - milliseconds).toISOString(); + const oauthCutoff = cutoff(WHOOP_OPERATIONAL_RETENTION.oauthStateMilliseconds); + const checkpointCutoff = cutoff(WHOOP_OPERATIONAL_RETENTION.checkpointMilliseconds); + const runCutoff = cutoff(WHOOP_OPERATIONAL_RETENTION.syncRunMilliseconds); + const seenCutoff = cutoff(WHOOP_OPERATIONAL_RETENTION.reconcileSeenMilliseconds); + const webhookCutoff = cutoff(WHOOP_OPERATIONAL_RETENTION.processedWebhookMilliseconds); + const abandonedWorkCutoff = cutoff(WHOOP_OPERATIONAL_RETENTION.abandonedWorkMilliseconds); + const limit = WHOOP_OPERATIONAL_RETENTION.deleteLimit; + const results = await this.db.batch([ + this.db.prepare(` + DELETE FROM whoop_oauth_states WHERE rowid IN ( + SELECT rowid FROM whoop_oauth_states + WHERE (consumed_at IS NOT NULL AND consumed_at < ?) + OR (expires_at < ?) + ORDER BY expires_at ASC LIMIT ? + ) + `).bind(oauthCutoff, oauthCutoff, limit), + this.db.prepare(` + DELETE FROM whoop_sync_checkpoints WHERE rowid IN ( + SELECT checkpoint.rowid FROM whoop_sync_checkpoints AS checkpoint + WHERE ( + (checkpoint.status IN ('complete', 'error') AND checkpoint.updated_at < ? + AND (checkpoint.target_id != '' OR EXISTS ( + SELECT 1 FROM whoop_sync_checkpoints AS newer + WHERE newer.whoop_user_id = checkpoint.whoop_user_id + AND newer.connection_id = checkpoint.connection_id + AND newer.resource = checkpoint.resource + AND newer.mode = checkpoint.mode + AND newer.target_id = checkpoint.target_id + AND newer.status IN ('complete', 'error') + AND (newer.created_at > checkpoint.created_at + OR (newer.created_at = checkpoint.created_at AND newer.sync_run_id > checkpoint.sync_run_id)) + ))) + OR + (checkpoint.status IN ('queued', 'running', 'retrying') AND checkpoint.updated_at < ? + AND NOT EXISTS ( + SELECT 1 FROM whoop_connections AS current_connection + WHERE current_connection.whoop_user_id = checkpoint.whoop_user_id + AND current_connection.connection_id = checkpoint.connection_id + AND current_connection.status IN ('active', 'backfilling') + AND (checkpoint.mode != 'reconcile' + OR current_connection.reconcile_generation = checkpoint.reconcile_generation) + )) + ) + ORDER BY checkpoint.updated_at ASC LIMIT ? + ) + `).bind(checkpointCutoff, abandonedWorkCutoff, limit), + this.db.prepare(` + DELETE FROM whoop_sync_runs WHERE rowid IN ( + SELECT run.rowid FROM whoop_sync_runs AS run + WHERE ( + (run.status IN ('complete', 'error') AND run.started_at < ? + AND EXISTS ( + SELECT 1 FROM whoop_sync_runs AS newer + WHERE newer.whoop_user_id = run.whoop_user_id + AND newer.connection_id = run.connection_id + AND newer.status IN ('complete', 'error') + AND (newer.started_at > run.started_at + OR (newer.started_at = run.started_at AND newer.run_id > run.run_id)) + )) + OR + (run.status IN ('queued', 'running', 'retrying') AND run.started_at < ? + AND NOT EXISTS ( + SELECT 1 FROM whoop_connections AS current_connection + WHERE current_connection.whoop_user_id = run.whoop_user_id + AND current_connection.connection_id = run.connection_id + AND current_connection.reconcile_generation = run.reconcile_generation + AND current_connection.status IN ('active', 'backfilling') + )) + ) + ORDER BY run.started_at ASC LIMIT ? + ) + `).bind(runCutoff, abandonedWorkCutoff, limit), + this.db.prepare(` + DELETE FROM whoop_reconcile_seen WHERE rowid IN ( + SELECT seen.rowid FROM whoop_reconcile_seen AS seen + WHERE seen.seen_at < ? + AND NOT EXISTS ( + SELECT 1 FROM whoop_sync_checkpoints AS checkpoint + WHERE checkpoint.whoop_user_id = seen.whoop_user_id + AND checkpoint.connection_id = seen.connection_id + AND checkpoint.reconcile_generation = seen.reconcile_generation + AND checkpoint.sync_run_id = seen.reconcile_run_id + AND checkpoint.status IN ('queued', 'running', 'retrying') + ) + ORDER BY seen.seen_at ASC LIMIT ? + ) + `).bind(seenCutoff, limit), + this.db.prepare(` + DELETE FROM whoop_webhook_events WHERE rowid IN ( + SELECT rowid FROM whoop_webhook_events + WHERE status = 'processed' AND processed_at < ? + AND event_type IN ('workout.updated', 'sleep.updated', 'recovery.updated') + ORDER BY processed_at ASC LIMIT ? + ) + `).bind(webhookCutoff, limit), + ]); + return { + oauthStates: changedRows(results[0]), + checkpoints: changedRows(results[1]), + runs: changedRows(results[2]), + seen: changedRows(results[3]), + webhookReceipts: changedRows(results[4]), + }; + } + + async getPendingRecoveryCycleIds(whoopUserId: number, limit = 25): Promise { + if (!Number.isInteger(limit) || limit < 1 || limit > 25) { + throw new Error("WHOOP pending recovery limit must be an integer from 1 to 25"); + } + const result = await this.db.prepare(` + SELECT recovery.cycle_id + FROM whoop_recoveries AS recovery + INNER JOIN whoop_connections AS connection + ON connection.whoop_user_id = recovery.whoop_user_id + WHERE recovery.whoop_user_id = ? + AND recovery.deleted_at IS NULL + AND recovery.score_state IN ('PENDING_SCORE', 'UNSCORABLE') + AND connection.status IN ('active', 'backfilling') + GROUP BY recovery.cycle_id + ORDER BY MIN(COALESCE(( + SELECT MAX(checkpoint.updated_at) + FROM whoop_sync_checkpoints AS checkpoint + WHERE checkpoint.whoop_user_id = recovery.whoop_user_id + AND checkpoint.connection_id = connection.connection_id + AND checkpoint.resource = 'recovery' + AND checkpoint.reconcile_generation = connection.reconcile_generation + AND checkpoint.target_id = 'recovery-cycle:' || CAST(recovery.cycle_id AS TEXT) + ), recovery.synced_at)) ASC, + recovery.cycle_id ASC + LIMIT ? + `).bind(whoopUserId, limit).all<{ cycle_id: number }>(); + return result.results.map((row) => row.cycle_id); + } + + async isSyncConnectionCurrent(whoopUserId: number, connectionId: string): Promise { + const row = await this.db.prepare(` + SELECT 1 AS current + FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? + AND status IN ('active', 'backfilling') + `).bind(whoopUserId, connectionId).first<{ current: number }>(); + return row?.current === 1; + } + + async isReconciliationCurrent( + whoopUserId: number, + connectionId: string, + reconcileGeneration: number, + ): Promise { + const row = await this.db.prepare(` + SELECT 1 AS current + FROM whoop_connections + WHERE whoop_user_id = ? AND connection_id = ? AND reconcile_generation = ? + AND status IN ('active', 'backfilling') + `).bind(whoopUserId, connectionId, reconcileGeneration).first<{ current: number }>(); + return row?.current === 1; + } + + async activateCompletedBackfill( + whoopUserId: number, + connectionId: string, + completedAt: string, + ): Promise { + const timestamp = canonicalTimestamp(completedAt)!; + const result = await this.db.prepare(` + UPDATE whoop_connections + SET status = 'active', + last_success_at = ?, + last_error = NULL, + consecutive_failure_count = 0, + updated_at = ? + WHERE whoop_user_id = ? AND connection_id = ? + AND status = 'backfilling' + AND initial_backfill_pending = 0 + AND 6 = ( + SELECT COUNT(DISTINCT resource) + FROM whoop_sync_checkpoints + WHERE whoop_user_id = ? AND connection_id = ? + AND mode = 'backfill' AND reconcile_generation = 0 + AND target_id = '' AND status = 'complete' + AND resource IN ('profile', 'body_measurement', 'cycle', 'recovery', 'sleep', 'workout') + ) + `).bind(timestamp, timestamp, whoopUserId, connectionId, whoopUserId, connectionId).run(); + return changedRows(result) === 1; + } + + async withWhoopAccessToken( + whoopUserId: number, + request: AccessTokenRequest, + refresh: RefreshTokenRequest, + options: AccessTokenOptions = {}, + ): Promise { + const now = options.now ?? (() => new Date()); + const createLeaseId = options.leaseId ?? (() => crypto.randomUUID()); + const sleep = options.sleep ?? defaultSleep; + const initial = await this.requireTokenConnection(whoopUserId); + if (options.expectedConnectionId !== undefined + && initial.connection_id !== options.expectedConnectionId) { + throw new WhoopStaleConnectionError(); + } + const initialCredentialVersion = initial.credential_version; + const initialAccessToken = await this.decryptToken(initial, "access"); + const refreshBeforeExpirationMilliseconds = options.refreshBeforeExpirationMilliseconds; + if (refreshBeforeExpirationMilliseconds !== undefined + && (!Number.isFinite(refreshBeforeExpirationMilliseconds) + || refreshBeforeExpirationMilliseconds < 0)) { + throw new Error("WHOOP refresh-before-expiration window is invalid"); + } + const accessTokenExpiresAt = initial.access_token_expires_at === null + ? Number.NaN + : Date.parse(initial.access_token_expires_at); + const proactiveRefresh = refreshBeforeExpirationMilliseconds !== undefined + && (!Number.isFinite(accessTokenExpiresAt) + || accessTokenExpiresAt <= now().getTime() + refreshBeforeExpirationMilliseconds); + + if (!proactiveRefresh) { + try { + return await request(initialAccessToken, initialCredentialVersion); + } catch (error) { + if (!(error instanceof WhoopUnauthorizedError)) throw error; + } + } + + const leaseId = createLeaseId(); + const refreshStartedAt = now(); + const ownsLease = await this.acquireRefreshLease( + whoopUserId, + leaseId, + refreshStartedAt.toISOString(), + initialCredentialVersion, + ); + let retryAccessToken: string; + let retryCredentialVersion: number; + + if (ownsLease) { + let ordinaryReleaseAllowed = true; + try { + const latest = await this.findOwnedRefreshLease( + whoopUserId, + initialCredentialVersion, + leaseId, + now().toISOString(), + ); + if (!latest) throw new Error("WHOOP refresh lease ownership was lost before refresh"); + const latestRefreshToken = await this.decryptToken(latest, "refresh"); + const abortAt = refreshStartedAt.getTime() + REFRESH_ABORT_MILLISECONDS; + const millisecondsUntilAbort = abortAt - now().getTime(); + if (millisecondsUntilAbort <= 0) { + throw new Error("WHOOP refresh deadline elapsed before dispatch"); + } + const dispatched = await this.markRefreshDispatched( + whoopUserId, + leaseId, + initialCredentialVersion, + now().toISOString(), + ); + if (!dispatched) throw new Error("WHOOP refresh lease ownership was lost before dispatch"); + ordinaryReleaseAllowed = false; + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(new DOMException("WHOOP token refresh timed out", "TimeoutError")); + }, millisecondsUntilAbort); + let tokens: WhoopTokenResponse; + try { + tokens = await refresh(latestRefreshToken, { signal: controller.signal }); + } catch (error) { + if (isDefiniteRefreshFailure(error)) { + const cleared = await this.clearDefiniteRefreshFailure( + whoopUserId, + leaseId, + initialCredentialVersion, + now().toISOString(), + ); + if (!cleared) throw new Error("WHOOP refresh dispatch ownership was lost"); + throw error; + } + await this.quarantineAmbiguousRefresh( + whoopUserId, + initialCredentialVersion, + leaseId, + now().toISOString(), + ); + throw ambiguousRefreshFailure(error); + } finally { + clearTimeout(timeoutId); + } + try { + const rotatedAt = now(); + const [accessToken, refreshToken] = await Promise.all([ + encryptWhoopToken(this.tokenEncryptionKey, whoopUserId, "access", tokens.access_token), + encryptWhoopToken(this.tokenEncryptionKey, whoopUserId, "refresh", tokens.refresh_token), + ]); + const stored = await this.storeRotatedTokens(whoopUserId, leaseId, initialCredentialVersion, { + accessToken, + accessTokenExpiresAt: new Date(rotatedAt.getTime() + tokens.expires_in * 1000).toISOString(), + refreshToken, + grantedScopes: tokens.scope?.split(/\s+/).filter(Boolean) + ?? latest.granted_scopes.split(/\s+/).filter(Boolean), + refreshedAt: rotatedAt.toISOString(), + }); + if (!stored) throw new Error("WHOOP rotated token storage was not committed"); + } catch (error) { + await this.quarantineAmbiguousRefresh( + whoopUserId, + initialCredentialVersion, + leaseId, + now().toISOString(), + ); + throw ambiguousRefreshFailure(error); + } + retryAccessToken = tokens.access_token; + retryCredentialVersion = initialCredentialVersion + 1; + } finally { + if (ordinaryReleaseAllowed) { + await this.releaseRefreshLease( + whoopUserId, + leaseId, + initialCredentialVersion, + now().toISOString(), + ); + } + } + } else { + await sleep(REFRESH_WAIT_MILLISECONDS); + const reread = await this.requireTokenConnection(whoopUserId); + if (options.expectedConnectionId !== undefined + && reread.connection_id !== options.expectedConnectionId) { + throw new WhoopStaleConnectionError(); + } + retryAccessToken = await this.decryptToken(reread, "access"); + retryCredentialVersion = reread.credential_version; + if (retryAccessToken === initialAccessToken) { + throw new Error("WHOOP access token refresh is still in progress"); + } + } + + try { + return await request(retryAccessToken, retryCredentialVersion); + } catch (error) { + if (error instanceof WhoopUnauthorizedError) { + await this.markNeedsReauth(whoopUserId, retryCredentialVersion, now().toISOString()); + } + throw error; + } + } + + private async requireTokenConnection(whoopUserId: number): Promise { + const row = await this.db.prepare(` + SELECT whoop_user_id, connection_id, status, access_token_ciphertext, access_token_nonce, + access_token_expires_at, refresh_token_ciphertext, refresh_token_nonce, + granted_scopes, credential_version + FROM whoop_connections + WHERE whoop_user_id = ? + `).bind(whoopUserId).first(); + if (!row) throw new Error("WHOOP connection is not available"); + return row; + } + + private async findOwnedRefreshLease( + whoopUserId: number, + credentialVersion: number, + leaseId: string, + now: string, + ): Promise { + return this.db.prepare(` + SELECT whoop_user_id, connection_id, status, access_token_ciphertext, access_token_nonce, + access_token_expires_at, refresh_token_ciphertext, refresh_token_nonce, + granted_scopes, credential_version + FROM whoop_connections + WHERE whoop_user_id = ? AND credential_version = ? + AND status IN ('active', 'backfilling') + AND refresh_dispatched_at IS NULL + AND refresh_lease_id = ? AND refresh_lease_expires_at > ? + `).bind(whoopUserId, credentialVersion, leaseId, now).first(); + } + + private async decryptToken(row: TokenConnectionRow, kind: "access" | "refresh"): Promise { + const ciphertext = kind === "access" ? row.access_token_ciphertext : row.refresh_token_ciphertext; + const nonce = kind === "access" ? row.access_token_nonce : row.refresh_token_nonce; + if (!ciphertext || !nonce) throw new Error("WHOOP connection token is not available"); + return decryptWhoopToken(this.tokenEncryptionKey, row.whoop_user_id, kind, { ciphertext, nonce }); + } + + private async markNeedsReauth(whoopUserId: number, credentialVersion: number, now: string): Promise { + await this.db.prepare(` + UPDATE whoop_connections + SET status = 'needs_reauth', last_error_at = ?, updated_at = ? + WHERE whoop_user_id = ? AND credential_version = ? + `).bind(now, now, whoopUserId, credentialVersion).run(); + } + + private async quarantineAmbiguousRefresh( + whoopUserId: number, + credentialVersion: number, + leaseId: string, + now: string, + ): Promise { + const result = await this.db.prepare(` + UPDATE whoop_connections + SET status = 'needs_reauth', + last_error_at = ?, + updated_at = ?, + last_error = 'WHOOP token refresh outcome is unknown', + refresh_lease_id = NULL, + refresh_lease_expires_at = NULL + WHERE whoop_user_id = ? AND refresh_lease_id = ? AND credential_version = ? + AND refresh_dispatched_at IS NOT NULL + `).bind(now, now, whoopUserId, leaseId, credentialVersion).run(); + return changedRows(result) === 1; + } +} + +export const withWhoopAccessToken = ( + repository: WhoopRepository, + whoopUserId: number, + request: AccessTokenRequest, + refresh: RefreshTokenRequest, + options: AccessTokenOptions = {}, +): Promise => repository.withWhoopAccessToken(whoopUserId, request, refresh, options); diff --git a/src/services/whoop/sync.ts b/src/services/whoop/sync.ts new file mode 100644 index 0000000..fe1cd7f --- /dev/null +++ b/src/services/whoop/sync.ts @@ -0,0 +1,698 @@ +import type { Env } from "../../types/env"; +import type { + WhoopQueueMessage, + WhoopResource, + WhoopWebhookEventType, +} from "../../types/whoop"; +import { WhoopClient, WhoopRequestError } from "./client"; +import { WhoopRepository, WhoopStaleConnectionError } from "./repository"; + +const RECONCILIATION_WINDOW_MILLISECONDS = 14 * 24 * 60 * 60 * 1000; + +class WhoopPostCheckpointError extends Error { + constructor() { + super("WHOOP post-checkpoint operation failed"); + } +} + +type SyncClient = Pick; + +type SyncRepository = Pick; + +type ReconciliationPublisherRepository = Pick; + +export interface WhoopSyncDependencies { + repository?: SyncRepository; + client?: SyncClient; + clientFactory?: (env: Env, accessToken: string) => SyncClient; + now?: () => Date; +} + +export interface EnqueueReconciliationDependencies { + repository?: ReconciliationPublisherRepository; + now?: () => Date; + expectedConnectionId?: string; + requireActiveConnection?: boolean; +} + +export interface ProcessWebhookInput { + eventType: WhoopWebhookEventType; + resourceId: string; + whoopUserId: number; + connectionId: string; +} + +const requireCurrentWrite = (written: boolean | void): void => { + if (written === false) throw new WhoopStaleConnectionError(); +}; + +const isCollectionResource = ( + resource: WhoopResource, +): resource is "cycle" | "recovery" | "sleep" | "workout" => + resource === "cycle" || resource === "recovery" || resource === "sleep" || resource === "workout"; + +const RECONCILIATION_RESOURCES = [ + "profile", "body_measurement", "cycle", "recovery", "sleep", "workout", +] as const; + +const checkpointIdentity = (body: Exclude) => body.kind === "backfill" + ? { reconcileGeneration: 0, syncRunId: "initial-backfill", targetId: "" } + : { + reconcileGeneration: body.reconcileGeneration, + syncRunId: body.reconcileRunId, + targetId: body.recoveryCycleId === undefined ? "" : `recovery-cycle:${body.recoveryCycleId}`, + }; + +const providerIdFor = ( + resource: "cycle" | "recovery" | "sleep" | "workout", + record: Record, +): string | number => { + const providerId = resource === "recovery" ? record.sleep_id : record.id; + if (typeof providerId !== "string" && typeof providerId !== "number") { + throw new Error("WHOOP source identity is unavailable"); + } + return providerId; +}; + +export async function enqueueReconciliation( + env: Env, + whoopUserId: number, + trigger: string, + dependencies: EnqueueReconciliationDependencies = {}, +): Promise { + const repository = dependencies.repository + ?? new WhoopRepository(env.DB, env.WHOOP_TOKEN_ENCRYPTION_KEY); + const now = (dependencies.now ?? (() => new Date()))(); + const connection = await repository.getCurrentConnection(); + if (!connection + || connection.whoopUserId !== whoopUserId + || (dependencies.expectedConnectionId !== undefined + && connection.connectionId !== dependencies.expectedConnectionId) + || (dependencies.requireActiveConnection + ? connection.status !== "active" + : connection.status !== "active" && connection.status !== "backfilling")) { + throw new Error("WHOOP connection is not available for reconciliation"); + } + const windowEnd = now.toISOString(); + const windowStart = new Date(now.getTime() - RECONCILIATION_WINDOW_MILLISECONDS).toISOString(); + const reconcileRunId = crypto.randomUUID(); + const reconcileGeneration = dependencies.requireActiveConnection + ? await repository.beginReconciliation(whoopUserId, connection.connectionId, windowEnd, true) + : await repository.beginReconciliation(whoopUserId, connection.connectionId, windowEnd); + if (reconcileGeneration === null) { + throw new Error("WHOOP connection changed before reconciliation began"); + } + const pendingRecoveryCycleIds = await repository.getPendingRecoveryCycleIds(whoopUserId, 25); + const messages: WhoopQueueMessage[] = RECONCILIATION_RESOURCES.map((resource) => ({ + kind: "reconcile", + whoopUserId, + connectionId: connection.connectionId, + reconcileGeneration, + reconcileRunId, + resource, + ...(isCollectionResource(resource) ? { windowStart, windowEnd } : {}), + trigger, + })); + messages.push(...pendingRecoveryCycleIds.map((recoveryCycleId) => ({ + kind: "reconcile" as const, + whoopUserId, + connectionId: connection.connectionId, + reconcileGeneration, + reconcileRunId, + resource: "recovery" as const, + recoveryCycleId, + trigger, + }))); + const runCreated = await repository.createSyncRun({ + runId: reconcileRunId, + whoopUserId, + connectionId: connection.connectionId, + reconcileGeneration, + trigger, + expectedTargetCount: messages.length, + startedAt: windowEnd, + }); + if (!runCreated) throw new Error("WHOOP connection changed before reconciliation publication"); + try { + await env.WHOOP_SYNC_QUEUE.sendBatch(messages.map((body) => ({ body }))); + } catch { + await repository.markSyncRunPublicationFailure( + reconcileRunId, + whoopUserId, + connection.connectionId, + reconcileGeneration, + windowEnd, + ); + await repository.recordSyncFailure( + whoopUserId, + connection.connectionId, + windowEnd, + "WHOOP queue publication failed", + ); + throw new Error("WHOOP reconciliation queue publication failed"); + } +} + +export async function processWebhook( + input: ProcessWebhookInput, + dependencies: WhoopSyncDependencies, +): Promise { + if (!dependencies.repository) { + throw new Error("WHOOP webhook processing dependencies are unavailable"); + } + const syncedAt = (dependencies.now ?? (() => new Date()))().toISOString(); + if (input.eventType.endsWith(".deleted")) { + const resource = input.eventType.split(".", 1)[0] as "workout" | "sleep" | "recovery"; + requireCurrentWrite(await dependencies.repository.tombstoneSourceRecord( + resource, + input.resourceId, + syncedAt, + { whoopUserId: input.whoopUserId, connectionId: input.connectionId }, + )); + return; + } + if (!dependencies.client) { + throw new Error("WHOOP webhook processing dependencies are unavailable"); + } + if (input.eventType === "recovery.updated") { + const sleep = await dependencies.client.getSleep(input.resourceId); + const recovery = await dependencies.client.getRecovery(sleep.cycle_id); + requireCurrentWrite(await dependencies.repository.upsertSourceRecord("recovery", recovery, { + tombstonePolicy: "preserve", + syncedAt, + whoopUserId: input.whoopUserId, + connectionId: input.connectionId, + })); + } + if (input.eventType === "workout.updated") { + const workout = await dependencies.client.getWorkout(input.resourceId); + requireCurrentWrite(await dependencies.repository.upsertSourceRecord("workout", workout, { + tombstonePolicy: "preserve", + syncedAt, + whoopUserId: input.whoopUserId, + connectionId: input.connectionId, + })); + } + if (input.eventType === "sleep.updated") { + const sleep = await dependencies.client.getSleep(input.resourceId); + requireCurrentWrite(await dependencies.repository.upsertSourceRecord("sleep", sleep, { + tombstonePolicy: "preserve", + syncedAt, + whoopUserId: input.whoopUserId, + connectionId: input.connectionId, + })); + } +} + +export async function handleWhoopQueue( + batch: MessageBatch, + env: Env, + dependencies: WhoopSyncDependencies = {}, +): Promise { + const repository = dependencies.repository + ?? new WhoopRepository(env.DB, env.WHOOP_TOKEN_ENCRYPTION_KEY); + const now = dependencies.now ?? (() => new Date()); + + for (const message of batch.messages) { + const body = message.body; + try { + const currentConnection = body.kind === "reconcile" + ? await repository.isReconciliationCurrent( + body.whoopUserId, + body.connectionId, + body.reconcileGeneration, + ) + : await repository.isSyncConnectionCurrent(body.whoopUserId, body.connectionId); + if (!currentConnection) { + message.ack(); + continue; + } + if (body.kind === "webhook") { + const processEvent = async (client: SyncClient): Promise => processWebhook({ + eventType: body.eventType, + resourceId: body.resourceId, + whoopUserId: body.whoopUserId, + connectionId: body.connectionId, + }, { repository, client, now }); + if (body.eventType.endsWith(".deleted")) { + await processWebhook({ + eventType: body.eventType, + resourceId: body.resourceId, + whoopUserId: body.whoopUserId, + connectionId: body.connectionId, + }, { repository, now }); + } else if (dependencies.client) { + await processEvent(dependencies.client); + } else { + const tokenRepository = repository as WhoopRepository; + await tokenRepository.withWhoopAccessToken( + body.whoopUserId, + (accessToken) => processEvent( + dependencies.clientFactory?.(env, accessToken) ?? new WhoopClient(env, accessToken), + ), + (refreshToken, options) => new WhoopClient(env, "").refreshToken(refreshToken, options), + { expectedConnectionId: body.connectionId }, + ); + } + requireCurrentWrite(await repository.markWebhookProcessed( + body.traceId, + body.whoopUserId, + body.connectionId, + now().toISOString(), + )); + requireCurrentWrite(await repository.recordSyncSuccess( + body.whoopUserId, + body.connectionId, + now().toISOString(), + )); + message.ack(); + continue; + } + const resource = body.resource; + const windowEnd = body.kind === "reconcile" + ? body.windowEnd ?? now().toISOString() + : null; + const windowStart = body.kind === "reconcile" + ? body.windowStart ?? new Date(Date.parse(windowEnd!) - RECONCILIATION_WINDOW_MILLISECONDS).toISOString() + : null; + const tombstonePolicy = body.kind === "reconcile" ? "reconcile" : "preserve"; + const identity = checkpointIdentity(body); + + const processPage = async (client: SyncClient): Promise => { + if (resource === "profile" || resource === "body_measurement") { + const record = resource === "profile" + ? await client.getProfile() + : { ...await client.getBodyMeasurements(), whoop_user_id: body.whoopUserId }; + const syncedAt = now().toISOString(); + requireCurrentWrite(await repository.upsertSourceRecord(resource, record, { + tombstonePolicy, + syncedAt, + whoopUserId: body.whoopUserId, + connectionId: body.connectionId, + ...(body.kind === "reconcile" + ? { reconcileGeneration: body.reconcileGeneration } + : {}), + })); + requireCurrentWrite(await repository.upsertCheckpoint({ + whoopUserId: body.whoopUserId, + connectionId: body.connectionId, + resource, + mode: body.kind, + ...identity, + windowStart, + windowEnd, + nextToken: null, + status: "complete", + pageCount: 1, + recordCount: 1, + createdAt: syncedAt, + updatedAt: syncedAt, + lastError: null, + })); + if (body.kind === "backfill") { + try { + await repository.activateCompletedBackfill( + body.whoopUserId, + body.connectionId, + syncedAt, + ); + } catch { + throw new WhoopPostCheckpointError(); + } + } + return; + } + if (body.kind === "reconcile" + && resource === "recovery" + && body.recoveryCycleId !== undefined) { + const record = await client.getRecovery(body.recoveryCycleId); + const syncedAt = now().toISOString(); + requireCurrentWrite(await repository.upsertSourceRecord("recovery", record, { + tombstonePolicy: "reconcile", + syncedAt, + whoopUserId: body.whoopUserId, + connectionId: body.connectionId, + reconcileGeneration: body.reconcileGeneration, + })); + requireCurrentWrite(await repository.upsertCheckpoint({ + whoopUserId: body.whoopUserId, + connectionId: body.connectionId, + resource, + mode: body.kind, + ...identity, + windowStart, + windowEnd, + nextToken: null, + status: "complete", + pageCount: 1, + recordCount: 1, + createdAt: syncedAt, + updatedAt: syncedAt, + lastError: null, + })); + return; + } + if (!isCollectionResource(resource)) return; + + const page = await client.getCollection(resource, { + limit: 25, + ...(windowStart === null ? {} : { start: windowStart }), + ...(windowEnd === null ? {} : { end: windowEnd }), + ...(body.nextToken === undefined ? {} : { nextToken: body.nextToken }), + }); + const syncedAt = now().toISOString(); + for (const record of page.records) { + requireCurrentWrite(await repository.upsertSourceRecord(resource, record, { + tombstonePolicy, + syncedAt, + whoopUserId: body.whoopUserId, + connectionId: body.connectionId, + ...(body.kind === "reconcile" + ? { reconcileGeneration: body.reconcileGeneration } + : {}), + })); + if (body.kind === "reconcile") { + requireCurrentWrite(await repository.recordReconciliationSeen({ + whoopUserId: body.whoopUserId, + connectionId: body.connectionId, + reconcileGeneration: body.reconcileGeneration, + reconcileRunId: body.reconcileRunId, + resource, + providerId: providerIdFor(resource, record as unknown as Record), + seenAt: syncedAt, + })); + } + } + const pageCount = (body.pageCount ?? 0) + 1; + const recordCount = (body.recordCount ?? 0) + page.records.length; + const checkpoint = { + whoopUserId: body.whoopUserId, + connectionId: body.connectionId, + resource, + mode: body.kind, + ...identity, + windowStart, + windowEnd, + nextToken: page.nextToken ?? null, + status: page.nextToken === undefined ? "complete" : "running", + pageCount, + recordCount, + createdAt: syncedAt, + updatedAt: syncedAt, + lastError: null, + }; + if (body.kind === "reconcile" && page.nextToken === undefined) { + requireCurrentWrite(await repository.finalizeReconciliation(checkpoint)); + } else { + requireCurrentWrite(await repository.upsertCheckpoint(checkpoint)); + } + if (page.nextToken !== undefined) { + try { + const nextMessage: WhoopQueueMessage = body.kind === "reconcile" + ? { + kind: "reconcile", + whoopUserId: body.whoopUserId, + connectionId: body.connectionId, + reconcileGeneration: body.reconcileGeneration, + reconcileRunId: body.reconcileRunId, + resource, + nextToken: page.nextToken, + pageCount, + recordCount, + windowStart: windowStart!, + windowEnd: windowEnd!, + ...(body.trigger === undefined ? {} : { trigger: body.trigger }), + } + : { + kind: "backfill", + whoopUserId: body.whoopUserId, + connectionId: body.connectionId, + resource, + nextToken: page.nextToken, + pageCount, + recordCount, + ...(body.trigger === undefined ? {} : { trigger: body.trigger }), + }; + await env.WHOOP_SYNC_QUEUE.send(nextMessage); + } catch { + requireCurrentWrite(await repository.upsertCheckpoint({ + ...checkpoint, + status: "retrying", + lastError: "WHOOP queue publication failed", + })); + throw new WhoopPostCheckpointError(); + } + } else if (body.kind === "backfill") { + try { + await repository.activateCompletedBackfill( + body.whoopUserId, + body.connectionId, + syncedAt, + ); + } catch { + throw new WhoopPostCheckpointError(); + } + } + }; + + if (dependencies.client) { + await processPage(dependencies.client); + } else { + const tokenRepository = repository as WhoopRepository; + await tokenRepository.withWhoopAccessToken( + body.whoopUserId, + (accessToken) => processPage( + dependencies.clientFactory?.(env, accessToken) ?? new WhoopClient(env, accessToken), + ), + (refreshToken, options) => new WhoopClient(env, "").refreshToken(refreshToken, options), + { expectedConnectionId: body.connectionId }, + ); + } + if (body.kind === "reconcile") { + requireCurrentWrite(await repository.refreshSyncRun( + body.reconcileRunId, + body.whoopUserId, + body.connectionId, + body.reconcileGeneration, + now().toISOString(), + )); + requireCurrentWrite(await repository.recordSyncSuccess( + body.whoopUserId, + body.connectionId, + now().toISOString(), + )); + } + message.ack(); + } catch (error) { + if (error instanceof WhoopStaleConnectionError) { + message.ack(); + continue; + } + if (error instanceof WhoopPostCheckpointError) { + if (body.kind === "reconcile") { + const failedAt = now().toISOString(); + try { + const checkpointed = await repository.upsertCheckpoint({ + whoopUserId: body.whoopUserId, + connectionId: body.connectionId, + resource: body.resource, + mode: body.kind, + ...checkpointIdentity(body), + windowStart: body.windowStart ?? null, + windowEnd: body.windowEnd ?? null, + nextToken: body.nextToken ?? null, + status: "retrying", + pageCount: body.pageCount ?? 0, + recordCount: body.recordCount ?? 0, + createdAt: failedAt, + updatedAt: failedAt, + lastError: "WHOOP queue publication failed", + }); + if (!checkpointed) { + message.ack(); + continue; + } + requireCurrentWrite(await repository.refreshSyncRun( + body.reconcileRunId, body.whoopUserId, body.connectionId, + body.reconcileGeneration, failedAt, + )); + requireCurrentWrite(await repository.recordSyncFailure( + body.whoopUserId, body.connectionId, failedAt, + "WHOOP queue publication failed", + )); + } catch (postCheckpointFailure) { + if (postCheckpointFailure instanceof WhoopStaleConnectionError) { + message.ack(); + continue; + } + } + } + message.retry({ delaySeconds: 30 }); + continue; + } + if (body.kind === "webhook") { + const lastError = error instanceof WhoopRequestError && error.status !== undefined + ? `WHOOP request failed with status ${error.status}` + : "WHOOP synchronization failed"; + const permanentClientError = error instanceof WhoopRequestError + && error.status !== undefined + && error.status >= 400 + && error.status < 500 + && !error.retryable; + let failureFinalized = false; + try { + const failureResult = await repository.markWebhookFailed( + body.traceId, + body.whoopUserId, + body.connectionId, + permanentClientError ? "error" : "retrying", + lastError, + now().toISOString(), + ); + if (failureResult === false) { + message.ack(); + continue; + } + const healthRecorded = await repository.recordSyncFailure( + body.whoopUserId, + body.connectionId, + now().toISOString(), + lastError, + ); + if (!healthRecorded) { + message.ack(); + continue; + } + failureFinalized = true; + } catch (failureWriteError) { + if (failureWriteError instanceof WhoopStaleConnectionError) { + message.ack(); + continue; + } + // Retrying preserves the webhook when its durable status write fails. + } + if (permanentClientError && failureFinalized) { + message.ack(); + continue; + } + message.retry({ delaySeconds: error instanceof WhoopRequestError + ? error.retryAfterSeconds ?? 30 + : 30 }); + continue; + } + const failedAt = now().toISOString(); + const lastError = error instanceof WhoopRequestError && error.status !== undefined + ? `WHOOP request failed with status ${error.status}` + : "WHOOP synchronization failed"; + const permanentClientError = error instanceof WhoopRequestError + && error.status !== undefined + && error.status >= 400 + && error.status < 500 + && !error.retryable; + let failureFinalized = false; + try { + const checkpointResult = await repository.upsertCheckpoint({ + whoopUserId: body.whoopUserId, + connectionId: body.connectionId, + resource: body.resource, + mode: body.kind, + ...checkpointIdentity(body), + windowStart: body.kind === "reconcile" ? body.windowStart ?? null : null, + windowEnd: body.kind === "reconcile" ? body.windowEnd ?? null : null, + nextToken: body.nextToken ?? null, + status: permanentClientError ? "error" : "retrying", + pageCount: body.pageCount ?? 0, + recordCount: body.recordCount ?? 0, + createdAt: failedAt, + updatedAt: failedAt, + lastError, + }); + if (checkpointResult === false) { + message.ack(); + continue; + } + if (permanentClientError + && body.kind === "reconcile" + && body.recoveryCycleId === undefined) { + const cleaned = await repository.cleanupReconciliationSeen({ + whoopUserId: body.whoopUserId, + connectionId: body.connectionId, + reconcileGeneration: body.reconcileGeneration, + reconcileRunId: body.reconcileRunId, + resource: body.resource, + }); + if (!cleaned) { + message.ack(); + continue; + } + } + if (body.kind === "reconcile") { + requireCurrentWrite(await repository.refreshSyncRun( + body.reconcileRunId, + body.whoopUserId, + body.connectionId, + body.reconcileGeneration, + failedAt, + )); + } + const healthRecorded = await repository.recordSyncFailure( + body.whoopUserId, + body.connectionId, + failedAt, + lastError, + ); + if (!healthRecorded) { + message.ack(); + continue; + } + failureFinalized = true; + } catch (failureWriteError) { + if (failureWriteError instanceof WhoopStaleConnectionError) { + message.ack(); + continue; + } + // Retrying the message is the durable fallback when checkpointing fails. + } + if (permanentClientError && failureFinalized) { + message.ack(); + continue; + } + message.retry({ delaySeconds: error instanceof WhoopRequestError + ? error.retryAfterSeconds ?? 30 + : 30 }); + } + } +} diff --git a/src/types/env.ts b/src/types/env.ts index d1ef6e7..b40bf0d 100644 --- a/src/types/env.ts +++ b/src/types/env.ts @@ -2,6 +2,12 @@ export interface Env { DB: D1Database; R2_BUCKET: R2Bucket; API_TOKEN: string; + WHOOP_CLIENT_ID: string; + WHOOP_CLIENT_SECRET: string; + WHOOP_TOKEN_ENCRYPTION_KEY: string; + WHOOP_REDIRECT_URI: string; + OS_BASE_URL: string; + WHOOP_SYNC_QUEUE: Queue; LANYARD_USER_ID: string; WAKATIME_API_KEY: string; WAKATIME_TIMEZONE: string; diff --git a/src/types/whoop.ts b/src/types/whoop.ts new file mode 100644 index 0000000..7615681 --- /dev/null +++ b/src/types/whoop.ts @@ -0,0 +1,78 @@ +export const WHOOP_SCOPES = [ + "offline", + "read:profile", + "read:body_measurement", + "read:cycles", + "read:recovery", + "read:sleep", + "read:workout", +] as const; + +export type WhoopScope = (typeof WHOOP_SCOPES)[number]; + +export type WhoopConnectionStatus = + | "not_connected" + | "connecting" + | "backfilling" + | "active" + | "needs_reauth" + | "disconnected" + | "error"; + +export type WhoopResource = + | "profile" + | "body_measurement" + | "cycle" + | "recovery" + | "sleep" + | "workout"; + +export type WhoopWebhookEventType = + | "workout.updated" + | "workout.deleted" + | "sleep.updated" + | "sleep.deleted" + | "recovery.updated" + | "recovery.deleted"; + +export interface WhoopWebhookEvent { + user_id: number; + id: string; + type: WhoopWebhookEventType; + trace_id: string; +} + +export type WhoopQueueMessage = + | { + kind: "backfill"; + whoopUserId: number; + connectionId: string; + resource: WhoopResource; + nextToken?: string; + pageCount?: number; + recordCount?: number; + trigger?: string; + } + | { + kind: "reconcile"; + whoopUserId: number; + connectionId: string; + reconcileGeneration: number; + reconcileRunId: string; + resource: WhoopResource; + nextToken?: string; + windowStart?: string; + windowEnd?: string; + pageCount?: number; + recordCount?: number; + recoveryCycleId?: number; + trigger?: string; + } + | { + kind: "webhook"; + traceId: string; + whoopUserId: number; + connectionId: string; + resourceId: string; + eventType: WhoopWebhookEventType; + }; diff --git a/wrangler.toml b/wrangler.toml index 0facd57..13089f5 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,4 +1,5 @@ name = "api" +account_id = "313c4e6e881f1e07c880d7230541200a" main = "src/index.ts" compatibility_date = "2024-08-01" @@ -7,6 +8,8 @@ API_VERSION = "0.4.0" R2_PUBLIC_BASE_URL = "https://media.anuragd.me" API_BASE_URL = "https://api.anuragd.me" GITHUB_WORK_USERNAME = "anurag-wa" +WHOOP_REDIRECT_URI = "https://api.anuragd.me/integrations/whoop/callback" +OS_BASE_URL = "https://os.anuragd.me" [triggers] crons = ["*/5 * * * *"] @@ -19,3 +22,15 @@ database_id = "f83dc86f-521f-4880-b54e-e3238baf861f" [[r2_buckets]] binding = "R2_BUCKET" bucket_name = "api-media" + +[[queues.producers]] +binding = "WHOOP_SYNC_QUEUE" +queue = "whoop-health-sync" + +[[queues.consumers]] +queue = "whoop-health-sync" +dead_letter_queue = "whoop-health-sync-dlq" +max_batch_size = 1 +max_batch_timeout = 1 +max_concurrency = 1 +max_retries = 5