diff --git a/CHANGELOG.md b/CHANGELOG.md index 85e4490d5..6b42cf3fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## [Unreleased] +## [1.32.24] — 2026-07-24 + +Polar sync is more resilient when one data type is unavailable. A single +collection that a device or account cannot serve no longer blanks the rest of +the Polar data on every hourly check. + +- **One unavailable Polar data type no longer blocks the others.** SpO2 on a + watch without the sensor, or a data type an account is not entitled to, used + to fail the whole Polar sync and leave recharge, sleep, and activity empty. + Each collection now imports on its own, so the ones that are available still + come in and only the one that failed is held back. +- **A failing Polar collection is named on the integration status.** When a + collection cannot be read, the sync is recorded as failed with the failing + collection named, instead of showing a healthy state over missing data. +- **Unexpected Polar response shapes are logged, not read as empty.** If Polar + returns a body that does not match the expected shape, it is now recorded for + diagnosis rather than silently treated as "no data", which previously could + mark a sync healthy while importing nothing. + ## [1.32.23] — 2026-07-24 Sync status now tells the truth for the connected wearables and CGM sources. A diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b02b00b47..0f7ef5f94 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.23 + version: 1.32.24 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 3dbbb9edd..1ef486bb1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.23", + "version": "1.32.24", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index e9d38b708..0ad558d70 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.23"; + /* @sw-version-fallback */ "v1.32.24"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts b/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts index 3d1c30923..557cf14e5 100644 --- a/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts +++ b/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts @@ -30,6 +30,18 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1"); }); + it("sums both counts without throwing when the vitals leg returns a partial-failure count", async () => { + // Post-R7 the vitals leg records a partial failure INTERNALLY and RETURNS + // its healthy-collections import count rather than throwing. The wrapper + // must sum it with the workout leg and complete cleanly — a partial vitals + // failure never starves the user's success accounting at the cohort level. + syncUserPolar.mockResolvedValue(4); + syncUserPolarWorkouts.mockResolvedValue(2); + await expect(syncUserPolarLegs("u1")).resolves.toBe(6); + expect(syncUserPolar).toHaveBeenCalledWith("u1"); + expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1"); + }); + it("still attempts the workout leg when the vitals leg fails (vice-versa isolation)", async () => { syncUserPolar.mockRejectedValue(new Error("vitals down")); syncUserPolarWorkouts.mockResolvedValue(3); diff --git a/src/lib/jobs/reminder/polar-sync.ts b/src/lib/jobs/reminder/polar-sync.ts index ae06b02c9..ee7c80253 100644 --- a/src/lib/jobs/reminder/polar-sync.ts +++ b/src/lib/jobs/reminder/polar-sync.ts @@ -27,6 +27,12 @@ export type PolarSyncPayload = PollCohortPayload; * always attempted, so a workouts-leg failure can never mask a vitals success * and vice versa — then the FIRST error is rethrown so the cohort handler warns * and skips this user's success accounting without losing the error. + * + * The vitals leg (`syncUserPolar`) now settles its five collections + * independently: a fetch/map failure on one collection is recorded as a partial + * failure and the leg RETURNS (no rethrow, no success stamp), importing the + * healthy collections. So the vitals leg reaching this wrapper's catch means a + * write-path/unexpected error, not a collection fetch failure. */ export async function syncUserPolarLegs(userId: string): Promise { let total = 0; @@ -63,8 +69,10 @@ export const handlePolarSync = makePollCohortHandler({ return users.map((u) => u.id); }, syncUser: syncUserPolarLegs, - // Catches an UNMARKED escape — today the `upsertPolarMeasurements` / - // `upsertPolarWorkouts` write-path throws (the fetch paths record + mark - // their own failures, and the legs wrapper rethrows the marked error). + // Catches an UNMARKED escape — the `upsertPolarMeasurements` / + // `upsertPolarWorkouts` write-path throw. The vitals leg records its own + // per-collection partial failures inline and returns without rethrowing; the + // workouts leg records + marks its own fetch failures. Only an unmarked + // write-path/unexpected escape reaches this boundary recorder. recordFailure: recordPolarSyncFailure, }); diff --git a/src/lib/polar/__tests__/client-exercises.test.ts b/src/lib/polar/__tests__/client-exercises.test.ts index cf41fa331..60e1ee6b7 100644 --- a/src/lib/polar/__tests__/client-exercises.test.ts +++ b/src/lib/polar/__tests__/client-exercises.test.ts @@ -1,4 +1,10 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { annotateMock } = vi.hoisted(() => ({ annotateMock: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ + annotate: annotateMock, + getEvent: () => null, +})); import { combinePolarStart, @@ -25,11 +31,21 @@ function installFetchMock(pages: Array<{ status: number; body?: unknown }>) { return fetchMock; } +beforeEach(() => { + annotateMock.mockClear(); +}); + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); +function driftAnnotations() { + return annotateMock.mock.calls + .map((c) => c[0]) + .filter((f) => f?.action?.name === "polar.envelope.drift"); +} + describe("fetchExercises — request path", () => { // #568 regression guard: the exercises collection GET carries NO userId in // the path (the user is identified by the Bearer token), exactly the shape @@ -118,6 +134,44 @@ describe("fetchExercises — body shape (assumption #3)", () => { }); }); +describe("fetchExercises — envelope drift logging (E-77)", () => { + it("logs a drift breadcrumb and degrades to [] on an object body with no recognised list key", async () => { + installFetchMock([{ status: 200, body: { nope: [{ id: "a" }] } }]); + expect(await fetchExercises("tok")).toEqual([]); + const drift = driftAnnotations(); + expect(drift).toHaveLength(1); + expect(drift[0].action.details).toMatchObject({ + verb: "fetchExercises", + httpStatus: 200, + keys: ["nope"], + }); + }); + + it("does NOT drift on a bare array", async () => { + installFetchMock([{ status: 200, body: [{ id: "a" }] }]); + expect(await fetchExercises("tok")).toHaveLength(1); + expect(driftAnnotations()).toHaveLength(0); + }); + + it("does NOT drift on a recognised envelope key (`data`)", async () => { + installFetchMock([{ status: 200, body: { data: [{ id: "a" }] } }]); + expect(await fetchExercises("tok")).toHaveLength(1); + expect(driftAnnotations()).toHaveLength(0); + }); + + it("does NOT drift on an empty array under a recognised key", async () => { + installFetchMock([{ status: 200, body: { exercises: [] } }]); + expect(await fetchExercises("tok")).toEqual([]); + expect(driftAnnotations()).toHaveLength(0); + }); + + it("does NOT drift on a 204 empty window", async () => { + installFetchMock([{ status: 204 }]); + expect(await fetchExercises("tok")).toEqual([]); + expect(driftAnnotations()).toHaveLength(0); + }); +}); + describe("parseIsoDuration", () => { it("parses hours/minutes/seconds", () => { expect(parseIsoDuration("PT2H44M30S")).toBe(2 * 3600 + 44 * 60 + 30); diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index 834ffb0ca..a92276f8f 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const { annotateMock } = vi.hoisted(() => ({ annotateMock: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ + annotate: annotateMock, + getEvent: () => null, +})); + import { POLAR_OAUTH_SCOPE, exchangeCode, @@ -43,12 +49,19 @@ function installFetchMock(pages: Array<{ status: number; body?: unknown }>) { } beforeEach(() => { + annotateMock.mockClear(); process.env.POLAR_CLIENT_ID = "env-cid"; process.env.POLAR_CLIENT_SECRET = "env-secret"; process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; delete process.env.POLAR_REDIRECT_URI; }); +function driftAnnotations() { + return annotateMock.mock.calls + .map((c) => c[0]) + .filter((f) => f?.action?.name === "polar.envelope.drift"); +} + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); @@ -209,6 +222,55 @@ describe("fetchNightlyRecharges", () => { }); }); +describe("fetchCollection — envelope drift logging", () => { + it("logs a drift breadcrumb (verb + top-level keys) and degrades to [] on an unexpected wrapped shape", async () => { + installFetchMock([ + { status: 200, body: { unexpected: [{ date: "2026-06-10" }] } }, + ]); + expect(await fetchNightlyRecharges("tok")).toEqual([]); + const drift = driftAnnotations(); + expect(drift).toHaveLength(1); + expect(drift[0].action.details).toMatchObject({ + verb: "fetchNightlyRecharges", + httpStatus: 200, + keys: ["unexpected"], + }); + }); + + it("does NOT drift on the correctly wrapped envelope", async () => { + installFetchMock([ + { status: 200, body: { recharges: [{ date: "2026-06-10" }] } }, + ]); + expect(await fetchNightlyRecharges("tok")).toHaveLength(1); + expect(driftAnnotations()).toHaveLength(0); + }); + + it("does NOT drift on a bare array for a bare-array endpoint", async () => { + installFetchMock([ + { status: 200, body: [{ date: "2026-06-10", cardio_load: 12 }] }, + ]); + expect(await fetchCardioLoads("tok")).toHaveLength(1); + expect(driftAnnotations()).toHaveLength(0); + }); + + it("drifts when a bare-array endpoint returns a non-array object", async () => { + installFetchMock([{ status: 200, body: { rows: [{ cardio_load: 1 }] } }]); + expect(await fetchCardioLoads("tok")).toEqual([]); + const drift = driftAnnotations(); + expect(drift).toHaveLength(1); + expect(drift[0].action.details).toMatchObject({ + verb: "fetchCardioLoads", + keys: ["rows"], + }); + }); + + it("does NOT drift on a 204 empty window", async () => { + installFetchMock([{ status: 204 }]); + expect(await fetchSleeps("tok")).toEqual([]); + expect(driftAnnotations()).toHaveLength(0); + }); +}); + describe("mapNightlyRecharge", () => { it("maps the recovery band to a 0-100 RECOVERY_SCORE", () => { const rec: PolarNightlyRecharge = { diff --git a/src/lib/polar/__tests__/sync.test.ts b/src/lib/polar/__tests__/sync.test.ts index 71aace68c..56e33a343 100644 --- a/src/lib/polar/__tests__/sync.test.ts +++ b/src/lib/polar/__tests__/sync.test.ts @@ -331,8 +331,9 @@ describe("syncUserPolar", () => { ); }); - it("records a reauth_required failure on a 401 and rethrows", async () => { + it("records a reauth_required partial failure on a 401 and does NOT throw (per-collection isolation)", async () => { getConnMock.mockResolvedValue(CONN); + // The recharge collection 401s; the four siblings return data. fetchRechargesMock.mockRejectedValue( new PolarApiError({ verb: "fetchNightlyRecharges", @@ -341,11 +342,17 @@ describe("syncUserPolar", () => { reason: "http_401", }), ); - const err = await syncUserPolar("u1").catch((e) => e); - expect(err).toBeInstanceOf(PolarApiError); - // Recorded at the source AND marked (the legs wrapper rethrows the marked - // error), so the poll-cohort boundary does not double-record it. - expect(isSyncFailureRecorded(err)).toBe(true); + fetchCardioLoadsMock.mockResolvedValue([ + { date: "2026-06-10", cardio_load: 42 }, + ]); + + const imported = await syncUserPolar("u1"); + + // No rethrow now — a fetch failure is isolated per-collection. + expect(imported).toBe(1); + expect(createdRows().map((row) => row.type)).toContain("CARDIO_LOAD"); + // Exactly one partial failure recorded, classified off the first error, + // naming the failed collection. expect(recordFailureMock).toHaveBeenCalledTimes(1); expect(recordFailureMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -353,11 +360,206 @@ describe("syncUserPolar", () => { integration: "polar", kind: "reauth_required", errorCode: "401", + message: expect.stringContaining("recharge"), }), ); expect(recordSuccessMock).not.toHaveBeenCalled(); }); + it("isolates one dead collection — imports the healthy four and records one named failure", async () => { + getConnMock.mockResolvedValue(CONN); + // SpO2 403s (no sensor / consent), the other four return one row each. + fetchSpo2Mock.mockRejectedValue( + new PolarApiError({ + verb: "fetchSpo2", + classification: "reauth_required", + httpStatus: 403, + reason: "http_403", + }), + ); + fetchRechargesMock.mockResolvedValue([ + { date: "2026-06-10", nightly_recharge_status: 6 }, + ]); + fetchSleepsMock.mockResolvedValue([ + { + date: "2026-06-10", + sleep_start_time: "2026-06-09T23:00:00+02:00", + sleep_end_time: "2026-06-10T07:00:00+02:00", + light_sleep: 3600, + }, + ]); + fetchActivitiesMock.mockResolvedValue([ + { start_time: "2026-06-10T00:00:00", steps: 8000 }, + ]); + fetchCardioLoadsMock.mockResolvedValue([ + { date: "2026-06-10", cardio_load: 55 }, + ]); + + const imported = await syncUserPolar("u1"); + + // The healthy four still wrote their rows — the source is not blanked. + const written = createdRows().map((row) => row.type); + expect(written).toContain("RECOVERY_SCORE"); + expect(written).toContain("SLEEP_DURATION"); + expect(written).toContain("ACTIVITY_STEPS"); + expect(written).toContain("CARDIO_LOAD"); + expect(written).not.toContain("OXYGEN_SATURATION"); + expect(imported).toBeGreaterThan(0); + // Exactly one failure, classified off the 403, naming spo2. No success. + expect(recordFailureMock).toHaveBeenCalledTimes(1); + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ + integration: "polar", + kind: "reauth_required", + errorCode: "403", + message: expect.stringContaining("spo2"), + }), + ); + expect(recordSuccessMock).not.toHaveBeenCalled(); + }); + + it("isolates a throwing mapper — one malformed record does not blank siblings", async () => { + getConnMock.mockResolvedValue(CONN); + // A malformed recharge record whose mapper throws on first field access + // must not reject the whole batch (the old Promise.all failure mode). + fetchRechargesMock.mockResolvedValue([ + new Proxy( + {}, + { + get() { + throw new Error("malformed recharge point"); + }, + }, + ), + ]); + fetchCardioLoadsMock.mockResolvedValue([ + { date: "2026-06-10", cardio_load: 77 }, + ]); + + const imported = await syncUserPolar("u1"); + + expect(imported).toBe(1); + expect(createdRows().map((row) => row.type)).toContain("CARDIO_LOAD"); + expect(recordFailureMock).toHaveBeenCalledTimes(1); + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("recharge") }), + ); + expect(recordSuccessMock).not.toHaveBeenCalled(); + }); + + it("sleep-collection failure skips the sweep, siblings still import", async () => { + getConnMock.mockResolvedValue(CONN); + fetchSleepsMock.mockRejectedValue( + new PolarApiError({ + verb: "fetchSleeps", + classification: "transient", + httpStatus: 500, + reason: "http_500", + }), + ); + fetchActivitiesMock.mockResolvedValue([ + { start_time: "2026-06-10T00:00:00", steps: 8000 }, + ]); + + const imported = await syncUserPolar("u1"); + + // The sweep never ran — a failed sleep collection pushes no sweep entries. + expect(updateManyMock).not.toHaveBeenCalled(); + // The activity sibling still imported. + expect(createdRows().map((row) => row.type)).toContain("ACTIVITY_STEPS"); + expect(imported).toBeGreaterThan(0); + expect(recordFailureMock).toHaveBeenCalledTimes(1); + expect(recordSuccessMock).not.toHaveBeenCalled(); + }); + + it("all five healthy → success stamped once, no failure recorded", async () => { + getConnMock.mockResolvedValue(CONN); + fetchRechargesMock.mockResolvedValue([ + { date: "2026-06-10", nightly_recharge_status: 6 }, + ]); + + await syncUserPolar("u1"); + + expect(recordFailureMock).not.toHaveBeenCalled(); + expect(recordSuccessMock).toHaveBeenCalledTimes(1); + expect(recordSuccessMock).toHaveBeenCalledWith("u1", "polar"); + }); + + it.each([ + { name: "recharge", type: "RECOVERY_SCORE" }, + { name: "sleep", type: "SLEEP_DURATION" }, + { name: "activity", type: "ACTIVITY_STEPS" }, + { name: "cardioload", type: "CARDIO_LOAD" }, + { name: "spo2", type: "OXYGEN_SATURATION" }, + ])( + "Oura-parity: a 4xx on the $name collection names it in the single failure and imports every sibling", + async ({ name }) => { + getConnMock.mockResolvedValue(CONN); + // Every collection returns one healthy row... + fetchRechargesMock.mockResolvedValue([ + { date: "2026-06-10", nightly_recharge_status: 6 }, + ]); + fetchSleepsMock.mockResolvedValue([ + { + date: "2026-06-10", + sleep_start_time: "2026-06-09T23:00:00+02:00", + sleep_end_time: "2026-06-10T07:00:00+02:00", + light_sleep: 3600, + }, + ]); + fetchActivitiesMock.mockResolvedValue([ + { start_time: "2026-06-10T00:00:00", steps: 8000 }, + ]); + fetchCardioLoadsMock.mockResolvedValue([ + { date: "2026-06-10", cardio_load: 55 }, + ]); + fetchSpo2Mock.mockResolvedValue([ + { test_time: 1_781_059_600, blood_oxygen_percent: 96 }, + ]); + // ...except the one under test, which 4xxs. + const mockByName: Record> = { + recharge: fetchRechargesMock, + sleep: fetchSleepsMock, + activity: fetchActivitiesMock, + cardioload: fetchCardioLoadsMock, + spo2: fetchSpo2Mock, + }; + mockByName[name]!.mockRejectedValue( + new PolarApiError({ + verb: name, + classification: "reauth_required", + httpStatus: 403, + reason: "http_403", + }), + ); + + await syncUserPolar("u1"); + + // Exactly one failure, naming this collection; no success stamp. + expect(recordFailureMock).toHaveBeenCalledTimes(1); + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ + integration: "polar", + message: expect.stringContaining(name), + }), + ); + expect(recordSuccessMock).not.toHaveBeenCalled(); + // Every sibling collection still imported at least one row. + const written = createdRows().map((row) => row.type); + const siblingTypes: Record = { + recharge: "RECOVERY_SCORE", + sleep: "SLEEP_DURATION", + activity: "ACTIVITY_STEPS", + cardioload: "CARDIO_LOAD", + spo2: "OXYGEN_SATURATION", + }; + for (const [collName, collType] of Object.entries(siblingTypes)) { + if (collName === name) continue; + expect(written).toContain(collType); + } + }, + ); + it("leaves a write-path failure UNMARKED so the cohort boundary records it once", async () => { // The `upsertPolarMeasurements` transaction throw escapes `syncUserPolar` // uncaught: no source-site record, no mark — the poll-cohort boundary is diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index de3a4dc94..4a6092a06 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -1,6 +1,9 @@ /** * v1.17.0 (F4) — Polar AccessLink v3 client for OAuth + data fetching. * Docs: https://www.polar.com/accesslink-api/ (re-verify at build). + * Envelope shapes + windows below verified against the live AccessLink swagger + * (`accesslink-api/swagger.yaml`) on 2026-07-24; a body that no longer matches + * emits a `polar.envelope.drift` breadcrumb rather than silently reading empty. * * Mirrors the WHOOP client structure (`src/lib/whoop/client.ts`): hand-rolled * fetch over `safeFetch` (no SDK), an OAuth handshake, typed collection @@ -20,7 +23,7 @@ * reads work; a 409 from registration means "already registered" and is * treated as success. */ -import { getEvent } from "@/lib/logging/context"; +import { annotate, getEvent } from "@/lib/logging/context"; import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { safeFetch } from "@/lib/safe-fetch"; import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; @@ -250,6 +253,30 @@ interface CollectionResult { records: T[]; } +/** Top-level key NAMES of a drifted response envelope, capped at 10. NAMES + * only, never values — no health data can ride the drift annotation. */ +function topLevelKeys(body: unknown): string[] { + if (!body || typeof body !== "object" || Array.isArray(body)) return []; + return Object.keys(body as Record).slice(0, 10); +} + +/** Emit the fixed-shape envelope-drift breadcrumb, then let the caller degrade + * to `[]`. Pinned `{verb, httpStatus, keys}` shape so the wide-event dashboard + * stays stable; a Polar-side envelope rename shows up here instead of silently + * reading as "no data". */ +function annotateEnvelopeDrift( + verb: string, + httpStatus: number, + body: unknown, +): void { + annotate({ + action: { + name: "polar.envelope.drift", + details: { verb, httpStatus, keys: topLevelKeys(body) }, + }, + }); +} + async function fetchCollection( path: string, accessToken: string, @@ -284,13 +311,30 @@ async function fetchCollection( if (res.status === 204) return []; const json = (await res.json().catch(() => null)) as CollectionResult | Record | T[] | null; - if (!json) return []; + // A 200 with a null/unparseable body where the contract documents 204 for an + // empty window: log the drift, then degrade to [] (behavior unchanged). + if (json === null) { + annotateEnvelopeDrift(verb, res.status, null); + return []; + } if (Array.isArray(json)) return json; - if (!recordsKey) return []; + if (!recordsKey) { + // A bare array is the verified shape for this endpoint (activities / + // cardio-load / spo2) but the body is a non-array object — an envelope + // rename would read as "no data". Log, then degrade. + annotateEnvelopeDrift(verb, res.status, json); + return []; + } const list = (json as Record)[recordsKey]; - return Array.isArray(list) ? (list as T[]) : []; + if (Array.isArray(list)) return list as T[]; + // The expected records key (`recharges` / `nights`) is missing or not an + // array — the same silent-"no data" drift class. Log, then degrade. + annotateEnvelopeDrift(verb, res.status, json); + return []; } +/** Nightly Recharge collection. Verified envelope: wrapped `{recharges: [...]}`. + * Window: the last 28 days, server-fixed (no request params, no pagination). */ export function fetchNightlyRecharges( accessToken: string, ): Promise { @@ -302,6 +346,8 @@ export function fetchNightlyRecharges( ); } +/** Sleep collection. Verified envelope: wrapped `{nights: [...]}`. Window: the + * last 28 days, server-fixed (no request params, no pagination). */ export function fetchSleeps(accessToken: string): Promise { return fetchCollection( "/v3/users/sleep", @@ -311,6 +357,11 @@ export function fetchSleeps(accessToken: string): Promise { ); } +/** Daily-activity collection. Verified envelope: a BARE array (no wrapper key). + * Window: defaults to the last 28 days; optional `from`/`to` (max 28-day range, + * `from` ≤ 365 days back) are deliberately not sent — the default equals the + * in-window maximum, so pinning them buys no coverage and adds a request-surface + * failure mode. No pagination. */ export function fetchActivities(accessToken: string): Promise { return fetchCollection( "/v3/users/activities", @@ -319,8 +370,10 @@ export function fetchActivities(accessToken: string): Promise { ); } -/** Training Load Pro collection — `cardio_load` strain figures for the recent - * window. */ +/** Training Load Pro collection — `cardio_load` strain figures. Verified + * envelope: a BARE array (no wrapper key). Window: the last 28 days on the bare + * list (ranged `/date` and `/period/days/{1..180}` variants exist for deeper + * history but are backfill machinery, not sent here). No pagination. */ export function fetchCardioLoads( accessToken: string, ): Promise { @@ -331,7 +384,9 @@ export function fetchCardioLoads( ); } -/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. */ +/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. Verified + * envelope: a BARE array (no wrapper key). Window: defaults to the last 28 days + * (optional `from`/`to`, max 28-day range, not sent). No pagination. */ export function fetchSpo2(accessToken: string): Promise { return fetchCollection( "/v3/users/biosensing/spo2", @@ -697,13 +752,13 @@ export interface PolarExercise { } /** - * Candidate envelope keys a paged `/v3/exercises` response might nest the list - * under. Polar's pagination shape on this endpoint is not verified; the fetcher - * tolerates a bare array OR any of these envelope shapes so a paged response - * degrades to "the rows on this page" rather than an empty result. The 30-day - * window is small enough that a single page covers a normal user; if Polar - * paginates, the hourly poll re-reads the whole window every tick so nothing is - * lost between pages. + * Candidate envelope keys the `/v3/exercises` list might nest under. The + * verified shape (AccessLink swagger, 2026-07-24) is a BARE array covering the + * last 30 days OF UPLOADS, with NO pagination token or page parameter — so the + * bare-array branch is the live path. This tolerant key list is kept purely as + * defense against a future envelope rename; when it engages (an object body + * matching none of these keys), the fetch site logs a `polar.envelope.drift` + * breadcrumb rather than silently reading empty. */ const EXERCISE_LIST_KEYS = ["exercises", "data", "items", "records"] as const; @@ -746,6 +801,21 @@ export async function fetchExercises( // 204 No Content — a window with no uploads. Polar returns an empty body. if (res.status === 204) return []; const json = (await res.json().catch(() => null)) as unknown; + // Drift: a non-null object body carrying no recognised list array (none of + // EXERCISE_LIST_KEYS holds an array) would silently read as "no uploads". Log + // at the fetch site — `normaliseExerciseList` stays a pure function — then + // degrade to its []. A correctly-shaped `{exercises: []}` finds an (empty) + // array under a known key and does NOT drift. + if ( + json !== null && + typeof json === "object" && + !Array.isArray(json) && + !EXERCISE_LIST_KEYS.some((k) => + Array.isArray((json as Record)[k]), + ) + ) { + annotateEnvelopeDrift("fetchExercises", res.status, json); + } return normaliseExerciseList(json); } diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index 7878c4475..048445a4f 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -12,6 +12,22 @@ * as a 401 → `reauth_required` on the shared integration ledger (`polar` key), * exactly like Nightscout's token-rejected path. * + * Isolation: the five vitals collections are settled independently + * (`Promise.allSettled`), so a single dead collection — e.g. a 403 on SpO2 or + * cardio-load on an account without the sensor or the higher tier — imports the + * healthy four and records ONE partial failure rather than blanking every + * collection. Mirrors Oura's shipped per-collection isolation, minus the + * token-refresh leg Polar structurally lacks (a 401 is just a per-collection + * failure the classifier maps to `reauth_required`). + * + * Window contract: every hourly tick re-reads the full server-fixed window for + * each collection — 28 days for recharge / sleep / activity / cardio-load / + * SpO2 (there is no narrower request and no pagination on any of them). A + * late-synced device, a re-scored night, or a HealthLog outage shorter than the + * window all self-heal on the next tick with no cursor or watermark. History + * older than the window is an upstream cap, not a HealthLog gap. See the + * client's per-fetcher docstrings for the verified per-endpoint windows. + * * Idempotency: each row's `externalId` is `:`. The shared * reconciler protects both external and natural identity. Polar re-scores a * night for a short window after the fact, so an exact re-post overwrites in @@ -25,7 +41,6 @@ import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; import { getEvent } from "@/lib/logging/context"; import { - markSyncFailureRecorded, recordSyncFailure, recordSyncSuccess, toFailureKind, @@ -124,61 +139,115 @@ function toUpsert( })); } +/** One Polar vitals collection: its ledger name (which doubles as the + * externalId resource prefix) and a self-contained fetch+map closure that + * resolves to the collection's mapped upserts. Each closure owns its own client + * call and mapper so a throw — a failing fetch OR a throwing mapper — is + * contained to this one collection under `Promise.allSettled`. */ +interface PolarCollectionSpec { + name: string; + collect: (token: string) => Promise; +} + +/** A single collection that failed to fetch/map, carried so the caller can + * classify + record ONE partial failure without blanking the collections that + * did succeed. */ +export interface PolarCollectionFailure { + name: string; + err: unknown; +} + /** * Sync one user's Polar data. Returns the count of measurement rows written. * A user with no Polar connection is a clean no-op (returns 0, touches no * status row). + * + * The five vitals collections are settled independently: the healthy ones + * import, a failing one is recorded as a single partial failure (no success + * stamp), and nothing is rethrown for a fetch/map failure. The only throw that + * escapes now is a write-path/unexpected error out of `upsertPolarMeasurements` + * — the poll-cohort boundary records that unmarked escape once. */ export async function syncUserPolar(userId: string): Promise { const conn = await getPolarConnection(userId); if (!conn) return 0; - const readings: PolarMeasurementUpsert[] = []; + // Filled by the sleep collection's closure as it maps records; carried so the + // caller can run the record-scoped sweep before the upsert. A collection that + // throws pushes nothing (the fetch is its first await), so a failed sleep + // collection leaves the sweep list empty and no rows are tombstoned. const sleepSweeps: SleepSegmentSweep[] = []; - try { - const [recharges, sleeps, activities, cardioLoads, spo2Tests] = - await Promise.all([ - fetchNightlyRecharges(conn.accessToken), - fetchSleeps(conn.accessToken), - fetchActivities(conn.accessToken), - fetchCardioLoads(conn.accessToken), - fetchSpo2(conn.accessToken), - ]); - for (const r of recharges) { - readings.push(...toUpsert(mapNightlyRecharge(r), "recharge")); + + const collections: PolarCollectionSpec[] = [ + { + name: "recharge", + collect: async (t) => + (await fetchNightlyRecharges(t)).flatMap((r) => + toUpsert(mapNightlyRecharge(r), "recharge"), + ), + }, + { + name: "sleep", + collect: async (t) => { + const records = await fetchSleeps(t); + const rowsOut: PolarMeasurementUpsert[] = []; + for (const s of records) { + const rows = toUpsert(mapSleep(s), "sleep"); + rowsOut.push(...rows); + // Night-scoped sweep entry: the reconstructed segments of this date + // all key under `sleep::seg:` (mapper-supplied). Any live row + // under that prefix this fetch did NOT re-produce is a re-score + // orphan or a legacy `:seg::` indexed row — tombstoned + // before the upsert. The prefix deliberately stays on `:seg:` — the + // IN_BED envelope keys on its measuredAt's UTC date, which can drift + // a calendar day from `s.date`, so a broader `sleep::` bound + // could cross nights. + sleepSweeps.push({ + prefix: `sleep:${s.date}:seg:`, + keepIds: rows + .filter((r) => r.type === "SLEEP_DURATION") + .map((r) => r.externalId), + }); + } + return rowsOut; + }, + }, + { + name: "activity", + collect: async (t) => + (await fetchActivities(t)).flatMap((a) => + toUpsert(mapActivity(a), "activity"), + ), + }, + { + name: "cardioload", + collect: async (t) => + (await fetchCardioLoads(t)).flatMap((c) => + toUpsert(mapCardioLoad(c), "cardioload"), + ), + }, + { + name: "spo2", + collect: async (t) => + (await fetchSpo2(t)).flatMap((s) => toUpsert(mapSpo2(s), "spo2")), + }, + ]; + + // Isolate each collection: a rejection is captured per-collection, never + // propagated, so one dead collection no longer blanks the healthy siblings. + const settled = await Promise.allSettled( + collections.map((spec) => spec.collect(conn.accessToken)), + ); + const readings: PolarMeasurementUpsert[] = []; + const failures: PolarCollectionFailure[] = []; + settled.forEach((res, i) => { + const spec = collections[i]!; + if (res.status === "fulfilled") { + readings.push(...res.value); + } else { + failures.push({ name: spec.name, err: res.reason }); } - for (const s of sleeps) { - const rows = toUpsert(mapSleep(s), "sleep"); - readings.push(...rows); - // Night-scoped sweep entry: the reconstructed segments of this date all - // key under `sleep::seg:` (mapper-supplied). Any live row under - // that prefix this fetch did NOT re-produce is a re-score orphan or a - // legacy `:seg::` indexed row — tombstoned before the upsert. - // The prefix deliberately stays on `:seg:` — the IN_BED envelope keys - // on its measuredAt's UTC date, which can drift a calendar day from - // `s.date`, so a broader `sleep::` bound could cross nights. - sleepSweeps.push({ - prefix: `sleep:${s.date}:seg:`, - keepIds: rows - .filter((r) => r.type === "SLEEP_DURATION") - .map((r) => r.externalId), - }); - } - for (const a of activities) { - readings.push(...toUpsert(mapActivity(a), "activity")); - } - for (const c of cardioLoads) { - readings.push(...toUpsert(mapCardioLoad(c), "cardioload")); - } - for (const t of spo2Tests) { - readings.push(...toUpsert(mapSpo2(t), "spo2")); - } - } catch (err) { - // Recorded here, then marked so the poll-cohort boundary does not - // double-record it when the legs wrapper rethrows it there. - await recordPolarSyncFailure(userId, err); - throw markSyncFailureRecorded(err); - } + }); // Clear whatever an earlier scoring left under the re-fetched nights before // the fresh set upserts (mirrors Google Health's replace-by-window order). @@ -195,11 +264,42 @@ export async function syncUserPolar(userId: string): Promise { }); // S4 — trigger the debounced morning refresh on a last-night segment landing - // (mirrors the Withings / WHOOP / Apple seams). + // (mirrors the Withings / WHOOP / Apple seams). Fires whether or not a + // sibling collection failed, since the sleep readings were still imported. void maybeEnqueueMorningRefresh(userId, insertedSleepMeasuredAts).catch( () => {}, ); + if (failures.length > 0) { + // Partial failure: import what the healthy collections returned, but keep + // the cycle honest — record ONE failure and do NOT stamp success, so the + // freshness surface reflects that some collections are behind and the next + // tick refetches them rather than showing green. Mirrors Oura's + // partial-failure ledger block; the record is inline (not + // `recordPolarSyncFailure`) because it needs the `partial sync failure + // ()` message prefix — the R6 recorder's boundary contract stays + // frozen. No rethrow: a rethrow would suppress the cohort's users_synced / + // reminder-satisfy accounting for a user whose data DID land. + const firstErr = failures[0]!.err; + const names = failures.map((f) => f.name).join(", "); + getEvent()?.addWarning( + `polar: ${failures.length} collection(s) failed for ${userId}: ${names}`, + ); + await recordSyncFailure({ + userId, + integration: "polar", + kind: classifyPolarFailure(firstErr), + message: `partial sync failure (${names}): ${ + firstErr instanceof Error ? firstErr.message : String(firstErr) + }`, + errorCode: + firstErr instanceof PolarApiError && firstErr.httpStatus != null + ? String(firstErr.httpStatus) + : undefined, + }); + return imported; + } + await recordSyncSuccess(userId, "polar"); return imported; }