mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Introduce a recorded-marker primitive on the integration ledger so a provider that records-then-rethrows a sync failure is not recorded a second time at the poll-cohort boundary, while an unrecorded escape (an Oura or Polar write-path throw, or any future path) lands on the ledger exactly once, correctly classified and redacted by the source that owns it. Without the marker a boundary-blind recorder would double-count the failure streak and could persist a Nightscout token-bearing URL into the stored error. Give the poll-cohort factory a required per-provider failure recorder and wire one for Oura, Polar, Nightscout, and Strava. Nightscout now counts only freshly inserted readings instead of insert plus re-confirm (the inflated count fired the reminder trigger every tick), records a transient failure and skips the success stamp when some rows fail to write, and emits the measurement-arrival spine for its inserts. Correct the docstring that promised a far-history backfill that does not exist: the hourly window imports about 48 hours and nothing deeper. Strava now enqueues its history backfill at connect time, mirroring the WHOOP and Fitbit callbacks, so deep history imports within the hour instead of waiting for the next worker restart.
177 lines
6.3 KiB
TypeScript
177 lines
6.3 KiB
TypeScript
/**
|
|
* v1.32.15 — Polar AccessLink exercise (workout) sync.
|
|
*
|
|
* Pulls the user's exercises from the stateless `GET /v3/exercises` list (last
|
|
* 30 days of uploads, no transaction / no commit / no userId in the path) and
|
|
* upserts each into the `Workout` table as `source = POLAR`, keyed on
|
|
* `(userId, source, externalId)` so a re-fetch / Polar-side re-analysis
|
|
* overwrites in place. Polar exercises are WORKOUT rows, not `Measurement`
|
|
* rows, so this file is deliberately SEPARATE from the vitals sync
|
|
* (`./sync.ts`) — isolation for a sensitive cohort, mirroring WHOOP's split
|
|
* `sync.ts` / `sync-workout.ts`.
|
|
*
|
|
* Cross-source dedup is NOT done here: a Polar run and the same run via Apple
|
|
* Health remain two distinct `Workout` rows (different `source`); the read-time
|
|
* `pickCanonicalWorkoutRows` picker collapses the cross-source twin via the
|
|
* user's source-priority ladder. One engine, no parallel path.
|
|
*
|
|
* Idempotency: every tick re-reads the whole 30-day window; the unique index
|
|
* makes a re-ingest a no-op-shaped overwrite. There is no commit / acknowledge
|
|
* step to fail (the deprecated transaction model had one), so ingest is the
|
|
* only write and it is idempotent — no cursor, no backfill queue (the API caps
|
|
* history at 30 days, so first-connect backfill IS the first regular tick).
|
|
*
|
|
* Ledger: workouts and vitals share the single `polar` integration ledger. This
|
|
* leg records only its OWN FAILURE (and rethrows so the cohort handler can
|
|
* attribute it); the single success write stays the vitals leg's job so a fully
|
|
* successful pass records success exactly once. A workouts-leg failure records a
|
|
* transient/reauth failure on the shared ledger but never touches the vitals
|
|
* success timestamp (`recordSyncFailure` leaves `lastSuccessAt` intact).
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit";
|
|
import { annotate, getEvent } from "@/lib/logging/context";
|
|
import {
|
|
markSyncFailureRecorded,
|
|
recordSyncFailure,
|
|
toFailureKind,
|
|
} from "@/lib/integrations/status";
|
|
import { fetchExercises, mapExercise, type PolarWorkoutRow } from "./client";
|
|
import { getPolarConnection } from "./credentials";
|
|
import { PolarApiError, classifyPolarError } from "./response-classifier";
|
|
|
|
async function recordPolarWorkoutFailure(
|
|
userId: string,
|
|
err: unknown,
|
|
): Promise<void> {
|
|
await recordSyncFailure({
|
|
userId,
|
|
integration: "polar",
|
|
// Same classification the vitals leg uses (`classifyPolarFailure` in
|
|
// `./sync`); resolved directly off the shared classifier so this leg does
|
|
// not have to import the heavier vitals-sync module.
|
|
kind: toFailureKind(classifyPolarError(err)),
|
|
message: err instanceof Error ? err.message : String(err),
|
|
errorCode:
|
|
err instanceof PolarApiError && err.httpStatus != null
|
|
? String(err.httpStatus)
|
|
: undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sync one user's Polar exercises into `Workout`. Returns the count of rows
|
|
* written. A user with no Polar connection is a clean no-op (returns 0, touches
|
|
* no status row).
|
|
*
|
|
* A 403 on the exercises read (a scope gap under `accesslink.read_all`) is
|
|
* classified through the shared response classifier as `reauth_required` — a
|
|
* SOFT/recoverable integration error surfaced on the status ledger, NOT a hard
|
|
* crash: it is recorded and rethrown so the composite cohort pass isolates this
|
|
* user and the vitals leg's own result is untouched.
|
|
*/
|
|
export async function syncUserPolarWorkouts(userId: string): Promise<number> {
|
|
const conn = await getPolarConnection(userId);
|
|
if (!conn) return 0;
|
|
|
|
let rows: PolarWorkoutRow[];
|
|
try {
|
|
const exercises = await fetchExercises(conn.accessToken);
|
|
rows = [];
|
|
for (const ex of exercises) {
|
|
const row = mapExercise(ex);
|
|
// Skip an unmappable exercise (no id / unparseable start); never throw
|
|
// per-row — one bad record must not fail the whole leg.
|
|
if (row) rows.push(row);
|
|
}
|
|
} catch (err) {
|
|
await recordPolarWorkoutFailure(userId, err);
|
|
throw markSyncFailureRecorded(err);
|
|
}
|
|
|
|
let imported: number;
|
|
try {
|
|
imported = await upsertPolarWorkouts(userId, rows);
|
|
} catch (err) {
|
|
await recordPolarWorkoutFailure(userId, err);
|
|
throw markSyncFailureRecorded(err);
|
|
}
|
|
|
|
return imported;
|
|
}
|
|
|
|
/**
|
|
* Upsert a batch of mapped Polar exercises into `Workout`, keyed on
|
|
* `(userId, source = POLAR, externalId)` so a re-fetch overwrites in place. A
|
|
* line-for-line mirror of `upsertStravaWorkouts`: `createManyAndReturn` +
|
|
* `skipDuplicates`, update-in-place on conflict, arrival emit on fresh inserts
|
|
* only. Failed rows are logged while the rest of the batch is attempted, then
|
|
* the FIRST error is rethrown so the caller cannot treat a partially persisted
|
|
* batch as a clean pass.
|
|
*/
|
|
export async function upsertPolarWorkouts(
|
|
userId: string,
|
|
rows: readonly PolarWorkoutRow[],
|
|
): Promise<number> {
|
|
if (rows.length === 0) return 0;
|
|
|
|
let imported = 0;
|
|
let firstError: unknown;
|
|
let hadError = false;
|
|
for (const r of rows) {
|
|
const data = {
|
|
sportType: r.sportType,
|
|
startedAt: r.startedAt,
|
|
endedAt: r.endedAt,
|
|
durationSec: r.durationSec,
|
|
totalEnergyKcal: r.totalEnergyKcal,
|
|
totalDistanceM: r.totalDistanceM,
|
|
avgHeartRate: r.avgHeartRate,
|
|
maxHeartRate: r.maxHeartRate,
|
|
elevationM: r.elevationM,
|
|
metadata: r.metadata,
|
|
};
|
|
try {
|
|
const [inserted] = await prisma.workout.createManyAndReturn({
|
|
data: {
|
|
userId,
|
|
source: "POLAR",
|
|
externalId: r.externalId,
|
|
...data,
|
|
},
|
|
skipDuplicates: true,
|
|
select: { id: true, startedAt: true },
|
|
});
|
|
if (inserted) {
|
|
void emitInsertedWorkoutArrival(userId, inserted, "polar").catch(
|
|
() => {},
|
|
);
|
|
} else {
|
|
await prisma.workout.update({
|
|
where: {
|
|
userId_source_externalId: {
|
|
userId,
|
|
source: "POLAR",
|
|
externalId: r.externalId,
|
|
},
|
|
},
|
|
data,
|
|
});
|
|
}
|
|
imported += 1;
|
|
} catch (err) {
|
|
getEvent()?.addWarning(`polar: failed to upsert workout: ${err}`);
|
|
if (!hadError) {
|
|
firstError = err;
|
|
hadError = true;
|
|
}
|
|
}
|
|
}
|
|
if (hadError) throw firstError;
|
|
|
|
annotate({
|
|
action: { name: "polar.workout.ingest", details: { imported } },
|
|
});
|
|
return imported;
|
|
}
|