Files
HealthLog/src/lib/oura/sync.ts
T
Marc-André Bombeck 7e54b6be81 refactor(integrations): split the provider clients into wire types, endpoints and mappers
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.
2026-10-23 13:30:10 +02:00

901 lines
33 KiB
TypeScript

/**
* v1.17.0 — Oura Cloud v2 sync.
*
* Pulls daily readiness (→ RECOVERY_SCORE + BODY_TEMPERATURE_DEVIATION), sleep
* (real per-segment hypnogram timeline when present, else per-stage totals;
* efficiency, HRV, RHR, respiratory rate), daily activity (steps, active
* energy), the daily Sleep Score (→ SLEEP_SCORE),
* daily SpO2 (→ OXYGEN_SATURATION), the dedicated vO2_max collection (→
* VO2_MAX), and the daily cardiovascular-age estimate (→ VASCULAR_AGE) for one
* connected user, mapping each into `Measurement` rows tagged `source = OURA`.
*
* daily_stress → STRESS_SCORE is deferred: STRESS_SCORE already has an
* HRV-derived COMPUTED producer that is not yet wired into the source-priority
* ladder or the weekly graded-series collapse, so a second producer here would
* double-count nondeterministically. Re-add once STRESS_SCORE is laddered.
*
* daily_resilience → RESILIENCE (v1.19.0): the daily resilience LEVEL (limited /
* adequate / solid / strong / exceptional) is ordinal-encoded (limited=1 …
* exceptional=5) into the numeric `value` — no new categorical column. An
* unknown / missing level mints no row. See `RESILIENCE_LEVELS` in `./client`.
*
* Token model: Oura uses refresh tokens. The merged schema has no expiry
* column, so the sync refreshes REACTIVELY — the first read refused for its
* token triggers one refresh (persisting BOTH rotated tokens, serialised per
* user) and a single retry. A 401 that names a missing scope is not a token
* refusal and spends no refresh. A failed refresh (`invalid_grant`) records
* `reauth_required` on the `oura` ledger.
*
* Idempotency: `externalId = <resource>:<day>:<fieldTag>` for the day-keyed
* collections; sleep rows carry a record-scoped `sleep:<record-id>:<fieldTag>`
* key (per-segment timeline + nightly scalars) so a nap and the main sleep on
* one day stay distinct instead of overwriting each other (B2). The shared
* reconciler protects both external and natural identity. Oura finalises a
* day's scores after the night, so an exact re-post overwrites in place.
*
* The measurement-write tail mirrors the shared WHOOP / Nightscout sync tail.
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { annotate, getEvent } from "@/lib/logging/context";
import {
markSyncFailureRecorded,
recordSyncFailure,
recordSyncSuccess,
toFailureKind,
type FailureKind,
} from "@/lib/integrations/status";
import type { InsertedMeasurementArrivalRow } from "@/lib/arrivals/measurement-emit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { afterProviderWrite } from "@/lib/integrations/sync-kit/after-write";
import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import {
sweepStaleSleepSegments,
type SleepSegmentSweep,
} from "@/lib/sleep/sweep-stale-segments";
import {
MeasurementReconciliationError,
reconcileExternalMeasurement,
} from "@/lib/measurements/reconcile-external-measurement";
import {
fetchCardiovascularAge,
fetchDailyActivity,
fetchDailySleep,
fetchDailySpo2,
fetchReadiness,
fetchResilience,
fetchSleep,
fetchVo2Max,
fetchWorkouts,
} from "./client";
import {
isRetiredOuraSleepPeriod,
mapCardiovascularAge,
mapDailyActivity,
mapDailySleep,
mapDailySpo2,
mapReadiness,
mapResilience,
mapSleep,
mapVo2Max,
type MappedMeasurement,
} from "./map";
import { getOuraConnection, syncOuraScopeDenied } from "./credentials";
import { refreshOuraAfterRefusal } from "./oauth-descriptor";
import { OuraApiError, classifyOuraError } from "./response-classifier";
import { syncUserOuraCyclePhases } from "./cycle-sync";
import {
OURA_COLLECTIONS,
isOuraScopeGatedCollection,
type OuraCollectionName,
} from "./scope";
import { writeOuraWorkouts } from "./sync-workouts";
import {
refoldOuraTouchedDays,
resolveOuraHeartRateWindow,
syncOuraHeartRateRange,
TouchedDays,
} from "./sync-heart-rate";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/** Floor of the lookback window (days) for an incremental sync. Oura finalises
* a night's scores hours after wake; 7 days re-fetches a handful of records
* (the upserts are idempotent) and covers the re-score tail. */
export const OURA_SYNC_LOOKBACK_DAYS = 7;
/**
* Ceiling of the lookback window (days). A catch-up tick may widen the window
* up to this many days; beyond it the missing span is history, not an outage,
* and belongs to a backfill rather than an hourly poll.
*
* 30 rather than the ~90 days Oura's own guidance would allow: the whole batch
* still rides ONE `$transaction` with a 60 s timeout (see
* `upsertOuraMeasurements` below), and a 30-day catch-up is roughly four times
* the row count of a normal tick — comfortably inside what that transaction
* handles. Raise it only after the batch write is chunked.
*/
export const OURA_SYNC_MAX_LOOKBACK_DAYS = 30;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Resolve how far back this tick should reach, anchored on the newest Oura
* measurement we actually hold.
*
* The fixed 7-day window lost days in two ways. A sync that failed for longer
* than a week resumed with a window too narrow to reach the gap, so the missing
* days stayed missing. And a ring left unsynced (Oura's cloud has nothing until
* the app is opened) produced a run of EMPTY successes — every signal green —
* after which the late-arriving days fell outside the window and were never
* imported.
*
* Anchoring on the newest imported row covers both: rows stop arriving whether
* the sync fails or succeeds empty, so the same watermark goes stale in either
* case. It is derived, never stored, so it cannot drift out of step with the
* data the way a persisted cursor can — the watermark IS the data.
*
* `deletedAt: null` matters: the sleep sweep soft-deletes rows a revised
* hypnogram orphaned, and a tombstone must not hold the watermark artificially
* fresh. The `+ 1` day is the overlap buffer over the last seen day. With data
* flowing normally the watermark is under two days old and the clamp floors at
* `OURA_SYNC_LOOKBACK_DAYS`, so the steady-state window and request count are
* exactly what they were before. No watermark at all is a fresh connection:
* take the full ceiling.
*/
export async function resolveOuraLookbackDays(userId: string): Promise<number> {
const newest = await prisma.measurement.findFirst({
where: { userId, source: "OURA", deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { measuredAt: true },
});
if (!newest) return OURA_SYNC_MAX_LOOKBACK_DAYS;
const daysSince = Math.floor(
(Date.now() - newest.measuredAt.getTime()) / DAY_MS,
);
return Math.min(
OURA_SYNC_MAX_LOOKBACK_DAYS,
Math.max(OURA_SYNC_LOOKBACK_DAYS, daysSince + 1),
);
}
export function classifyOuraFailure(err: unknown): FailureKind {
return toFailureKind(classifyOuraError(err));
}
/**
* Record a whole-connection Oura sync failure on the shared `oura` 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 async function recordOuraSyncFailure(
userId: string,
err: unknown,
): Promise<void> {
await recordSyncFailure({
userId,
integration: "oura",
kind: classifyOuraFailure(err),
message: err instanceof Error ? err.message : String(err),
errorCode:
err instanceof OuraApiError && err.httpStatus != null
? String(err.httpStatus)
: undefined,
});
}
function ymd(d: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates
return d.toISOString().slice(0, 10);
}
export interface OuraMeasurementUpsert {
type: string;
value: number;
unit: string;
measuredAt: Date;
externalId: string;
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | null;
}
function toUpsert(
mapped: MappedMeasurement[],
resourcePrefix: string,
dateOnlyDay?: string,
): OuraMeasurementUpsert[] {
return mapped.map((m) => ({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
// A mapper that needs a record-scoped key (sleep rows — per-segment timeline
// + nightly scalars) carries its own externalId; everything else falls back
// to the day-keyed `<resource>:<day>:<fieldTag>` shape. A record with no
// instant of its own (`dateOnlyDay`) keys on its date: its row sits at
// local noon in the user's zone, which east of UTC+12 is the previous UTC
// date. A timed record keys on its instant's UTC date, as it always has.
externalId:
m.externalId ??
`${resourcePrefix}:${dateOnlyDay ?? ymd(m.measuredAt)}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
}));
}
/** One Oura collection: its ledger name 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 is contained to this one collection. A
* collection that writes rows of its own (workouts, the heart-rate stream)
* writes inside the closure, counts on `OuraRunContext.sideImported`, and
* resolves to no upserts. */
interface OuraCollectionSpec {
name: OuraCollectionName;
collect: (token: string) => Promise<OuraMeasurementUpsert[]>;
}
/** A single collection that failed to fetch/map, carried so the caller can
* classify + record the failure without blanking the collections that did
* succeed. */
export interface OuraCollectionFailure {
name: string;
err: unknown;
}
export interface OuraFetchResult {
readings: OuraMeasurementUpsert[];
failures: OuraCollectionFailure[];
/**
* One sweep entry per fetched sleep record (`sleep:<record-id>:` prefix +
* that record's fresh SLEEP_DURATION externalIds). Drives the record-scoped
* cleanup of rows a revised hypnogram orphaned — including every legacy
* run-indexed `seg:<i>` row — before the fresh set upserts.
*/
sleepSweeps: SleepSegmentSweep[];
/**
* The `sleep:<record-id>:` prefix of every fetched sleep period Oura marks as
* rejected or deleted (`isRetiredOuraSleepPeriod`). Such a period maps to no
* rows, and whatever an earlier sync imported for it is retired.
*/
sleepRetirements: string[];
/**
* Collections the grant does not cover: refused with a 401 that names the
* missing scope, or with a bare 401 while a sibling succeeded with the same
* token. Not failures; the card asks for one reconnect instead. `null` when
* the run never proved the token (no collection succeeded, none was refused
* by name, or the refresh was abandoned): it learned nothing about the
* grant, so the note from the last run that did stands.
*/
scopeDenied: string[] | null;
}
/**
* What one run reads: the day window of the day-keyed collections, the time
* range of the heart-rate stream, the user's zone, and the sinks the sleep
* collection and the self-writing collections fill as they go.
*/
export interface OuraRunContext {
userId: string;
tz: string;
query: { startDate: string; endDate: string };
heartRate: { start: Date; end: Date };
/** False on a history backfill: old rows are not arrivals. */
emitArrivals: boolean;
sleepSweeps: SleepSegmentSweep[];
sleepRetirements: string[];
/** Rows written by the collections that write their own. */
sideImported: number;
/** Days the heart-rate stream touched; refolded once by the caller. */
heartRateTouched: TouchedDays;
}
export function ouraRunContext(
userId: string,
tz: string,
query: { startDate: string; endDate: string },
heartRate: { start: Date; end: Date },
emitArrivals = true,
): OuraRunContext {
return {
userId,
tz,
query,
heartRate,
emitArrivals,
sleepSweeps: [],
sleepRetirements: [],
sideImported: 0,
heartRateTouched: new TouchedDays(),
};
}
/**
* The fetch+map (or fetch+write) closure of one collection over the context's
* window. The one place each collection's read is defined, for the hourly
* sync and the history backfill alike.
*/
export function ouraCollectionReader(
name: OuraCollectionName,
ctx: OuraRunContext,
): (token: string) => Promise<OuraMeasurementUpsert[]> {
const { tz, query } = ctx;
switch (name) {
case "readiness":
return async (t) =>
(await fetchReadiness(t, query)).flatMap((r) =>
toUpsert(
mapReadiness(r, tz),
"readiness",
r.timestamp ? undefined : r.day,
),
);
case "sleep":
return async (t) => {
const records = await fetchSleep(t, query);
const ups: OuraMeasurementUpsert[] = [];
for (const s of records) {
if (isRetiredOuraSleepPeriod(s)) {
if (s.id) ctx.sleepRetirements.push(`sleep:${s.id}:`);
continue;
}
const rows = toUpsert(
mapSleep(s, tz),
"sleep",
s.bedtime_end ? undefined : s.day,
);
ups.push(...rows);
// Record-scoped sweep entry: every SLEEP_DURATION row of this
// record keys under `sleep:<id>:` (hypnogram runs AND the
// stage-total fallback), so the prefix bounds the cleanup to this
// one record. Clears whatever a revised hypnogram orphaned —
// shifted run boundaries, legacy run-indexed `seg:<i>` rows, and a
// stale stage-total set once the hypnogram appears.
ctx.sleepSweeps.push({
prefix: `sleep:${s.id}:`,
keepIds: rows
.filter((r) => r.type === "SLEEP_DURATION")
.map((r) => r.externalId),
});
}
return ups;
};
case "activity":
return async (t) =>
(await fetchDailyActivity(t, query)).flatMap((a) =>
toUpsert(
mapDailyActivity(a, tz),
"activity",
a.timestamp ? undefined : a.day,
),
);
case "daily_sleep":
return async (t) =>
(await fetchDailySleep(t, query)).flatMap((d) =>
toUpsert(
mapDailySleep(d, tz),
"daily_sleep",
d.timestamp ? undefined : d.day,
),
);
case "spo2":
return async (t) =>
(await fetchDailySpo2(t, query)).flatMap((s) =>
toUpsert(mapDailySpo2(s, tz), "spo2", s.day),
);
case "vo2max":
return async (t) =>
(await fetchVo2Max(t, query)).flatMap((v) =>
toUpsert(mapVo2Max(v, tz), "vo2max", v.timestamp ? undefined : v.day),
);
case "cardio_age":
return async (t) =>
(await fetchCardiovascularAge(t, query)).flatMap((c) =>
toUpsert(mapCardiovascularAge(c, tz), "cardio_age", c.day),
);
case "resilience":
return async (t) =>
(await fetchResilience(t, query)).flatMap((r) =>
toUpsert(mapResilience(r, tz), "resilience", r.day),
);
case "workout":
return async (t) => {
const workouts = await fetchWorkouts(t, query);
ctx.sideImported += await writeOuraWorkouts(ctx.userId, workouts, {
emitArrivals: ctx.emitArrivals,
});
return [];
};
case "heartrate":
return async (t) => {
const { imported, truncated } = await syncOuraHeartRateRange(
ctx.userId,
t,
ctx.heartRate,
{ emitArrivals: ctx.emitArrivals, touched: ctx.heartRateTouched },
);
ctx.sideImported += imported;
if (truncated) {
// A window Oura could not page to its end is not read: fail it so
// the run is not reported clean over a gap.
throw new Error("oura heart-rate walk stopped short of its window");
}
return [];
};
}
}
/**
* A 401 whose body names the missing scope (see `./scope`): Oura checked the
* token and found it valid, the grant just does not cover this collection. A
* refresh cannot widen a grant, so none is spent on it.
*/
export function isNamedScopeRefusal(err: unknown): boolean {
return (
err instanceof OuraApiError &&
err.httpStatus === 401 &&
err.missingScope !== undefined
);
}
/**
* Any other 401: the token was refused, or an older authorization server sent
* no body to tell. This is what the one refresh is for. A 403 is neither: Oura
* answers it for a lapsed membership, which no refresh or reconnect fixes.
*/
export function isTokenRefusal(err: unknown): boolean {
return (
err instanceof OuraApiError &&
err.httpStatus === 401 &&
err.missingScope === undefined
);
}
/**
* A failure that is the grant's scope rather than the token. Only the
* collections behind a scope beyond `daily` qualify: without `daily` nothing
* syncs, and that is a reconnect, not a hint. On those, a 401 naming its scope
* always qualifies; a bare 401 qualifies only once a sibling collection has
* proven the token, the inference that predates Oura naming the scope.
*/
function isScopeRefusal(
f: OuraCollectionFailure,
tokenProven: boolean,
): boolean {
if (!isOuraScopeGatedCollection(f.name)) return false;
return isNamedScopeRefusal(f.err) || (tokenProven && isTokenRefusal(f.err));
}
/**
* Split the scope refusals out of a run's failures. Oura's authorization
* server enforces scopes per collection, so a grant issued without `spo2`,
* `heart_health` or `stress` answers 401 on exactly the collections behind
* them and 200 everywhere else. Counting those toward the reconnect ladder
* painted a working connection as broken on every sync, and refreshing the
* token over them changed nothing. Any other failure, a 401 on a core
* collection included, stays a failure.
*
* A 401 that names its scope proves the token by itself: Oura validated it in
* order to say which permission it lacks.
*/
function splitScopeDenied(
failures: OuraCollectionFailure[],
siblingSucceeded: boolean,
): { failures: OuraCollectionFailure[]; scopeDenied: string[] | null } {
const tokenProven =
siblingSucceeded || failures.some((f) => isNamedScopeRefusal(f.err));
if (!tokenProven) return { failures, scopeDenied: null };
return {
failures: failures.filter((f) => !isScopeRefusal(f, true)),
scopeDenied: failures
.filter((f) => isScopeRefusal(f, true))
.map((f) => f.name),
};
}
/**
* Fetch every Oura daily collection for a user with a single reactive
* refresh-on-401 retry, ISOLATED per collection. Unlike a bare `Promise.all`,
* one flaky endpoint or one throwing mapper no longer rejects the whole batch
* (which used to blank readiness, sleep, activity, spo2 all at once). Each
* collection is settled independently (`Promise.allSettled`); the ones that
* succeed import, the ones that throw are returned as `failures` for the caller
* to record — mirroring Google Health's / Fitbit's per-collection hard-fail
* ledger.
*
* The reactive refresh is preserved: because every collection shares one access
* token, an expired token 401s all of them together; a single refresh is done
* once and ONLY the failed collections are retried with the rotated token. A
* failed refresh (`invalid_grant`) still throws so the caller parks the whole
* connection at `reauth_required`.
*/
async function fetchAll(
ctx: OuraRunContext,
accessToken: string,
refreshTokenCiphertext: string,
knownScopeDenied: readonly string[] = [],
): Promise<OuraFetchResult> {
const userId = ctx.userId;
const { sleepSweeps, sleepRetirements } = ctx;
const collections: OuraCollectionSpec[] = OURA_COLLECTIONS.map((c) => ({
name: c.name,
collect: ouraCollectionReader(c.name, ctx),
}));
// Run a set of collections isolated from one another. A rejection is captured
// per-collection, never propagated, so siblings still resolve.
const attempt = async (
token: string,
specs: OuraCollectionSpec[],
): Promise<{
readings: OuraMeasurementUpsert[];
failures: OuraCollectionFailure[];
succeeded: number;
}> => {
const settled = await Promise.allSettled(
specs.map((spec) => spec.collect(token)),
);
const readings: OuraMeasurementUpsert[] = [];
const failures: OuraCollectionFailure[] = [];
let succeeded = 0;
settled.forEach((res, i) => {
const spec = specs[i]!;
if (res.status === "fulfilled") {
succeeded++;
readings.push(...res.value);
} else {
failures.push({ name: spec.name, err: res.reason });
}
});
return { readings, failures, succeeded };
};
const first = await attempt(accessToken, collections);
// The refresh is for a refused token. A 401 that names its scope is not one,
// and neither is a bare 401 on a collection already known to sit outside the
// grant while a sibling succeeded with the same token: a refresh cannot
// widen a grant, so none is spent on either.
const knownDenied = new Set<string>(
knownScopeDenied.filter((name) => isOuraScopeGatedCollection(name)),
);
const needsRefresh = first.failures.some(
(f) =>
isTokenRefusal(f.err) &&
!(first.succeeded > 0 && knownDenied.has(f.name)),
);
if (!needsRefresh) {
return {
readings: first.readings,
...splitScopeDenied(first.failures, first.succeeded > 0),
sleepSweeps,
sleepRetirements,
};
}
// Reactive refresh: the access token expired (a 401 on the shared token).
// Refresh once, serialised per user so an overlapping sync never spends the
// same one-time refresh token twice, and retry the collections that failed
// for any reason but a named scope. A failed refresh throws so the caller
// parks reauth_required.
const usableToken = await refreshOuraAfterRefusal(
userId,
refreshTokenCiphertext,
);
if (!usableToken)
return {
readings: first.readings,
// The refresh never happened, so no sibling's success can be trusted
// over it; a refusal that names its scope still says what it says.
...splitScopeDenied(first.failures, false),
sleepSweeps,
sleepRetirements,
};
const settledByScope = first.failures.filter((f) =>
isNamedScopeRefusal(f.err),
);
const retryNames = new Set(
first.failures
.filter((f) => !isNamedScopeRefusal(f.err))
.map((f) => f.name),
);
const retry = await attempt(
usableToken,
collections.filter((c) => retryNames.has(c.name)),
);
return {
readings: [...first.readings, ...retry.readings],
// Proven by either pass: a collection that succeeded before the refresh
// proves the grant as much as one that succeeded after it.
...splitScopeDenied(
[...settledByScope, ...retry.failures],
first.succeeded + retry.succeeded > 0,
),
sleepSweeps,
sleepRetirements,
};
}
/**
* Retire what earlier syncs imported for sleep periods Oura now reports as
* rejected or deleted: every live OURA row keyed under the period's
* `sleep:<record-id>:` prefix, whatever its type, because the period's stage
* rows and its nightly scalars (lowest heart rate, HRV, breathing rate,
* efficiency) all came from a period that was not sleep. Soft delete, then the
* shared write tail refolds the touched days and re-warms the touched types.
* Best-effort like the segment sweep: a failure never fails the sync, and the
* record stays inside the lookback window, so the next run retries it.
*/
export async function retireOuraSleepRecords(
userId: string,
prefixes: readonly string[],
): Promise<number> {
// A bare `sleep::` (no record id) would match nothing useful and must never
// widen to the whole source.
const bounded = prefixes.filter((p) => /^sleep:[^:]+:$/.test(p));
if (bounded.length === 0) return 0;
try {
const rows = await prisma.measurement.findMany({
where: {
userId,
source: "OURA",
deletedAt: null,
OR: bounded.map((prefix) => ({ externalId: { startsWith: prefix } })),
},
select: { id: true, type: true, measuredAt: true },
});
if (rows.length === 0) return 0;
const res = await prisma.measurement.updateMany({
where: { userId, id: { in: rows.map((r) => r.id) }, deletedAt: null },
data: { deletedAt: new Date() },
});
await afterMeasurementMutation(userId, rows, {
source: "sync",
label: "oura",
});
invalidateUserMeasurements(userId);
annotate({ meta: { oura_sleep_retired_rows: res.count } });
return res.count;
} catch (err) {
getEvent()?.addWarning(
`oura: retiring rejected sleep periods failed for ${userId}: ${err}`,
);
return 0;
}
}
/**
* Sync one user's Oura data.
*
* `imported` is the count of measurement rows written; `failed` is true when a
* collection did not settle. The count used to be the whole return value,
* which hid the partial: a run where every collection failed answered `0`, the
* same as a run that found nothing new, and the settings card reported both as
* a success. A user with no Oura connection is a clean no-op (nothing
* imported, nothing failed, no status row touched).
*/
export async function syncUserOura(
userId: string,
opts: { lookbackDays?: number } = {},
): Promise<SyncWriteResult> {
const conn = await getOuraConnection(userId);
if (!conn) return { imported: 0, failed: false };
// An explicit window from the caller wins; otherwise derive it from what we
// already hold so a gap widens the window instead of being skipped over.
const lookbackDays =
opts.lookbackDays ?? (await resolveOuraLookbackDays(userId));
// Oura keys its daily collections by the user's calendar date, so the
// window is cut on the user's own days, and date-only records are anchored
// at local noon of their date in this zone. Heart rate is read by instant
// over a window of hours (`resolveOuraHeartRateWindow`).
const tz = await resolveUserTimezone(userId);
const today = userDayKey(new Date(), tz);
const ctx = ouraRunContext(
userId,
tz,
{ startDate: shiftDateKey(today, -lookbackDays), endDate: today },
await resolveOuraHeartRateWindow(userId),
);
let result: OuraFetchResult;
try {
result = await fetchAll(
ctx,
conn.accessToken,
conn.refreshTokenCiphertext,
conn.scopeDenied,
);
} catch (err) {
// Only a whole-connection failure (a failed token refresh → reauth) reaches
// here now; per-collection failures are captured on `result.failures`.
// Recorded here, then marked so the poll-cohort boundary does not
// double-record it when it surfaces there.
await recordOuraSyncFailure(userId, err);
throw markSyncFailureRecorded(err);
}
// Remember which collections the grant does not cover, so the settings card
// can ask for one reconnect. Best-effort: the note is a convenience, the
// sync outcome does not depend on it.
// A run that never proved the token says nothing about the grant and leaves
// the note as it was.
const scopeDenied = result.scopeDenied;
if (scopeDenied && scopeDenied.length > 0) {
annotate({
meta: { oura_scope_denied: scopeDenied },
});
}
if (scopeDenied) {
try {
await syncOuraScopeDenied(userId, scopeDenied);
} catch (err) {
getEvent()?.addWarning(`oura: scope note write failed: ${err}`);
}
}
// Clear whatever an earlier scoring left under the re-fetched sleep records
// 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; the record stays inside the lookback window, so the next tick
// retries it.
await sweepStaleSleepSegments(userId, "OURA", result.sleepSweeps);
await retireOuraSleepRecords(userId, result.sleepRetirements);
// Import everything the healthy collections returned regardless of whether a
// sibling collection failed — one bad collection must not blank the source.
let insertedSleepMeasuredAts: Date[] = [];
const imported =
(await upsertOuraMeasurements(userId, result.readings, {
onInserted: (rows) => {
insertedSleepMeasuredAts = rows
.filter((row) => row.type === "SLEEP_DURATION")
.map((row) => row.measuredAt);
},
})) + ctx.sideImported;
await refoldOuraTouchedDays(userId, ctx.heartRateTouched);
// v1.29.x — best-effort Cycle Insights import. Fully isolated from the
// measurement sync's status ledger on purpose: `daily_cycle_phases` sits
// outside the scope most self-registered Oura apps are granted (see the
// `OuraCyclePhase` docstring in `./client`), so a 403/404 here is the
// COMMON case for a typical connection. Re-reads the connection so a
// reactive refresh `fetchAll` just performed is picked up rather than the
// (now possibly stale) token captured before it ran.
try {
const freshConn = await getOuraConnection(userId);
if (freshConn) {
await syncUserOuraCyclePhases(userId, freshConn.accessToken, ctx.query);
}
} catch (err) {
getEvent()?.addWarning(
`oura: cycle-phases import skipped for ${userId}: ${err}`,
);
}
// 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(
() => {},
);
// Background-sync posture: mark the analytics cells stale (never hard-evict
// from a poll) so the imported rows reach the cached readers. Fires on a
// partial failure too — rows that DID land must not stay invisible for the
// rest of the TTL window.
if (imported > 0) {
invalidateUserMeasurements(userId);
}
if (result.failures.length > 0) {
// Partial failure: keep the cycle honest. Record the 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.
const firstErr = result.failures[0]!.err;
getEvent()?.addWarning(
`oura: ${result.failures.length} collection(s) failed for ${userId}: ${result.failures
.map((f) => f.name)
.join(", ")}`,
);
await recordSyncFailure({
userId,
integration: "oura",
kind: classifyOuraFailure(firstErr),
message: `partial sync failure (${result.failures
.map((f) => f.name)
.join(", ")}): ${
firstErr instanceof Error ? firstErr.message : String(firstErr)
}`,
errorCode:
firstErr instanceof OuraApiError && firstErr.httpStatus != null
? String(firstErr.httpStatus)
: undefined,
});
return { imported, failed: true };
}
await recordSyncSuccess(userId, "oura");
return { imported, failed: false };
}
export async function upsertOuraMeasurements(
userId: string,
readings: OuraMeasurementUpsert[],
opts: {
onInserted?: (rows: InsertedMeasurementArrivalRow[]) => void;
/** False on a history backfill: old rows are not arrivals. */
emitArrivals?: boolean;
} = {},
): 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: "OURA",
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);
// The shared tail (a history backfill writes through here and has no other
// invalidation); the caller may emit the arrivals itself.
await afterProviderWrite({
userId,
provider: "oura",
touched,
...(opts.emitArrivals !== false
? { arrivals: { rows: insertedRows, source: "oura" as const } }
: {}),
});
return imported;
}