mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge pull request #626 from MBombeck/release/v1.32.24
release/v1.32.24 — isolate Polar collections and log envelope drift
This commit is contained in:
11 files changed
+600
-73
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<number> {
|
||||
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,
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<string, ReturnType<typeof vi.fn>> = {
|
||||
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<string, string> = {
|
||||
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
|
||||
|
||||
+84
-14
@@ -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<T> {
|
||||
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<string, unknown>).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<T>(
|
||||
path: string,
|
||||
accessToken: string,
|
||||
@@ -284,13 +311,30 @@ async function fetchCollection<T>(
|
||||
if (res.status === 204) return [];
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
CollectionResult<T> | Record<string, T[]> | 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<string, unknown>)[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<PolarNightlyRecharge[]> {
|
||||
@@ -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<PolarSleep[]> {
|
||||
return fetchCollection<PolarSleep>(
|
||||
"/v3/users/sleep",
|
||||
@@ -311,6 +357,11 @@ export function fetchSleeps(accessToken: string): Promise<PolarSleep[]> {
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<PolarActivity[]> {
|
||||
return fetchCollection<PolarActivity>(
|
||||
"/v3/users/activities",
|
||||
@@ -319,8 +370,10 @@ export function fetchActivities(accessToken: string): Promise<PolarActivity[]> {
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<PolarCardioLoad[]> {
|
||||
@@ -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<PolarSpo2[]> {
|
||||
return fetchCollection<PolarSpo2>(
|
||||
"/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<string, unknown>)[k]),
|
||||
)
|
||||
) {
|
||||
annotateEnvelopeDrift("fetchExercises", res.status, json);
|
||||
}
|
||||
return normaliseExerciseList(json);
|
||||
}
|
||||
|
||||
|
||||
+146
-46
@@ -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 `<date>:<fieldTag>`. 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<PolarMeasurementUpsert[]>;
|
||||
}
|
||||
|
||||
/** 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<number> {
|
||||
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:<date>:seg:` (mapper-supplied). Any live row
|
||||
// under that prefix this fetch did NOT re-produce is a re-score
|
||||
// orphan or a legacy `:seg:<tag>:<i>` 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:<date>:` 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:<date>:seg:` (mapper-supplied). Any live row under
|
||||
// that prefix this fetch did NOT re-produce is a re-score orphan or a
|
||||
// legacy `:seg:<tag>:<i>` 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:<date>:` 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<number> {
|
||||
});
|
||||
|
||||
// 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
|
||||
// (<names>)` 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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user