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

+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.