Files
HealthLog/src/lib/integrations/sync-verdict.ts
T
Marc-André Bombeck 527dd6c1fa fix(integrations): escalate a long transient failure streak, and scope a success to its own leg
A sync failure the classifier bucketed as `transient` had no exit. The park
test read `kind === "persistent"` and the persistent streak anchor was only
ever stamped by a persistent failure, so a streak could run for a thousand
attempts with the persistent counter at zero, the state frozen at
`error_transient`, and no ladder it could climb. `transient` is a hypothesis
about retryability, and the classifier defaults every timeout, 429, 5xx and
unrecognised response to it precisely because the wire cannot tell a reboot
from a permanent break. That default is right for one attempt and wrong for a
thousand: an unbroken streak retried hourly for a full day has refuted it by
experiment. Record it as `persistent` from there, which feeds the park ladder
that already exists — 48 h of unbroken failure end to end before an
integration stops attempting.

The counters could not accumulate anyway. `IntegrationStatus` is keyed
(user, integration) while Withings runs four legs on four crons and WHOOP four
resources on four more, and a success cleared the error, every bucket, the
streak anchor and the state whatever leg had recorded them. The ECG tick at
:41 wiped what the sleep tick recorded at :15 — including a reauth park — so
the strike ladder was reset several times an hour and the card painted a green
"connected · 12 minutes ago" over a data class that had stopped arriving. A
success now clears only what it is entitled to clear.

Two columns carry it: `failing_since_at` dates the streak, `failing_leg` names
its owner. The migration backfills the anchor for rows already in an error
state so a connection broken for weeks escalates on its next attempt rather
than restarting its clock.
2026-07-26 14:19:13 +02:00

324 lines
12 KiB
TypeScript

