mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Every interactive path, every import, MCP and Telegram checked an incoming measurement against VALUE_RANGES before storing it. The provider sync writers did not, so a decode slip or a glitched sensor landed a row the application itself calls impossible, and every mean, median and personal band derived from it inherited the impossibility. Filtering the read side hid the row. It kept landing. The gate is one module reading the domain the app already declares, applied at two kinds of seam. reconcileExternalMeasurement is the shared identity writer for Polar, Oura, WHOOP, the Withings sleep leg, the Apple export importer and the batch endpoint, so gating there means a provider added later inherits the refusal rather than having to remember a check it never knew about; the new rejected_range verdict carries no row, which is what makes the compiler force every caller to handle it. The five writers that hold Prisma themselves gate at their entry, before the existence probe decides insert versus overwrite, so an impossible reading can no longer replace a good stored value. An out-of-band value is dropped. Not clamped, because a clamp invents a reading nobody took. Not stored behind a flag, because a quarantined row is still a row and every consumer would have to learn the flag. Not fatal to the batch, because one bad sample must not strand the run's other readings behind a held watermark. Absence reads as absence. A silent drop, though, is indistinguishable from a provider that sent nothing, so every drop is tallied on the wide event under measurement.range_rejected with the metric, the provider and the edge it broke. Counts only: the reading itself is health data and never enters the log line, and the direction plus the metric's own declared bound is what a diagnosis actually needs. The tally accumulates rather than replacing, because a provider gates once per resource leg and the reconciler once per row. One warning per provider lifts the event to warn so a drop does not hide inside a green line. A structural guard enumerates all twenty measurement writers with the reason each is allowed to write, so a new one has to answer the question before it ships. scripts/diagnose-implausible-measurements.ts reports the rows already stored, aggregated per metric and source with no value beyond the bucket's own extremes. It repairs nothing: whether a stored impossible row is a decode slip or a band that is too narrow is not a call a script gets to make.
407 lines
15 KiB
TypeScript
407 lines
15 KiB
TypeScript
/**
|
|
* v1.17.0 (F4) — Polar AccessLink sync.
|
|
*
|
|
* Pulls Nightly Recharge (→ RECOVERY_SCORE / ANS_CHARGE / HRV / RHR /
|
|
* respiratory rate), sleep (per-stage durations on a reconstructed END-instant
|
|
* timeline + sleep score), daily activity (steps + active energy + distance),
|
|
* Training Load Pro (→ CARDIO_LOAD), and SpO2 (→ OXYGEN_SATURATION) for one
|
|
* connected user, mapping each into `Measurement` rows tagged `source = POLAR`.
|
|
*
|
|
* Token model: Polar tokens do not expire and have no refresh path, so there is
|
|
* no `getValidToken` refresh dance (the WHOOP shape). A revoked grant surfaces
|
|
* 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
|
|
* place (WHOOP-style re-score), not first-write-wins.
|
|
*
|
|
* The measurement-write tail (per-row upsert → rollup fold → status-insight
|
|
* invalidate) mirrors the shared WHOOP / Nightscout sync tail; it is NOT a new
|
|
* write path.
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import type { MeasurementType } from "@/generated/prisma/client";
|
|
import { getEvent } from "@/lib/logging/context";
|
|
import {
|
|
recordSyncFailure,
|
|
recordSyncSuccess,
|
|
toFailureKind,
|
|
type FailureKind,
|
|
} from "@/lib/integrations/status";
|
|
import {
|
|
collapseToTypeDayKeys,
|
|
recomputeBucketsForMeasurement,
|
|
} from "@/lib/rollups/measurement-rollups";
|
|
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
|
import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
|
|
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
|
import {
|
|
emitInsertedMeasurementArrivals,
|
|
type InsertedMeasurementArrivalRow,
|
|
} from "@/lib/arrivals/measurement-emit";
|
|
import {
|
|
MeasurementReconciliationError,
|
|
reconcileExternalMeasurement,
|
|
} from "@/lib/measurements/reconcile-external-measurement";
|
|
import {
|
|
fetchActivities,
|
|
fetchCardioLoads,
|
|
fetchNightlyRecharges,
|
|
fetchSleeps,
|
|
fetchSpo2,
|
|
mapActivity,
|
|
mapCardioLoad,
|
|
mapNightlyRecharge,
|
|
mapSleep,
|
|
mapSpo2,
|
|
type MappedMeasurement,
|
|
} from "./client";
|
|
import {
|
|
sweepStaleSleepSegments,
|
|
type SleepSegmentSweep,
|
|
} from "@/lib/sleep/sweep-stale-segments";
|
|
import { getPolarConnection } from "./credentials";
|
|
import { PolarApiError, classifyPolarError } from "./response-classifier";
|
|
|
|
/** Map a Polar error onto the shared integration-ledger failure kind. */
|
|
export function classifyPolarFailure(err: unknown): FailureKind {
|
|
return toFailureKind(classifyPolarError(err));
|
|
}
|
|
|
|
/**
|
|
* Record a Polar vitals-leg sync failure on the shared `polar` ledger with the
|
|
* correct classification + HTTP code. Extracted so the poll-cohort boundary can
|
|
* record a future escape with the SAME provider-owned classification the inline
|
|
* catch already applies.
|
|
*/
|
|
export const POLAR_LEG_VITALS = "vitals";
|
|
|
|
export async function recordPolarSyncFailure(
|
|
userId: string,
|
|
err: unknown,
|
|
): Promise<void> {
|
|
await recordSyncFailure({
|
|
userId,
|
|
integration: "polar",
|
|
leg: POLAR_LEG_VITALS,
|
|
kind: classifyPolarFailure(err),
|
|
message: err instanceof Error ? err.message : String(err),
|
|
errorCode:
|
|
err instanceof PolarApiError && err.httpStatus != null
|
|
? String(err.httpStatus)
|
|
: undefined,
|
|
});
|
|
}
|
|
|
|
/** One mapped reading with its `externalId` resolved. */
|
|
export interface PolarMeasurementUpsert {
|
|
type: string;
|
|
value: number;
|
|
unit: string;
|
|
measuredAt: Date;
|
|
externalId: string;
|
|
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED" | null;
|
|
}
|
|
|
|
function toUpsert(
|
|
mapped: MappedMeasurement[],
|
|
resourcePrefix: string,
|
|
): PolarMeasurementUpsert[] {
|
|
return mapped.map((m) => ({
|
|
type: m.type,
|
|
value: m.value,
|
|
unit: m.unit,
|
|
measuredAt: m.measuredAt,
|
|
// Reconstructed sleep segments supply their own indexed externalId so the
|
|
// several rows of one night stay distinct; everything else keys on
|
|
// `<resource>:<date>:<fieldTag>` — stable across re-syncs of the same day.
|
|
// The date is read off `measuredAt` for untimed rows (midnight-UTC
|
|
// anchored), so a mapper-supplied externalId is honoured verbatim to avoid
|
|
// the timed sleep instants drifting the date slice.
|
|
externalId:
|
|
m.externalId ??
|
|
`${resourcePrefix}:${m.measuredAt.toISOString().slice(0, 10)}:${m.fieldTag}`,
|
|
sleepStage: m.sleepStage ?? null,
|
|
}));
|
|
}
|
|
|
|
/** 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 rows written AND whether a
|
|
* collection did not settle — the bare count used to narrow the partial away,
|
|
* so a run whose every collection failed answered the same `0` as a quiet
|
|
* night. A user with no Polar connection is a clean no-op (nothing imported,
|
|
* nothing failed, no status row touched).
|
|
*
|
|
* 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<SyncWriteResult> {
|
|
const conn = await getPolarConnection(userId);
|
|
if (!conn) return { imported: 0, failed: false };
|
|
|
|
// 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[] = [];
|
|
|
|
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 });
|
|
}
|
|
});
|
|
|
|
// Clear whatever an earlier scoring left under the re-fetched nights before
|
|
// the fresh set upserts (mirrors Google Health's replace-by-window order).
|
|
// Best-effort inside the helper — a sweep failure never fails the sync.
|
|
await sweepStaleSleepSegments(userId, "POLAR", sleepSweeps);
|
|
|
|
let insertedSleepMeasuredAts: Date[] = [];
|
|
const imported = await upsertPolarMeasurements(userId, readings, {
|
|
onInserted: (rows) => {
|
|
insertedSleepMeasuredAts = rows
|
|
.filter((row) => row.type === "SLEEP_DURATION")
|
|
.map((row) => row.measuredAt);
|
|
},
|
|
});
|
|
|
|
// S4 — trigger the debounced morning refresh on a last-night segment landing
|
|
// (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",
|
|
leg: POLAR_LEG_VITALS,
|
|
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, failed: true };
|
|
}
|
|
|
|
await recordSyncSuccess(userId, "polar", { leg: POLAR_LEG_VITALS });
|
|
return { imported, failed: false };
|
|
}
|
|
|
|
/**
|
|
* Upsert a batch of mapped Polar readings, then fold the rollup tier +
|
|
* invalidate status-insight caches once at the end (mirrors the WHOOP /
|
|
* Nightscout sync tail). The shared reconciler protects both external and
|
|
* natural identity; an exact re-post overwrites in place. Best-effort on the
|
|
* rollup fold.
|
|
*/
|
|
export async function upsertPolarMeasurements(
|
|
userId: string,
|
|
readings: PolarMeasurementUpsert[],
|
|
opts: {
|
|
onInserted?: (rows: InsertedMeasurementArrivalRow[]) => void;
|
|
} = {},
|
|
): Promise<number> {
|
|
if (readings.length === 0) return 0;
|
|
|
|
let imported = 0;
|
|
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
|
|
const insertedRows: Array<
|
|
InsertedMeasurementArrivalRow & { externalId: string | null }
|
|
> = [];
|
|
|
|
const verdicts = await prisma.$transaction(
|
|
async (tx) => {
|
|
const outcomes = [];
|
|
for (const r of readings) {
|
|
const verdict = await reconcileExternalMeasurement(
|
|
tx,
|
|
{
|
|
userId,
|
|
type: r.type as MeasurementType,
|
|
source: "POLAR",
|
|
value: r.value,
|
|
unit: r.unit,
|
|
measuredAt: r.measuredAt,
|
|
externalId: r.externalId,
|
|
sleepStage: r.sleepStage ?? null,
|
|
},
|
|
{ exactExternalMatch: "update" },
|
|
);
|
|
if (verdict.status === "failed") {
|
|
throw new MeasurementReconciliationError(verdict);
|
|
}
|
|
outcomes.push(verdict);
|
|
}
|
|
return outcomes;
|
|
},
|
|
{ timeout: 60_000 },
|
|
);
|
|
|
|
for (let index = 0; index < readings.length; index++) {
|
|
const reading = readings[index]!;
|
|
const verdict = verdicts[index]!;
|
|
// Refused by the plausibility gate: nothing was written and nothing was
|
|
// touched, so the reading counts as neither imported nor a rollup dirt
|
|
// mark. The drop is already on the wide event.
|
|
if (verdict.status === "rejected_range") continue;
|
|
for (const dirty of verdict.dirtyIdentities ?? []) {
|
|
touched.push(dirty);
|
|
}
|
|
const type = reading.type as MeasurementType;
|
|
imported++;
|
|
touched.push({ type, measuredAt: reading.measuredAt });
|
|
if (verdict.status === "inserted") {
|
|
insertedRows.push(verdict.row);
|
|
}
|
|
}
|
|
|
|
opts.onInserted?.(insertedRows);
|
|
void emitInsertedMeasurementArrivals(userId, insertedRows, "polar").catch(
|
|
() => {},
|
|
);
|
|
try {
|
|
const keys = collapseToTypeDayKeys(touched);
|
|
for (const k of keys) {
|
|
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
|
|
}
|
|
invalidateStatusInsightsForTypes(
|
|
userId,
|
|
keys.map((k) => k.type),
|
|
).catch((err) => {
|
|
getEvent()?.addWarning(
|
|
`polar: status-insight invalidate failed for ${userId}: ${err}`,
|
|
);
|
|
});
|
|
} catch (err) {
|
|
getEvent()?.addWarning(
|
|
`polar: rollup recompute failed for ${userId}: ${err}`,
|
|
);
|
|
}
|
|
|
|
return imported;
|
|
}
|