feat(integrations): resolve sync health on the server

The ledger records what the last attempt did. It has no way to say what is
no longer being attempted: a row frozen at `error_transient` for a month
reads exactly like one retrying every hour, because nothing anywhere read
the age of `lastAttemptAt`.

`sync-verdict.ts` derives the missing reading as a pure read-side
projection: eight verdicts down one ladder, no writer, no cron, nothing that
can desynchronise from the row it reads. `stalled` is the new one, and it
deliberately outranks `failing` — an integration nobody is calling is not
failing, it is not running.

Every entry on `/api/integrations/status` carries the verdict, computed
inline from data the route already holds. That placement is the point: a
card-level verdict misses a card-less provider by construction, which is how
a dead sync stayed invisible. Nightscout joins the envelope so its card can
stop fetching its own status.

The freshness module gains the workout leg (Strava writes no measurements at
all) and a source-parameterised core so the Apple Health route can reuse it.
Its per-metric `stale` flag fires only while the provider itself reads
healthy: that combination is the dead-pipe signature, and when the whole
connection has stopped every metric ages together anyway.

MCP reports the verdict beside `connected`, which only ever meant "not
explicitly disconnected".
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 09:41:20 +02:00
1 parent a1a978f5e6
commit de47bfa5ba
6 files changed
+736 -86

No files matched your search

