Commit Graph
11 Commits
Author SHA1 Message Date
Marc-André Bombeck e626f2123f refactor(integrations): run one write step after every provider sync
`integrations/sync-kit/after-write.ts` is the tail every provider write
ends in: the arrivals go out, the touched days are refolded, the read
caches are marked stale back to the earliest touched reading, and the
touched status cards are re-warmed (through `afterMeasurementMutation`,
shared with the interactive routes); Withings also evicts the dashboard
snapshot. A full-history backfill refolds its whole touched range once
through `afterProviderBackfill`.

Withings, WHOOP, Polar, Nightscout, Google Health, Fitbit and Oura drop
their hand-written tails, and no provider module imports the comprehensive
insight generator any more; a guard holds the provider folders to that.
Differences from the copies: the status cards are re-warmed even when the
fold failed, and the caches are marked stale back to the earliest touched
reading instead of wholesale, so older closed blocks survive a sync.

Several provider unit tests mocked an export the old tails never reached
(their failures were caught and only logged); the mocks now name what the
code calls.
2026-10-23 13:12:23 +02:00
Marc-André Bombeck b6f45c0d88 refactor(integrations): move every provider OAuth client onto one factory
`oauth-kit/client.ts` builds a provider's consent URL, code exchange,
refresh and deauthorization from a declaration of what differs: endpoints
and scope, client authentication (form or HTTP Basic, with or without the
client id beside it), the fields a grant adds, how an answer is classified
and the refusal named, the error type, and where the token sits in the
body. The seven hand-written copies of the redirect URI, authorization URL,
token post, Basic header and PKCE pair are gone; each provider keeps an
`oauth.ts` with its declaration.

Shared behaviour is now uniform: a 2xx without a token object (an empty or
non-JSON body) is a transient `empty_token_body` failure for every
provider. WHOOP, Strava and Google Health used to return null, and
Withings threw an unclassified SyntaxError; the OAuth wire golden records
exactly these lines changing and nothing else. Oura's live/legacy endpoint
fallback, Google's invalid_grant lift, Strava's Fault summary and Fitbit's
errorType are declared per provider.
2026-10-23 12:57:47 +02:00
Marc-André Bombeck ba1271275f feat(fitbit): stop at the Web API turndown and point to Google Health
Google turns the legacy Fitbit Web API off on 30 October 2026 (support
ended on 30 September). From that day the hourly poll, the backfill,
the sleep repair and the token refresh make no call, the connect, sync
and test routes refuse with `provider_retired`, and the connection
reads as not connected to the health read and the watchdog, so the
shutdown pages nobody and asks nobody to reconnect. The connection row
and everything it imported stay.

The status envelope publishes `retirement` on the Fitbit entry (the
day, whether it has come, the successor), and the Fitbit card shows
when the connection stops, how to move to Google Health (same devices,
one more consent, tokens cannot be carried over) and that the data
stays; sync and connect grey out after the day. Both integrations can
run side by side meanwhile: every type Fitbit writes is laddered with
Fitbit just above Google Health, so a day both deliver counts once.

The test setups pin the turndown off, so the Fitbit suites do not turn
red on the day; the turndown's own tests flip it.
2026-10-21 12:46:14 +02:00
Marc-André Bombeck f01f53a1cc perf(rollups): refold the buckets of every provider sync in one pass
Every provider sync and the shared mutation tail refolded its touched
(type, day) buckets one at a time: an aggregate, a transaction and three
queue sends per day. They now hand the whole set to
recomputeBucketsForKeys, which leaves the DAY tier exactly as the per-key
hook did (pinned by a parity test against it) at the cost of one
aggregate per chunk of keys and one coarse job per run of buckets.
2026-10-20 20:20:00 +02:00
Marc-André Bombeck ae8b614dd0 feat(insights): choose the status regeneration gap by ingest source
A sync from a watch or an integration now waits the instance's sync gap
(AppSettings.aiSyncRegenGapHours, else AI_SYNC_REGEN_GAP_HOURS, else 6 h)
before it may regenerate a status card again; a hand entry keeps the hour.
Every caller of afterMeasurementMutation and invalidateStatusInsightsForTypes
names its source, so a new ingest path cannot skip the choice.