/**
* Server-side sync-health verdict — the one place liveness is decided.
*
* The ledger (`src/lib/integrations/status.ts`) records what the last sync
* attempt DID. It has no way to express what an attempt is no longer doing:
* `error_transient` documents itself as "the sync entry-point still attempts on
* the next run", yet a row can sit at that value for a month while nothing runs
* at all — and nothing anywhere read the AGE of `lastAttemptAt`, so the state
* was unrepresentable. A provider that had stopped even trying looked exactly
* like one that was retrying every hour.
*
* This module derives the missing verdict as a read-side projection. It writes
* nothing, adds no cron, and cannot desynchronise from the row it reads. The
* status envelope computes it per entry from data it already has in hand (zero
* additional queries) and every consumer — the Settings cards, the fallback
* row, the MCP status tool — renders the same answer.
*
* The module is deliberately pure: no Prisma import, no I/O, `import type` only.
* That keeps it importable from client components (the pill mapper reads the
* verdict union) without dragging the database client into the browser bundle.
*/
import type { IntegrationKey } from "./status";
/**
* The eight verdicts, from healthiest to least.
*
* fresh → syncing, data is current.
* stale → syncing (or push-fed) but no new data for a while.
* The primary arm for push-based Apple Health.
* stalled → no sync ATTEMPT for longer than the cadence allows.
* Not "failing" — not being tried at all.
* failing → attempting and erroring (transient or persistent).
* reauth_required → the grant is gone; the user must reconnect.
* parked → paused after a persistent failure streak; needs a
* manual resume.
* pending_first_sync → connected/configured but no attempt has been made yet.
* disconnected → no live connection.
*/
export type SyncVerdict =
| "fresh"
| "stale"
| "stalled"
| "failing"
| "reauth_required"
| "parked"
| "pending_first_sync"
| "disconnected";
/** The verdict plus the instant that triggered it (null when nothing did). */
export interface SyncHealth {
verdict: SyncVerdict;
since: string | null;
}
/**
* How long a polled integration may go without a sync ATTEMPT before it counts
* as stalled. 24 h is roughly 24 missed hourly ticks — unambiguous, and
* tolerant of a maintenance window or a slow redeploy.
*/
export const ATTEMPT_STALE_AFTER_MS = 24 * 60 * 60 * 1000;
/**
* How long a polled integration may go without new DATA before it counts as
* stale. Near-unreachable for the hourly cohort by construction (every tick
* either stamps a success or flips the row to an error state), so it stands as
* defence rather than as the primary signal.
*/
export const DATA_STALE_AFTER_MS = 48 * 60 * 60 * 1000;
/**
* Apple Health is push-based: the iOS app delivers, nothing here polls. Seven
* days is the "this pipe is not delivering" line. Shorter windows false-alarm
* on normal iOS background-delivery gaps (low storage, app unopened, budget
* exhausted), and a wrong warning on the flagship integration costs more trust
* than a few days of latency on a genuinely dead one.
*/
export const APPLE_HEALTH_DATA_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
/**
* A metric type that has not been seen for this long, while the integration
* around it reads healthy, is the dead-pipe signature. Deliberately generous:
* a weekly weigher must not be warned after a holiday.
*/
export const METRIC_QUIET_AFTER_MS = 14 * 24 * 60 * 60 * 1000;
/** Per-cohort expectation. `attemptStaleAfterMs: null` = no attempt concept. */
export interface SyncCadence {
attemptStaleAfterMs: number | null;
dataStaleAfterMs: number;
}
/** Hourly cron cohort — every integration with a ledger row. */
export const POLLED_CADENCE: SyncCadence = {
attemptStaleAfterMs: ATTEMPT_STALE_AFTER_MS,
dataStaleAfterMs: DATA_STALE_AFTER_MS,
};
/** Apple Health — the iOS app pushes; there is nothing to "attempt". */
export const PUSH_CADENCE: SyncCadence = {
attemptStaleAfterMs: null,
dataStaleAfterMs: APPLE_HEALTH_DATA_STALE_AFTER_MS,
};
/**
* Every `IntegrationKey` is polled by an hourly cron registered in
* `src/lib/jobs/reminder/register-integration-sync.ts`. Apple Health is not an
* `IntegrationKey` — it takes `PUSH_CADENCE` directly.
*/
export const INTEGRATION_CADENCE: Record<IntegrationKey, SyncCadence> = {
withings: POLLED_CADENCE,
whoop: POLLED_CADENCE,
fitbit: POLLED_CADENCE,
nightscout: POLLED_CADENCE,
polar: POLLED_CADENCE,
oura: POLLED_CADENCE,
"google-health": POLLED_CADENCE,
strava: POLLED_CADENCE,
};
export interface SyncVerdictInput {
/**
* Whether a live connection exists (an OAuth token, a connection row). Pass
* `undefined` for providers that carry no connection concept, so the ladder
* falls through to `configured`.
*/
connected?: boolean;
/** Whether credentials / a URL are stored for this provider. */
configured?: boolean;
/** The ledger `state` string. */
state?: string | null;
lastSuccessAt?: string | Date | null;
lastAttemptAt?: string | Date | null;
/** A connection row's own `lastSyncedAt`, for rows predating the ledger. */
legacyLastSyncedAt?: string | Date | null;
/**
* Start of the current unbroken failure streak, from the ledger. This is what
* the error verdicts date themselves by. `lastAttemptAt` is the wrong anchor
* — it is refreshed every hour by the retry itself, so a connection dead for
* a fortnight reported "failing, just now". `lastSuccessAt` is nearly right
* but a multi-leg provider can advance it while one leg stays broken.
*/
failingSinceAt?: string | Date | null;
/** Newest observed data instant, when the caller knows it (Apple Health). */
lastDataAt?: string | Date | null;
cadence: SyncCadence;
now?: Date | number;
}
function toMillis(value: string | Date | null | undefined): number | null {
if (value == null) return null;
const ms = value instanceof Date ? value.getTime() : Date.parse(value);
return Number.isFinite(ms) ? ms : null;
}
/** Newest of the supplied instants, or null when they are all absent. */
function newest(...values: Array<number | null>): number | null {
let max: number | null = null;
for (const value of values) {
if (value == null) continue;
if (max == null || value > max) max = value;
}
return max;
}
function iso(ms: number | null): string | null {
return ms == null ? null : new Date(ms).toISOString();
}
/**
* Resolve the sync-health verdict. First match wins.
*
* The `stalled` arm deliberately outranks `failing`: a row frozen at
* `error_transient` for a month is not failing, it is not being tried — the
* exact contradiction of the state's own documented contract.
*
* Two arms are refinements over the pure-ledger ladder, and they preserve what
* the cards already rendered: an explicit `connected === false` and an explicit
* `state === "disconnected"` both short-circuit to `disconnected`. Without them
* a provider whose credentials are still stored after a disconnect would age
* into `stalled` and claim its sync had stopped, when the user stopped it.
*/
export function resolveSyncVerdict(input: SyncVerdictInput): SyncHealth {
const nowMs =
input.now == null
? Date.now()
: input.now instanceof Date
? input.now.getTime()
: input.now;
const lastSuccess = toMillis(input.lastSuccessAt);
const lastAttempt = toMillis(input.lastAttemptAt);
const legacy = toMillis(input.legacyLastSyncedAt);
const lastData = toMillis(input.lastDataAt);
const failingSince = toMillis(input.failingSinceAt);
// 1. No live connection, or one the user explicitly ended.
if (input.connected === false)
return { verdict: "disconnected", since: null };
if (!input.connected && !input.configured) {
return { verdict: "disconnected", since: null };
}
if (input.state === "disconnected") {
return { verdict: "disconnected", since: null };
}
// 2. States the user has to act on, in order of specificity.
if (input.state === "parked") {
return { verdict: "parked", since: iso(failingSince ?? lastAttempt) };
}
if (input.state === "error_reauth") {
return {
verdict: "reauth_required",
since: iso(failingSince ?? lastAttempt),
};
}
// 3. Nothing has ever run and nothing has ever arrived — the honest reading
// of the "connected, never attempted" synthetic ledger snapshot, and of a
// push source that has yet to deliver its first sample.
if (newest(lastAttempt, lastSuccess, legacy, lastData) == null) {
return { verdict: "pending_first_sync", since: null };
}
// 4. Polled cohort only: has it even tried lately?
const attemptWindow = input.cadence.attemptStaleAfterMs;
if (attemptWindow != null) {
const lastTouch = newest(lastAttempt, lastSuccess);
if (lastTouch != null && nowMs - lastTouch > attemptWindow) {
return { verdict: "stalled", since: iso(lastTouch) };
}
}
// 5. Trying and erroring. `since` dates the streak, not the last retry: this
// is the field the card renders, and "failing since a minute ago" on a
// connection that last delivered a fortnight back is the sentence this
// whole path exists to stop.
if (input.state === "error_transient") {
return { verdict: "failing", since: iso(failingSince ?? lastSuccess) };
}
// 6. Running, but nothing new is arriving. The primary arm for push sources.
const lastFresh = newest(lastSuccess, legacy, lastData);
if (lastFresh != null && nowMs - lastFresh > input.cadence.dataStaleAfterMs) {
return { verdict: "stale", since: iso(lastFresh) };
}
return { verdict: "fresh", since: null };
}
/**
* Metric types that are irregular by nature. A fixed quiet-window would
* false-positive on every one of them, so they render their honest timestamp
* and never tint. Starting list, tunable — the fuller per-type cadence model
* stays the deferred follow-up.
*/
export const SPARSE_METRIC_TYPES: ReadonlySet<string> = new Set([
// Measured only when the user steps on a body-composition scale that
// supports them, or during an occasional fitness test.
"VO2_MAX",
"VASCULAR_AGE",
"PULSE_WAVE_VELOCITY",
"SIX_MINUTE_WALK_DISTANCE",
"FALL_COUNT",
// Event types: fired by the device only when something happens. Silence is
// the healthy reading.
"IRREGULAR_RHYTHM_NOTIFICATION",
"HIGH_HEART_RATE_EVENT",
"LOW_HEART_RATE_EVENT",
"WALKING_STEADINESS_EVENT",
"BREATHING_DISTURBANCE_EVENT",
"AUDIO_EXPOSURE_EVENT",
// People train irregularly; a quiet training week is not a broken pipe.
"WORKOUTS",
]);
/** The pseudo-type carrying a source's newest workout, next to its metrics. */
export const WORKOUT_FRESHNESS_TYPE = "WORKOUTS";
/** Newest recorded reading for one `(source, type)` pair, as queried. */
export interface MetricFreshnessSample {
/** The `MeasurementType` (e.g. "RESPIRATORY_RATE"), or `WORKOUTS`. */
type: string;
/** ISO timestamp of the newest (non-deleted) reading for this metric. */
lastSeenAt: string;
}
/** A sample plus the server's staleness verdict for it. */
export interface MetricFreshnessEntry extends MetricFreshnessSample {
/**
* The metric has gone quiet while the integration around it reads healthy.
* Only ever true when the provider verdict is `fresh` — see
* `classifyMetricFreshness`.
*/
stale: boolean;
}
/**
* Attach the per-metric staleness flag.
*
* A metric silent for weeks WHILE the pipe is otherwise green is the dead-pipe
* signature: a per-collection 403, an upstream envelope drift, a revoked
* per-type permission. When the provider itself is stalled / failing / stale
* every metric ages together, so tinting them would only restate what the pill
* already says — the entries stay neutral and the provider verdict carries the
* story.
*/
export function classifyMetricFreshness(
samples: readonly MetricFreshnessSample[],
verdict: SyncVerdict,
now: Date | number = Date.now(),
): MetricFreshnessEntry[] {
const nowMs = now instanceof Date ? now.getTime() : now;
return samples.map((sample) => {
if (verdict !== "fresh" || SPARSE_METRIC_TYPES.has(sample.type)) {
return { ...sample, stale: false };
}
const seen = toMillis(sample.lastSeenAt);
return {
...sample,
stale: seen != null && nowMs - seen > METRIC_QUIET_AFTER_MS,
};
});
}