+27 -1
View File
@@ -20,6 +20,12 @@ import {
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getSourceFreshness } from "@/lib/integrations/metric-freshness";
import {
classifyMetricFreshness,
PUSH_CADENCE,
resolveSyncVerdict,
} from "@/lib/integrations/sync-verdict";
export const directionEnum = z.enum([
"bidirectional",
@@ -129,10 +135,30 @@ export const GET = apiHandler(async () => {
const stored = parseStored(row?.healthKitConfigJson ?? null);
const entries = mergeWithDefaults(stored);
const lastSyncedAt = row?.healthKitLastSyncedAt?.toISOString() ?? null;
// Apple Health is push-based: the iOS app delivers and nothing here polls, so
// liveness is a data question, not an attempt question. Without a threshold a
// three-week-dead pipe rendered the same green as one that delivered an hour
// ago. Fail-soft on the per-metric read — an honesty signal is never worth
// 500-ing the config the iOS client needs.
// No `connected` hint: Apple Health has nothing to connect or disconnect, so
// "never delivered" resolves to `pending_first_sync`, not `disconnected`.
const syncHealth = resolveSyncVerdict({
configured: true,
lastDataAt: lastSyncedAt,
legacyLastSyncedAt: lastSyncedAt,
cadence: PUSH_CADENCE,
});
const samples = await getSourceFreshness(user.id, "APPLE_HEALTH").catch(
() => [],
);
return apiSuccess({
entries,
lastSyncedAt: row?.healthKitLastSyncedAt?.toISOString() ?? null,
lastSyncedAt,
syncHealth,
metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict),
});
});
+178 -44
View File
@@ -27,7 +27,14 @@ import {
import {
getSourceMetricFreshness,
type MetricFreshnessEntry,
type MetricFreshnessSample,
} from "@/lib/integrations/metric-freshness";
import {
classifyMetricFreshness,
INTEGRATION_CADENCE,
resolveSyncVerdict,
type SyncHealth,
} from "@/lib/integrations/sync-verdict";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
@@ -49,6 +56,7 @@ export const GET = apiHandler(async () => {
polarStatus,
ouraStatus,
stravaStatus,
nightscoutStatus,
dbUser,
withingsConn,
whoopConn,
@@ -70,6 +78,7 @@ export const GET = apiHandler(async () => {
getIntegrationStatus(user.id, "polar"),
getIntegrationStatus(user.id, "oura"),
getIntegrationStatus(user.id, "strava"),
getIntegrationStatus(user.id, "nightscout"),
prisma.user.findUnique({
where: { id: user.id },
select: {
@@ -95,6 +104,11 @@ export const GET = apiHandler(async () => {
moodLogEnabled: true,
moodLogLastSyncedAt: true,
moodLogWebhookSecret: true,
// Nightscout folds onto the envelope; the card reads these three off
// it instead of its own /api/nightscout/status round-trip.
nightscoutUrlEncrypted: true,
nightscoutTokenEncrypted: true,
nightscoutAllowPrivateHost: true,
},
}),
prisma.withingsConnection.findUnique({
@@ -149,26 +163,93 @@ export const GET = apiHandler(async () => {
// ("connected · 5 min ago"). Fail-soft: this is an honesty signal, never worth
// 500-ing the whole Settings card, so a groupBy hiccup degrades to no data.
const metricFreshness = await getSourceMetricFreshness(user.id).catch(
() => ({}) as Partial<Record<IntegrationKey, MetricFreshnessEntry[]>>,
() => ({}) as Partial<Record<IntegrationKey, MetricFreshnessSample[]>>,
);
const now = Date.now();
const whoopConnected = !!whoopConn?.whoopUserId;
/**
* The sync-health verdict + the per-metric staleness flags, resolved from
* data already in hand — no additional query. Every entry carries it,
* whether or not a card exists for the provider: a card-level verdict misses
* a card-less provider by construction, which is how a month-dead sync went
* unnoticed.
*/
function resolve(
integration: IntegrationKey,
snapshot: {
state: string;
lastSuccessAt: string | null;
lastAttemptAt: string | null;
},
opts: {
connected?: boolean;
configured?: boolean;
legacyLastSyncedAt?: string | null;
},
): { syncHealth: SyncHealth; metricFreshness: MetricFreshnessEntry[] } {
const syncHealth = resolveSyncVerdict({
connected: opts.connected,
configured: opts.configured,
state: snapshot.state,
lastSuccessAt: snapshot.lastSuccessAt,
lastAttemptAt: snapshot.lastAttemptAt,
legacyLastSyncedAt: opts.legacyLastSyncedAt ?? null,
cadence: INTEGRATION_CADENCE[integration],
now,
});
return {
syncHealth,
metricFreshness: classifyMetricFreshness(
metricFreshness[integration] ?? [],
syncHealth.verdict,
now,
),
};
}
const withingsConfigured =
!!dbUser?.withingsClientIdEncrypted &&
!!dbUser?.withingsClientSecretEncrypted;
const withingsLegacyLastSyncedAt =
withingsConn?.lastSyncedAt?.toISOString() ?? null;
const moodLogConfigured = !!dbUser?.moodLogUrlEncrypted;
const moodLogLegacyLastSyncedAt =
dbUser?.moodLogLastSyncedAt?.toISOString() ?? null;
const whoopConfigured =
!!dbUser?.whoopClientIdEncrypted && !!dbUser?.whoopClientSecretEncrypted;
const whoopLegacyLastSyncedAt = whoopConnected
? (whoopConn?.lastSyncedAt?.toISOString() ?? null)
: null;
const fitbitConnected = !!fitbitConn;
const fitbitConfigured =
!!dbUser?.fitbitClientIdEncrypted && !!dbUser?.fitbitClientSecretEncrypted;
const fitbitLegacyLastSyncedAt =
fitbitConn?.lastSyncedAt?.toISOString() ?? null;
const googleHealthConnected = !!googleHealthConn;
const googleHealthConfigured =
!!dbUser?.googleHealthClientIdEncrypted &&
!!dbUser?.googleHealthClientSecretEncrypted;
const googleHealthLegacyLastSyncedAt =
googleHealthConn?.lastSyncedAt?.toISOString() ?? null;
const polarConnected = !!dbUser?.polarAccessTokenEncrypted;
const ouraConnected = !!dbUser?.ouraAccessTokenEncrypted;
const stravaConnected = !!dbUser?.stravaAccessTokenEncrypted;
const nightscoutConfigured = !!dbUser?.nightscoutUrlEncrypted;
return apiSuccess({
threshold: getPersistentFailureThreshold(),
integrations: [
{
...withingsStatus,
configured:
!!dbUser?.withingsClientIdEncrypted &&
!!dbUser?.withingsClientSecretEncrypted,
configured: withingsConfigured,
connected: !!withingsConn,
connectedAt: withingsConn?.createdAt?.toISOString() ?? null,
// Surface the connection row's success time too so the UI
// can fall back to it when no IntegrationStatus row exists
// yet (legacy connections established before this feature).
legacyLastSyncedAt: withingsConn?.lastSyncedAt?.toISOString() ?? null,
legacyLastSyncedAt: withingsLegacyLastSyncedAt,
tokenExpiresAt: withingsConn?.tokenExpiresAt?.toISOString() ?? null,
tokenExpired: withingsConn
? withingsConn.tokenExpiresAt.getTime() <= now
@@ -178,33 +259,39 @@ export const GET = apiHandler(async () => {
// Null `scope` = legacy connection that predates activity-scope reads.
scope: withingsConn?.scope ?? null,
hasActivityScope: hasActivityScope(withingsConn?.scope ?? null),
metricFreshness: metricFreshness.withings ?? [],
...resolve("withings", withingsStatus, {
connected: !!withingsConn,
configured: withingsConfigured,
legacyLastSyncedAt: withingsLegacyLastSyncedAt,
}),
} satisfies IntegrationViewModel & WithingsExtras,
{
...moodLogStatus,
// moodLog "configured" tracks the URL alone (the API key is
// optional for the webhook-only path), matching the legacy
// /api/integrations/moodlog/status contract the card relied on.
configured: !!dbUser?.moodLogUrlEncrypted,
configured: moodLogConfigured,
enabled: dbUser?.moodLogEnabled ?? false,
legacyLastSyncedAt: dbUser?.moodLogLastSyncedAt?.toISOString() ?? null,
legacyLastSyncedAt: moodLogLegacyLastSyncedAt,
// V3 audit STILL-V2-C-2: stored secret is AES-GCM encrypted at rest;
// decrypt for the settings page (legacy plaintext is also handled).
webhookSecret: readMoodLogSecret(dbUser?.moodLogWebhookSecret ?? null),
entryCount: moodLogEntryCount,
// moodLog has no connection row — `configured` alone decides whether it
// is meant to be running, so the verdict gets no `connected` hint.
...resolve("moodlog", moodLogStatus, {
configured: moodLogConfigured,
legacyLastSyncedAt: moodLogLegacyLastSyncedAt,
}),
} satisfies IntegrationViewModel & MoodLogExtras,
{
...whoopStatus,
configured:
!!dbUser?.whoopClientIdEncrypted &&
!!dbUser?.whoopClientSecretEncrypted,
configured: whoopConfigured,
connected: whoopConnected,
connectedAt: whoopConnected
? (whoopConn?.createdAt.toISOString() ?? null)
: null,
legacyLastSyncedAt: whoopConnected
? (whoopConn?.lastSyncedAt?.toISOString() ?? null)
: null,
legacyLastSyncedAt: whoopLegacyLastSyncedAt,
tokenExpiresAt: whoopConnected
? (whoopConn?.tokenExpiresAt.toISOString() ?? null)
: null,
@@ -214,32 +301,35 @@ export const GET = apiHandler(async () => {
backfillCompleted: whoopConnected
? !!whoopConn?.backfillCompletedAt
: null,
metricFreshness: metricFreshness.whoop ?? [],
...resolve("whoop", whoopStatus, {
connected: whoopConnected,
configured: whoopConfigured,
legacyLastSyncedAt: whoopLegacyLastSyncedAt,
}),
} satisfies IntegrationViewModel & WhoopExtras,
{
...fitbitStatus,
configured:
!!dbUser?.fitbitClientIdEncrypted &&
!!dbUser?.fitbitClientSecretEncrypted,
connected: !!fitbitConn,
configured: fitbitConfigured,
connected: fitbitConnected,
connectedAt: fitbitConn?.createdAt?.toISOString() ?? null,
legacyLastSyncedAt: fitbitConn?.lastSyncedAt?.toISOString() ?? null,
legacyLastSyncedAt: fitbitLegacyLastSyncedAt,
tokenExpiresAt: fitbitConn?.tokenExpiresAt?.toISOString() ?? null,
tokenExpired: fitbitConn
? fitbitConn.tokenExpiresAt.getTime() <= now
: null,
backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null,
metricFreshness: metricFreshness.fitbit ?? [],
...resolve("fitbit", fitbitStatus, {
connected: fitbitConnected,
configured: fitbitConfigured,
legacyLastSyncedAt: fitbitLegacyLastSyncedAt,
}),
} satisfies IntegrationViewModel & FitbitExtras,
{
...googleHealthStatus,
configured:
!!dbUser?.googleHealthClientIdEncrypted &&
!!dbUser?.googleHealthClientSecretEncrypted,
connected: !!googleHealthConn,
configured: googleHealthConfigured,
connected: googleHealthConnected,
connectedAt: googleHealthConn?.createdAt?.toISOString() ?? null,
legacyLastSyncedAt:
googleHealthConn?.lastSyncedAt?.toISOString() ?? null,
legacyLastSyncedAt: googleHealthLegacyLastSyncedAt,
tokenExpiresAt: googleHealthConn?.tokenExpiresAt?.toISOString() ?? null,
tokenExpired: googleHealthConn
? googleHealthConn.tokenExpiresAt.getTime() <= now
@@ -251,7 +341,11 @@ export const GET = apiHandler(async () => {
// user-revoked grant) surfaced from the connection row; the card reads it
// to raise a distinct "Reconnect" CTA separate from parked/disconnected.
needsReauth: googleHealthConn ? googleHealthConn.needsReauth : false,
metricFreshness: metricFreshness["google-health"] ?? [],
...resolve("google-health", googleHealthStatus, {
connected: googleHealthConnected,
configured: googleHealthConfigured,
legacyLastSyncedAt: googleHealthLegacyLastSyncedAt,
}),
} satisfies IntegrationViewModel & GoogleHealthExtras,
{
...polarStatus,
@@ -259,36 +353,60 @@ export const GET = apiHandler(async () => {
// OAuth card has no separate "credentials saved but disconnected" view
// beyond `hasOwnCredentials`). The card greys out the connect button
// when no usable credentials resolve (`available`).
connected: !!dbUser?.polarAccessTokenEncrypted,
configured: !!dbUser?.polarAccessTokenEncrypted,
connected: polarConnected,
configured: polarConnected,
available: polarAvailable,
hasOwnCredentials:
!!dbUser?.polarClientIdEncrypted &&
!!dbUser?.polarClientSecretEncrypted,
metricFreshness: metricFreshness.polar ?? [],
...resolve("polar", polarStatus, {
connected: polarConnected,
configured: polarConnected,
}),
} satisfies IntegrationViewModel & OAuthProviderExtras,
{
...ouraStatus,
connected: !!dbUser?.ouraAccessTokenEncrypted,
configured: !!dbUser?.ouraAccessTokenEncrypted,
connected: ouraConnected,
configured: ouraConnected,
available: ouraAvailable,
hasOwnCredentials:
!!dbUser?.ouraClientIdEncrypted &&
!!dbUser?.ouraClientSecretEncrypted,
metricFreshness: metricFreshness.oura ?? [],
...resolve("oura", ouraStatus, {
connected: ouraConnected,
configured: ouraConnected,
}),
} satisfies IntegrationViewModel & OAuthProviderExtras,
{
...stravaStatus,
connected: !!dbUser?.stravaAccessTokenEncrypted,
configured: !!dbUser?.stravaAccessTokenEncrypted,
connected: stravaConnected,
configured: stravaConnected,
available: stravaAvailable,
hasOwnCredentials:
!!dbUser?.stravaClientIdEncrypted &&
!!dbUser?.stravaClientSecretEncrypted,
// Strava writes Workout rows, not Measurements, so it has no
// per-metric measurement freshness (like moodLog).
metricFreshness: metricFreshness.strava ?? [],
// Strava writes Workout rows, not Measurements — its freshness list
// carries the `WORKOUTS` pseudo-entry alone.
...resolve("strava", stravaStatus, {
connected: stravaConnected,
configured: stravaConnected,
}),
} satisfies IntegrationViewModel & OAuthProviderExtras,
{
...nightscoutStatus,
// Nightscout is a URL the self-hoster pastes once; `configured` (a
// stored URL) is the connect marker, and a fully-public instance has
// no token. The card reads these off the envelope now instead of its
// own status round-trip, so the whole page reads from one source.
connected: nightscoutConfigured,
configured: nightscoutConfigured,
hasToken: !!dbUser?.nightscoutTokenEncrypted,
allowPrivateHost: dbUser?.nightscoutAllowPrivateHost ?? false,
...resolve("nightscout", nightscoutStatus, {
connected: nightscoutConfigured,
configured: nightscoutConfigured,
}),
} satisfies IntegrationViewModel & NightscoutExtras,
],
});
});
@@ -300,12 +418,18 @@ interface IntegrationViewModel {
lastAttemptAt: string | null;
lastError: string | null;
/**
* F-SYNC-1 — per-metric-type last-value timestamps for the integration's
* synced measurements. Present on the measurement-backed integrations;
* omitted for moodLog (which writes MoodEntry rows, not Measurements). Lets
* the card flag a silently-dead metric the green integration state hides.
* The server-resolved liveness verdict. Present on EVERY entry, whether or
* not a card renders it — the unit of coverage is the envelope entry, not
* the card.
*/
metricFreshness?: MetricFreshnessEntry[];
syncHealth: SyncHealth;
/**
* Per-metric-type last-value timestamps for the integration's synced data,
* each with the server-computed `stale` flag. Empty for moodLog (which
* writes MoodEntry rows, not Measurements). Lets the card flag a silently
* dead metric the green integration state hides.
*/
metricFreshness: MetricFreshnessEntry[];
}
interface WithingsExtras {
@@ -371,3 +495,13 @@ interface OAuthProviderExtras {
available: boolean;
hasOwnCredentials: boolean;
}
// Nightscout folds onto the envelope so the card stops firing its own status
// round-trip and so the provider inherits the shared verdict. `hasToken` and
// `allowPrivateHost` are the two fields the card's form placeholders read.
interface NightscoutExtras {
connected: boolean;
configured: boolean;
hasToken: boolean;
allowPrivateHost: boolean;
}
+141 -39
View File
@@ -1,5 +1,5 @@
/**
* F-SYNC-1 — per-metric-type freshness (the smallest honest signal).
* Per-metric-type freshness (the smallest honest signal).
*
* Freshness was tracked only per-integration (`IntegrationStatus.lastSuccessAt`
* / a connection's `lastSyncedAt`). A provider that returns HTTP 200 with an
@@ -12,19 +12,33 @@
* This computes the last-value timestamp per `(source, type)` straight from the
* `Measurement` table (no schema change, no migration) so a caller can surface
* per-metric liveness honestly: a metric whose newest reading is frozen weeks in
* the past is visibly distinct from one that is genuinely current. The coarser
* "is this stale for THIS metric's expected cadence" classification (per-type
* thresholds / an expected-cadence probe cron) is the deferred fuller model;
* this exposes the raw honest timestamp the UI renders as "last seen …".
* the past is visibly distinct from one that is genuinely current. The
* classification of "quiet" vs "current" is a pure function next to the verdict
* (`classifyMetricFreshness` in `./sync-verdict`); this module only reads.
*
* Two sources write no `Measurement` rows for part of what they deliver:
* Strava writes only `Workout` rows, and Polar's workout leg is one of its two
* cron legs. A second grouped read over `Workout` appends a `WORKOUTS`
* pseudo-entry so those pipes are visible too.
*/
import { prisma } from "@/lib/db";
import type { MeasurementSource } from "@/generated/prisma/client";
import type { IntegrationKey } from "./status";
import {
WORKOUT_FRESHNESS_TYPE,
type MetricFreshnessSample,
} from "./sync-verdict";
export type {
MetricFreshnessEntry,
MetricFreshnessSample,
} from "./sync-verdict";
/**
* The `MeasurementSource` each sync integration's rows carry. `moodlog` is
* absent — it writes `MoodEntry` rows, not `Measurement` rows, so it has no
* per-metric measurement freshness.
* per-metric measurement freshness. `strava` is absent from the measurement
* map for the same reason and picks up its `WORKOUTS` entry below.
*/
export const INTEGRATION_MEASUREMENT_SOURCE: Partial<
Record<IntegrationKey, MeasurementSource>
@@ -38,53 +52,141 @@ export const INTEGRATION_MEASUREMENT_SOURCE: Partial<
"google-health": "GOOGLE_HEALTH",
};
/** Newest recorded reading for one `(source, type)` pair. */
export interface MetricFreshnessEntry {
/** The `MeasurementType` (e.g. "RESPIRATORY_RATE"). */
type: string;
/** ISO timestamp of the newest (non-deleted) reading for this metric. */
lastSeenAt: string;
/**
* The `MeasurementSource` each workout-writing integration's `Workout` rows
* carry. Strava is workouts-only; Polar syncs workouts alongside its
* measurements.
*/
export const INTEGRATION_WORKOUT_SOURCE: Partial<
Record<IntegrationKey, MeasurementSource>
> = {
strava: "STRAVA",
polar: "POLAR",
};
/**
* Newest measurement per `(source, type)` for the given sources. One grouped
* query; a `(source, type)` pair with no rows simply has no entry, which is how
* honest absence stays structural — a metric a provider never delivered is
* never invented.
*/
export async function getMeasurementFreshnessBySource(
userId: string,
sources: readonly MeasurementSource[],
): Promise<Map<MeasurementSource, MetricFreshnessSample[]>> {
const out = new Map<MeasurementSource, MetricFreshnessSample[]>();
if (sources.length === 0) return out;
const grouped = await prisma.measurement.groupBy({
by: ["source", "type"],
where: { userId, deletedAt: null, source: { in: [...sources] } },
_max: { measuredAt: true },
});
for (const row of grouped) {
const lastSeen = row._max.measuredAt;
if (!lastSeen) continue;
const list = out.get(row.source) ?? [];
list.push({ type: row.type, lastSeenAt: lastSeen.toISOString() });
out.set(row.source, list);
}
return out;
}
/**
* Newest workout per source. `Workout` rows are hard-deleted, so there is no
* tombstone predicate to apply.
*/
export async function getWorkoutFreshnessBySource(
userId: string,
sources: readonly MeasurementSource[],
): Promise<Map<MeasurementSource, string>> {
const out = new Map<MeasurementSource, string>();
if (sources.length === 0) return out;
const grouped = await prisma.workout.groupBy({
by: ["source"],
where: { userId, source: { in: [...sources] } },
_max: { startedAt: true },
});
for (const row of grouped) {
const lastSeen = row._max.startedAt;
if (!lastSeen) continue;
out.set(row.source, lastSeen.toISOString());
}
return out;
}
/**
* Per-metric last-value timestamps for one measurement source, sorted
* alphabetically for a stable wire shape. Used by the Apple Health route, whose
* transport sits outside the integration envelope.
*/
export async function getSourceFreshness(
userId: string,
source: MeasurementSource,
): Promise<MetricFreshnessSample[]> {
const [measurements, workouts] = await Promise.all([
getMeasurementFreshnessBySource(userId, [source]),
getWorkoutFreshnessBySource(userId, [source]),
]);
const entries = measurements.get(source) ?? [];
const workoutSeenAt = workouts.get(source);
if (workoutSeenAt) {
entries.push({
type: WORKOUT_FRESHNESS_TYPE,
lastSeenAt: workoutSeenAt,
});
}
return sortByType(entries);
}
/**
* Compute per-`(integration, type)` last-value timestamps for the sync
* integrations, keyed by `IntegrationKey`. One grouped query over the live
* `Measurement` rows; a source with no rows simply has no entry. Types are
* sorted alphabetically for a stable wire shape.
* integrations, keyed by `IntegrationKey`. Two grouped queries (measurements +
* workouts); a source with no rows simply has no entry.
*/
export async function getSourceMetricFreshness(
userId: string,
): Promise<Partial<Record<IntegrationKey, MetricFreshnessEntry[]>>> {
const sources = Object.values(INTEGRATION_MEASUREMENT_SOURCE);
const grouped = await prisma.measurement.groupBy({
by: ["source", "type"],
where: { userId, deletedAt: null, source: { in: sources } },
_max: { measuredAt: true },
});
): Promise<Partial<Record<IntegrationKey, MetricFreshnessSample[]>>> {
const [measurements, workouts] = await Promise.all([
getMeasurementFreshnessBySource(
userId,
Object.values(INTEGRATION_MEASUREMENT_SOURCE),
),
getWorkoutFreshnessBySource(
userId,
Object.values(INTEGRATION_WORKOUT_SOURCE),
),
]);
// Invert the source→key map once so each grouped row resolves its integration.
const sourceToKey = new Map<MeasurementSource, IntegrationKey>(
(
Object.entries(INTEGRATION_MEASUREMENT_SOURCE) as Array<
[IntegrationKey, MeasurementSource]
>
).map(([key, source]) => [source, key]),
);
const out: Partial<Record<IntegrationKey, MetricFreshnessSample[]>> = {};
const out: Partial<Record<IntegrationKey, MetricFreshnessEntry[]>> = {};
for (const row of grouped) {
const lastSeen = row._max.measuredAt;
if (!lastSeen) continue;
const key = sourceToKey.get(row.source);
if (!key) continue;
for (const [key, source] of Object.entries(
INTEGRATION_MEASUREMENT_SOURCE,
) as Array<[IntegrationKey, MeasurementSource]>) {
const entries = measurements.get(source);
if (entries?.length) out[key] = entries;
}
for (const [key, source] of Object.entries(
INTEGRATION_WORKOUT_SOURCE,
) as Array<[IntegrationKey, MeasurementSource]>) {
const lastSeenAt = workouts.get(source);
if (!lastSeenAt) continue;
(out[key] ??= []).push({
type: row.type,
lastSeenAt: lastSeen.toISOString(),
type: WORKOUT_FRESHNESS_TYPE,
lastSeenAt,
});
}
for (const key of Object.keys(out) as IntegrationKey[]) {
out[key]!.sort((a, b) => a.type.localeCompare(b.type));
out[key] = sortByType(out[key]!);
}
return out;
}
function sortByType(entries: MetricFreshnessSample[]): MetricFreshnessSample[] {
return [...entries].sort((a, b) => a.type.localeCompare(b.type));
}
+309
View File
@@ -0,0 +1,309 @@
/**
* 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,
moodlog: 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 — moodLog is
* configured by URL alone — 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;
/** 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);
// 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(lastAttempt) };
}
if (input.state === "error_reauth") {
return { verdict: "reauth_required", since: iso(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.
if (input.state === "error_transient") {
return { verdict: "failing", since: iso(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,
};
});
}
+20 -1
View File
@@ -62,6 +62,10 @@ import {
getIntegrationStatus,
type IntegrationKey,
} from "@/lib/integrations/status";
import {
INTEGRATION_CADENCE,
resolveSyncVerdict,
} from "@/lib/integrations/sync-verdict";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence";
import type { McpAuthContext } from "./auth";
@@ -860,6 +864,8 @@ const getIntegrationStatusOutput: z.ZodRawShape = {
reauthRequired: z.boolean(),
lastSuccessAt: z.string().nullable(),
lastAttemptAt: z.string().nullable(),
verdict: z.string(),
since: z.string().nullable(),
}),
)
.optional(),
@@ -1478,7 +1484,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
name: "get_integration_status",
title: "Get device & service sync status",
description:
"Fetch the sync health of the user's connected devices and services (Withings, WHOOP, Fitbit, Nightscout, Polar, Oura, moodLog) — which are connected, when each last synced, and whether one needs reconnecting or is failing. Answers 'why is my data stale?'. Reuses the integration-status ledger; carries no secrets or tokens. Returns { present: false } when no integration has ever attempted a sync.",
"Fetch the sync health of the user's connected devices and services (Withings, WHOOP, Fitbit, Nightscout, Polar, Oura, moodLog) — which are connected, when each last synced, and whether one needs reconnecting or is failing. Answers 'why is my data stale?'. Reuses the integration-status ledger; carries no secrets or tokens. `connected` means only 'not explicitly disconnected'; `verdict` is the liveness truth (fresh / stale / stalled / failing / reauth_required / parked / pending_first_sync / disconnected), where `stalled` means the sync has stopped even attempting. Returns { present: false } when no integration has ever attempted a sync.",
inputShape: {},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: getIntegrationStatusOutput,
@@ -1503,6 +1509,17 @@ export const MCP_TOOLS: McpToolDefinition[] = [
ctx.userId,
r.integration as IntegrationKey,
);
// `connected` means "not explicitly disconnected" and says nothing
// about liveness — a month-dead pull still reads true. `verdict` is
// the liveness truth, resolved by the same server-side rule the
// Settings page renders.
const health = resolveSyncVerdict({
configured: true,
state: s.state,
lastSuccessAt: s.lastSuccessAt,
lastAttemptAt: s.lastAttemptAt,
cadence: INTEGRATION_CADENCE[s.integration],
});
return {
provider: s.integration,
state: s.state,
@@ -1510,6 +1527,8 @@ export const MCP_TOOLS: McpToolDefinition[] = [
reauthRequired: s.state === "error_reauth" || s.state === "parked",
lastSuccessAt: s.lastSuccessAt,
lastAttemptAt: s.lastAttemptAt,
verdict: health.verdict,
since: health.since,
};
}),
);
+61 -1
View File
@@ -29,6 +29,57 @@ const healthKitEntry = z
"One resolved HealthKit metric mapping (defaults merged with the user's stored overrides).",
});
// Mirrors `src/lib/integrations/sync-verdict.ts` — the server-resolved liveness
// verdict. Apple Health is push-based, so only the data-age arms apply.
const syncHealth = z
.object({
verdict: z
.enum([
"fresh",
"stale",
"stalled",
"failing",
"reauth_required",
"parked",
"pending_first_sync",
"disconnected",
])
.describe(
"Liveness verdict. For Apple Health: `fresh` when data arrived within the last 7 days, `stale` when it did not, `pending_first_sync` when none ever arrived.",
),
since: z.iso
.datetime({ offset: true })
.nullable()
.describe("The instant that triggered the verdict; null when none did."),
})
.meta({
id: "SyncHealth",
description:
"Server-resolved sync-health verdict. The single source of liveness truth — `lastSyncedAt` alone cannot express 'this pipe stopped delivering'.",
});
const metricFreshnessEntry = z
.object({
type: z
.string()
.describe(
"The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg.",
),
lastSeenAt: z.iso
.datetime({ offset: true })
.describe("Newest recorded reading for this type from this source."),
stale: z
.boolean()
.describe(
"This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single revoked HealthKit permission). Only ever true when the verdict is `fresh`.",
),
})
.meta({
id: "MetricFreshnessEntry",
description:
"Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually delivered appear — absence is absence, never an invented row.",
});
const healthKitConfigResponse = z
.object({
entries: z.array(healthKitEntry),
@@ -38,11 +89,20 @@ const healthKitConfigResponse = z
.describe(
"When HealthKit last synced for this user; null when never (and always null on the PATCH echo).",
),
syncHealth: syncHealth
.optional()
.describe("Present on the GET read; omitted from the PATCH echo."),
metricFreshness: z
.array(metricFreshnessEntry)
.optional()
.describe(
"Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH echo.",
),
})
.meta({
id: "HealthKitConfigResponse",
description:
"The resolved HealthKit integration config: the default metric set merged with the user's stored per-metric overrides.",
"The resolved HealthKit integration config: the default metric set merged with the user's stored per-metric overrides, plus the sync-health verdict and per-metric freshness.",
});
// Mirror the route's `patchSchema` — merge-by-id; unknown ids are ignored.