Refs #1217
2026-10-15 22:49:00 +02:00
Marc-André Bombeck e34741c077 fix(google-health): write a dense collection page by page during a backfill
A full-history backfill read every heart-rate point of an account into one
array, mapped it into a second, and wrote the lot in one call; the cycle's
deferred-rollup ledger then kept one entry per written reading. On a watch
that records a reading a minute that is over a million points, and the
backfill ran a 1 GB heap out of memory within seconds, restarting the
instance it shares with the app (#1023).

The metric walk now hands each page to its write before requesting the
next, and both the Google Health and Fitbit ledgers collapse to one key per
touched type-day as they record. The heart-rate walk's page ceiling rises
from 1000 to 10000: at one point a minute the old ceiling is under two
years, a longer history always ended truncated, and the backfill retried
the whole walk on every attempt and every boot without completing.
2026-09-27 20:42:54 +02:00
Marc-André Bombeck 34f847f12f fix(integrations): anchor synced daily totals at local noon in the user's zone
Withings, Fitbit, Google Health, Polar and Oura wrote each daily total and
daily summary at noon UTC of its date. From UTC+12 on (New Zealand all year,
Fiji, Tonga, Samoa) noon UTC is already the next local day, so every synced
step count, SpO2 or resting-pulse day showed up a day late. The rows now sit
at local noon of their date in the user's zone, and the fetch windows run
through the user's own today.

Fitbit weigh-ins carry a local wall clock without an offset and were read
as UTC; they are read in the user's zone now.

ExternalIds stay on the calendar date, so the next sync finds each row and
moves it in place instead of adding a second one, and the day a row leaves
is refolded. Rows inside each integration's re-read window heal on the next
sync; Fitbit and Google Health re-read the whole history on reconnect.
2026-09-26 15:58:02 +02:00
Marc-André Bombeck 2941d56238 fix(sync): enforce the plausibility domain on every provider write path
Every interactive path, every import, MCP and Telegram checked an incoming
measurement against VALUE_RANGES before storing it. The provider sync writers
did not, so a decode slip or a glitched sensor landed a row the application
itself calls impossible, and every mean, median and personal band derived from
it inherited the impossibility. Filtering the read side hid the row. It kept
landing.

The gate is one module reading the domain the app already declares, applied at
two kinds of seam. reconcileExternalMeasurement is the shared identity writer
for Polar, Oura, WHOOP, the Withings sleep leg, the Apple export importer and
the batch endpoint, so gating there means a provider added later inherits the
refusal rather than having to remember a check it never knew about; the new
rejected_range verdict carries no row, which is what makes the compiler force
every caller to handle it. The five writers that hold Prisma themselves gate at
their entry, before the existence probe decides insert versus overwrite, so an
impossible reading can no longer replace a good stored value.

An out-of-band value is dropped. Not clamped, because a clamp invents a reading
nobody took. Not stored behind a flag, because a quarantined row is still a row
and every consumer would have to learn the flag. Not fatal to the batch, because
one bad sample must not strand the run's other readings behind a held watermark.
Absence reads as absence.

A silent drop, though, is indistinguishable from a provider that sent nothing,
so every drop is tallied on the wide event under measurement.range_rejected with
the metric, the provider and the edge it broke. Counts only: the reading itself
is health data and never enters the log line, and the direction plus the
metric's own declared bound is what a diagnosis actually needs. The tally
accumulates rather than replacing, because a provider gates once per resource
leg and the reconciler once per row. One warning per provider lifts the event to
warn so a drop does not hide inside a green line.

A structural guard enumerates all twenty measurement writers with the reason
each is allowed to write, so a new one has to answer the question before it
ships. scripts/diagnose-implausible-measurements.ts reports the rows already
stored, aggregated per metric and source with no value beyond the bucket's own
extremes. It repairs nothing: whether a stored impossible row is a decode slip
or a band that is too narrow is not a call a script gets to make.
2026-08-07 22:19:43 +02:00
Marc-André Bombeck ce41d5bf17 fix(fitbit): key sleep segments on their start instant and sweep re-score leftovers
Fitbit re-scores a night after the fact, and the per-segment externalId carried
a positional index plus the stage label. Both moved on a re-score: renumbered
segments minted fresh keys, the previous scoring's rows stayed live because
nothing swept them, and the night total counted both copies from then on.

Key the segment on the stable session anchor plus its own start instant
(`<logId>:sleep:<segment-start>`), and add the replace-by-window sweep every
other segment-writing provider already has. The two ship together with the
natural-key rescue that has been sitting in the Fitbit upsert unused: the sweep
tombstones the old-keyed rows first, so the rescue re-keys those very rows in
place instead of inserting a parallel copy or losing the insert to
`skipDuplicates` against the natural unique index.

A one-shot repair queue re-reads the bounded sleep history once per connection
so no user has to do anything; it stamps `sleepRepairedAt` (migration 0270) only
on a clean run, never touches the sync watermark, and reports the rows it
removed. Nights older than the 365-day backfill horizon keep their old keys and
whatever duplicates they already hold — they can no longer gain new ones.

Alongside: `fitbitGet` now honours a `Retry-After` inside a 120 s cap by waiting
once and retrying once, and fails fast above it so a long hold cannot starve the
rest of the poll cohort or outlive a job's expiration. The measurement upsert
skips a write whose payload is unchanged, which takes the hourly overlap off the
delta feed for every Fitbit resource.
2026-07-25 06:15:37 +02:00
Marc-André Bombeck 25b7ada8bf fix(fitbit): hold the sync verdict when a token is dead or a write fails
A revoked or expired Fitbit refresh token used to fail the cycle's status
ledger and then stamp the same cycle a success: the connection flipped back
to connected, the watermark advanced, and after a reconnect the 24h overlap
could no longer reach the missed days. getValidToken's three null-return
paths (credentials missing, refresh failure, vanished connection) now register
on the per-cycle hard-fail ledger, so the cycle's verdict fails, the park at
error_reauth holds, and lastSyncedAt does not move. The two measurement write
catches do the same, so fetched-but-unwritten rows are re-fetched next tick
instead of aging out of the overlap.

syncUserFitbit now returns { imported, failed }; the manual-sync route, the
poll cohort adapter, and the backfill consume the new shape. The backfill
stamps backfillCompletedAt only after a clean full-history walk: a parked
connection returns without stamping, and a failed verdict throws so pg-boss
retries on the already-configured retry budget.
2026-07-24 12:47:27 +02:00
Marc-André Bombeck 62eb431d66 release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0

* test: stabilize water capture re-entry
2026-07-22 04:14:15 +02:00