From 4574155ce6b92a68299d1165f8458e9e12285ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 21:19:46 +0200 Subject: [PATCH 1/3] fix(polar): isolate each vitals collection so one dead read no longer blanks the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the Polar vitals sync from a single Promise.all over the five collections into a Promise.allSettled over a per-collection spec, each closure fetching and mapping so a throwing mapper is contained exactly like a failing fetch. The healthy collections import; a failing one is recorded once as a partial failure that names it, without stamping success and without rethrowing, so a single unavailable data type — SpO2 on a watch without the sensor, a higher-tier cardio-load read an account lacks — stops blanking recharge, sleep, and activity on every tick. A 401 is now a per-collection failure classified reauth_required, not a whole-leg throw; the write-path escape still surfaces unmarked for the poll boundary to record once. Mirrors the shipped Oura isolation, minus the token-refresh leg Polar has no path for. --- .../__tests__/polar-sync-legs.test.ts | 12 + src/lib/jobs/reminder/polar-sync.ts | 14 +- src/lib/polar/__tests__/sync.test.ts | 214 +++++++++++++++++- src/lib/polar/sync.ts | 192 ++++++++++++---- 4 files changed, 377 insertions(+), 55 deletions(-) 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__/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/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; } From 1d31c51d01c7e4bd69586bf501984b0627b0c5f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 21:19:58 +0200 Subject: [PATCH 2/3] fix(polar): log envelope-shape drift instead of silently reading it as no data The collection fetchers degrade an unexpected 200 body to an empty list, so a Polar-side envelope rename would read as "the user has no data" and stamp a clean sync while importing nothing. Emit a fixed-shape polar.envelope.drift breadcrumb (verb, HTTP status, top-level key names only, never values) at each silent-empty site in the collection fetcher and the exercises fetcher, keeping the log-and-degrade behavior so a live account stays alive while the gap becomes visible in the event stream. Truth up the client docstrings against the verified AccessLink contract: each vitals list is a bare array or a named wrapper over a server-fixed 28-day window with no pagination, and exercises is a bare array over the last 30 days of uploads. Every hourly tick re-reads the full window, so a late-synced device or a short outage self-heals with no cursor or watermark; history beyond the window is an upstream cap. No request parameters added. --- .../polar/__tests__/client-exercises.test.ts | 56 ++++++++++- src/lib/polar/__tests__/client.test.ts | 62 ++++++++++++ src/lib/polar/client.ts | 98 ++++++++++++++++--- 3 files changed, 201 insertions(+), 15 deletions(-) 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/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); } From 4ac39a70529f4afa9e4f5adf7e8435dfd9cc1e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 21:26:35 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20v1.32.24=20=E2=80=94=20is?= =?UTF-8?q?olate=20Polar=20collections=20and=20log=20envelope=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-collection isolation for the Polar vitals sync: one unavailable data type no longer blanks the rest, a failing collection is named on the integration status, and an unexpected response shape is logged instead of reading as no data. Bump version, regenerate the OpenAPI version string, add the changelog entry. --- CHANGELOG.md | 19 +++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 22 insertions(+), 3 deletions(-) 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/*`