mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Oura, Polar, WHOOP and Strava each keep the transport and endpoints in `client.ts` and gain `wire-types.ts` (the record shapes) and `map.ts` (the pure record-to-row mappers, no I/O). Google Health's 1,600-line client becomes `budget.ts` (slice budget and outcome tally), `request.ts` (error detail, retry, rate limits), `data-points.ts` (the paginated walker) and a small `client.ts` for the profile and roll-up reads; its `export * from "./mappers"` facade is gone and its importers name the module that defines what they use. The code moved verbatim; the mapper goldens written before the move and the provider integration tests pass unchanged.
471 lines
16 KiB
TypeScript
471 lines
16 KiB
TypeScript
/**
|
|
* v1.28.x — Strava API v3 sync.
|
|
*
|
|
* Pulls the athlete's activities and upserts each into the `Workout` table as
|
|
* `source = STRAVA`, keyed on `(userId, source, externalId)` so a re-fetch /
|
|
* `activity.update` overwrites in place. Strava is a WORKOUT source only — it
|
|
* exposes no daily metrics, so unlike the Oura/Polar sync this writes `Workout`
|
|
* rows and stops (no measurement rollup fold).
|
|
*
|
|
* Cross-source dedup is NOT done here: a Strava 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 at read
|
|
* time via the user's source-priority ladder. One engine, no parallel path.
|
|
*
|
|
* Token model: Strava ROTATES its refresh token on every refresh. The schema
|
|
* has no expiry column, so the sync refreshes REACTIVELY — the first read that
|
|
* 401s triggers one refresh (persisting BOTH rotated tokens via a compare-and-
|
|
* set) and a single retry. A refresh Strava refuses for a dead token (a 400
|
|
* whose fault names `RefreshToken`) records `reauth_required` on the `strava`
|
|
* ledger; any other refusal is classified by its status.
|
|
*
|
|
* Rate limits: 200 req / 15 min + 2000 / day per app. The walk is page-bounded
|
|
* (`walkPages`, which reports a walk cut off at its ceiling instead of
|
|
* passing it off as the whole history) and the per-activity detail fetch (for `calories`, absent from the summary)
|
|
* is budget-capped. A 429 mid-walk ends the walk where it stands: what was
|
|
* read lands, the cursor moves only as far as an oldest-first walk proves, the
|
|
* failure is booked transient, and the run reports how long Strava's headers
|
|
* say to wait (`retryAfterSeconds`), which the backfill waits out before its
|
|
* next run. It used to throw out of the walk and discard everything read.
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import type { Prisma } from "@/generated/prisma/client";
|
|
import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit";
|
|
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
|
import { annotate, getEvent } from "@/lib/logging/context";
|
|
import {
|
|
markSyncFailureRecorded,
|
|
recordSyncFailure,
|
|
recordSyncSuccess,
|
|
toFailureKind,
|
|
type FailureKind,
|
|
} from "@/lib/integrations/status";
|
|
import { walkPages } from "@/lib/integrations/page-walk";
|
|
import { fetchActivities, fetchActivityById } from "./client";
|
|
import { mapActivity, summaryHasHeartRate, type StravaWorkoutRow } from "./map";
|
|
import { type StravaDetailedActivity } from "./wire-types";
|
|
import { getStravaConnection } from "./credentials";
|
|
import { refreshStravaAfterRefusal } from "./oauth-descriptor";
|
|
import {
|
|
StravaApiError,
|
|
classifyStravaError,
|
|
isStravaRateLimited,
|
|
} from "./response-classifier";
|
|
|
|
/** Default first-incremental lookback (days) when no cursor exists yet. */
|
|
export const STRAVA_SYNC_LOOKBACK_DAYS = 30;
|
|
|
|
/** Overlap (ms) subtracted from the stored cursor on an incremental sync so an
|
|
* activity uploaded late (started before the cursor, synced after) is still
|
|
* caught. The upsert is idempotent, so re-fetching the overlap window is cheap. */
|
|
export const STRAVA_SYNC_OVERLAP_MS = 24 * 60 * 60 * 1000;
|
|
|
|
const PER_PAGE = 100;
|
|
const INCREMENTAL_MAX_PAGES = 5;
|
|
const BACKFILL_MAX_PAGES = 40;
|
|
/** Per-activity detail calls (for `calories`) are capped per sync to stay well
|
|
* under Strava's 200-req / 15-min ceiling on a heavy backfill. */
|
|
const INCREMENTAL_DETAIL_BUDGET = 100;
|
|
const BACKFILL_DETAIL_BUDGET = 150;
|
|
|
|
export function classifyStravaFailure(err: unknown): FailureKind {
|
|
return toFailureKind(classifyStravaError(err));
|
|
}
|
|
|
|
export async function recordStravaFailure(
|
|
userId: string,
|
|
err: unknown,
|
|
): Promise<void> {
|
|
await recordSyncFailure({
|
|
userId,
|
|
integration: "strava",
|
|
kind: classifyStravaFailure(err),
|
|
message: err instanceof Error ? err.message : String(err),
|
|
errorCode:
|
|
err instanceof StravaApiError && err.httpStatus != null
|
|
? String(err.httpStatus)
|
|
: undefined,
|
|
});
|
|
}
|
|
|
|
export interface SyncUserStravaOptions {
|
|
fullSync?: boolean;
|
|
lookbackDays?: number;
|
|
}
|
|
|
|
/**
|
|
* Sync one user's Strava activities. Returns the count of `Workout` rows
|
|
* written. A user with no Strava connection is a clean no-op (returns 0,
|
|
* touches no status row).
|
|
*/
|
|
export async function syncUserStrava(
|
|
userId: string,
|
|
opts: SyncUserStravaOptions = {},
|
|
): Promise<number> {
|
|
return (await runStravaSync(userId, opts)).imported;
|
|
}
|
|
|
|
/** What one history run read, for the backfill to resume from. */
|
|
export interface StravaHistoryRun {
|
|
imported: number;
|
|
/**
|
|
* The start of the oldest activity the run read, when the pages came
|
|
* newest first as Strava serves a walk without `after`; null otherwise,
|
|
* so a place is never recorded from pages in an order the resume does not
|
|
* expect.
|
|
*/
|
|
oldestStart: Date | null;
|
|
/** Set when Strava's rate limit ended the run: seconds until it resets. */
|
|
retryAfterSeconds?: number;
|
|
}
|
|
|
|
/**
|
|
* One run of the full-history walk, from `before` (or the newest activity)
|
|
* back as far as one run's page ceiling reaches.
|
|
*/
|
|
export async function syncStravaHistory(
|
|
userId: string,
|
|
before?: Date,
|
|
): Promise<StravaHistoryRun> {
|
|
return runStravaSync(userId, { fullSync: true }, before);
|
|
}
|
|
|
|
async function runStravaSync(
|
|
userId: string,
|
|
opts: SyncUserStravaOptions,
|
|
before?: Date,
|
|
): Promise<StravaHistoryRun> {
|
|
const conn = await getStravaConnection(userId);
|
|
if (!conn) return { imported: 0, oldestStart: null };
|
|
|
|
const fullSync = opts.fullSync ?? false;
|
|
const beforeEpoch =
|
|
before === undefined ? undefined : Math.floor(before.getTime() / 1000);
|
|
|
|
// Resolve the incremental cursor. On a full backfill we walk all history
|
|
// (`after` omitted). Otherwise start at the stored newest-activity instant
|
|
// minus the overlap, or a default lookback when no cursor exists yet.
|
|
let afterEpoch: number | undefined;
|
|
if (!fullSync) {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { stravaLastActivityAt: true },
|
|
});
|
|
const lookbackMs =
|
|
(opts.lookbackDays ?? STRAVA_SYNC_LOOKBACK_DAYS) * 24 * 60 * 60 * 1000;
|
|
const base = user?.stravaLastActivityAt
|
|
? user.stravaLastActivityAt.getTime() - STRAVA_SYNC_OVERLAP_MS
|
|
: Date.now() - lookbackMs;
|
|
afterEpoch = Math.floor(base / 1000);
|
|
}
|
|
|
|
// Reactive refresh state — one refresh per sync, shared across every call.
|
|
let accessToken = conn.accessToken;
|
|
let refreshDone = false;
|
|
|
|
async function refreshOnce(): Promise<boolean> {
|
|
if (refreshDone) return false;
|
|
const usable = await refreshStravaAfterRefusal(
|
|
userId,
|
|
conn!.refreshTokenCiphertext,
|
|
);
|
|
if (!usable) return false;
|
|
accessToken = usable;
|
|
refreshDone = true;
|
|
return true;
|
|
}
|
|
|
|
async function authed<T>(fn: (token: string) => Promise<T>): Promise<T> {
|
|
try {
|
|
return await fn(accessToken);
|
|
} catch (err) {
|
|
if (
|
|
err instanceof StravaApiError &&
|
|
err.httpStatus === 401 &&
|
|
(await refreshOnce())
|
|
) {
|
|
return fn(accessToken);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
const maxPages = fullSync ? BACKFILL_MAX_PAGES : INCREMENTAL_MAX_PAGES;
|
|
let detailBudget = fullSync
|
|
? BACKFILL_DETAIL_BUDGET
|
|
: INCREMENTAL_DETAIL_BUDGET;
|
|
|
|
let rows: StravaWorkoutRow[];
|
|
let newestStart: Date | null = null;
|
|
let oldestStart: Date | null = null;
|
|
let previousStart: number | null = null;
|
|
let newestFirst = true;
|
|
// Assigned inside the page callback; the cast keeps the compiler from
|
|
// narrowing it to its initial `null` after the walk.
|
|
let rateLimited = null as StravaApiError | null;
|
|
|
|
try {
|
|
// Strava pages by number; a short page is the last one. A walk that
|
|
// reaches its ceiling with a full page last reports itself truncated, and
|
|
// the backfill leaves its marker unset (`integrations/page-walk.ts`).
|
|
const walk = await walkPages<number, StravaWorkoutRow>(
|
|
"strava.activities",
|
|
maxPages,
|
|
async (cursor) => {
|
|
const page = cursor ?? 1;
|
|
let activities: Awaited<ReturnType<typeof fetchActivities>>;
|
|
try {
|
|
activities = await authed((t) =>
|
|
fetchActivities(t, {
|
|
after: afterEpoch,
|
|
before: beforeEpoch,
|
|
page,
|
|
perPage: PER_PAGE,
|
|
}),
|
|
);
|
|
} catch (err) {
|
|
if (!isStravaRateLimited(err)) throw err;
|
|
rateLimited = err;
|
|
return { items: [], next: null, interrupted: true };
|
|
}
|
|
const mapped: StravaWorkoutRow[] = [];
|
|
for (const a of activities) {
|
|
// Fetch detail only to fill `calories` (absent from the summary),
|
|
// and only while the budget allows. When the summary already lacks
|
|
// HR we also let the detail top it up. A detail failure is
|
|
// non-fatal — map from the summary — but a 429 ends the walk here,
|
|
// keeping the activities before this one.
|
|
let detail: StravaDetailedActivity | null = null;
|
|
if (detailBudget > 0) {
|
|
try {
|
|
detail = await authed((t) => fetchActivityById(t, a.id));
|
|
detailBudget -= 1;
|
|
} catch (err) {
|
|
if (isStravaRateLimited(err)) {
|
|
rateLimited = err;
|
|
return { items: mapped, next: null, interrupted: true };
|
|
}
|
|
getEvent()?.addWarning(
|
|
`strava: activity detail fetch failed for ${a.id}: ${err}`,
|
|
);
|
|
detail = null;
|
|
}
|
|
} else if (!summaryHasHeartRate(a)) {
|
|
getEvent()?.addWarning(
|
|
`strava: detail budget exhausted; mapping activity ${a.id} from summary only`,
|
|
);
|
|
}
|
|
|
|
const row = mapActivity(a, detail);
|
|
if (row) {
|
|
mapped.push(row);
|
|
if (a.start_date) {
|
|
const d = new Date(a.start_date);
|
|
if (!Number.isNaN(d.getTime())) {
|
|
if (!newestStart || d > newestStart) newestStart = d;
|
|
if (!oldestStart || d < oldestStart) oldestStart = d;
|
|
if (previousStart !== null && d.getTime() > previousStart) {
|
|
newestFirst = false;
|
|
}
|
|
previousStart = d.getTime();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
items: mapped,
|
|
next: activities.length < PER_PAGE ? null : page + 1,
|
|
};
|
|
},
|
|
1,
|
|
);
|
|
rows = walk.items;
|
|
} catch (err) {
|
|
await recordStravaFailure(userId, err);
|
|
throw markSyncFailureRecorded(err);
|
|
}
|
|
|
|
// A walk the rate limit ended is a prefix of the pages in the order Strava
|
|
// served them. Oldest first (an incremental walk), every activity up to the
|
|
// newest one read is in; newest first, older ones are still missing, and
|
|
// moving the cursor would skip them for good.
|
|
const cursorSafe = rateLimited === null || !newestFirst;
|
|
|
|
let imported: number;
|
|
try {
|
|
imported = await upsertStravaWorkouts(userId, rows);
|
|
|
|
// Advance the incremental cursor to the newest activity start we saw, but
|
|
// never move it backwards (a bounded backfill page-walk may not reach the
|
|
// very newest activity in one pass).
|
|
if (newestStart && cursorSafe) {
|
|
await prisma.user.updateMany({
|
|
where: {
|
|
id: userId,
|
|
OR: [
|
|
{ stravaLastActivityAt: null },
|
|
{ stravaLastActivityAt: { lt: newestStart } },
|
|
],
|
|
},
|
|
data: { stravaLastActivityAt: newestStart },
|
|
});
|
|
}
|
|
} catch (err) {
|
|
await recordStravaFailure(userId, err);
|
|
throw markSyncFailureRecorded(err);
|
|
}
|
|
|
|
if (rateLimited) {
|
|
// Not finished, so no success; booked transient, the next run goes on.
|
|
await recordStravaFailure(userId, rateLimited);
|
|
if (imported > 0) invalidateUserMeasurements(userId);
|
|
annotate({
|
|
action: {
|
|
name: "strava.sync.rate_limited",
|
|
details: {
|
|
imported,
|
|
retry_after_seconds: rateLimited.retryAfterSeconds ?? null,
|
|
},
|
|
},
|
|
});
|
|
return {
|
|
imported,
|
|
oldestStart: newestFirst ? oldestStart : null,
|
|
retryAfterSeconds: rateLimited.retryAfterSeconds,
|
|
};
|
|
}
|
|
|
|
await recordSyncSuccess(userId, "strava");
|
|
|
|
// Background-sync posture: sweep the workouts cache and mark the analytics
|
|
// cells stale (workouts feed achievements + analytics) so the upserted rows
|
|
// reach the cached readers, matching the `/api/workouts/batch` writer.
|
|
if (imported > 0) {
|
|
invalidateUserMeasurements(userId);
|
|
}
|
|
|
|
annotate({
|
|
action: { name: "strava.sync.complete", details: { imported } },
|
|
});
|
|
return { imported, oldestStart: newestFirst ? oldestStart : null };
|
|
}
|
|
|
|
/**
|
|
* The update for a row mapped from the summary alone. Calories, the
|
|
* description and (when the summary has none) the heart rate come only from
|
|
* the detail read, so their absence here says nothing: the stored values stay.
|
|
* A detail read that did run is written as it came, nulls included.
|
|
*/
|
|
async function summaryOnlyUpdate<
|
|
D extends {
|
|
totalEnergyKcal: number | null;
|
|
avgHeartRate: number | null;
|
|
maxHeartRate: number | null;
|
|
metadata: Prisma.InputJsonValue;
|
|
},
|
|
>(data: D, stored: { metadata: Prisma.JsonValue } | null) {
|
|
const { totalEnergyKcal, avgHeartRate, maxHeartRate, metadata, ...rest } =
|
|
data;
|
|
const storedMeta =
|
|
stored?.metadata && typeof stored.metadata === "object"
|
|
? (stored.metadata as Record<string, unknown>)
|
|
: {};
|
|
const description = storedMeta.stravaDescription;
|
|
return {
|
|
...rest,
|
|
...(totalEnergyKcal != null ? { totalEnergyKcal } : {}),
|
|
...(avgHeartRate != null ? { avgHeartRate } : {}),
|
|
...(maxHeartRate != null ? { maxHeartRate } : {}),
|
|
metadata:
|
|
typeof description === "string"
|
|
? ({
|
|
...(metadata as Record<string, unknown>),
|
|
stravaDescription: description,
|
|
} as Prisma.InputJsonValue)
|
|
: metadata,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Upsert a batch of mapped Strava activities into `Workout`, keyed on
|
|
* `(userId, source = STRAVA, externalId)` so a re-fetch overwrites in place.
|
|
* `(userId, source = STRAVA, externalId)` so replays overwrite in place.
|
|
* Failed rows are logged while the rest of the batch is attempted, then the
|
|
* first error is rethrown so the caller cannot advance its cursor or backfill
|
|
* watermark past a partially persisted batch.
|
|
*/
|
|
export async function upsertStravaWorkouts(
|
|
userId: string,
|
|
rows: readonly StravaWorkoutRow[],
|
|
): 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: "STRAVA",
|
|
externalId: r.externalId,
|
|
...data,
|
|
},
|
|
skipDuplicates: true,
|
|
select: { id: true, startedAt: true },
|
|
});
|
|
if (inserted) {
|
|
void emitInsertedWorkoutArrival(userId, inserted, "strava").catch(
|
|
() => {},
|
|
);
|
|
} else {
|
|
const where = {
|
|
userId_source_externalId: {
|
|
userId,
|
|
source: "STRAVA" as const,
|
|
externalId: r.externalId,
|
|
},
|
|
};
|
|
await prisma.workout.update({
|
|
where,
|
|
data: r.summaryOnly
|
|
? await summaryOnlyUpdate(
|
|
data,
|
|
await prisma.workout.findUnique({
|
|
where,
|
|
select: { metadata: true },
|
|
}),
|
|
)
|
|
: data,
|
|
});
|
|
}
|
|
imported += 1;
|
|
} catch (err) {
|
|
getEvent()?.addWarning(`strava: failed to upsert workout: ${err}`);
|
|
if (!hadError) {
|
|
firstError = err;
|
|
hadError = true;
|
|
}
|
|
}
|
|
}
|
|
if (hadError) throw firstError;
|
|
|
|
annotate({
|
|
action: { name: "strava.workout.ingest", details: { imported } },
|
|
});
|
|
return imported;
|
|
}
|