Sleep v2 Get answers only the first 24 hours after its startdate. The
sync asked for the whole 30-day window, so it got the night a month
back and nothing since: every night's stages arrived when the sliding
window reached it, weeks late.
The summary now runs first and lists the nights; each night's stages
are read over its own span (at most 24 hours). Every run re-reads the
nights that ended, or that Withings changed, in the last 48 hours, and
fills older nights that hold no stage row yet, eight per run with a
rotation so a night without stages cannot hold up the rest; a full
sync reads them all. Without the night list the last 24 hours are read
as one slice and nothing is swept, since a slice can cut a night.
A summary failure used to be a warning beside a green leg. It is now
the sleep leg's failure, recorded after the stages land.
A 429 in the middle of the activity walk threw out of it before the
upsert, so everything the run had read was discarded and the next run
started over. The walk now ends where the limit hit: what was read
lands, the cursor moves only as far as an oldest-first walk proves,
the failure is booked transient, and the run reports the wait Strava's
X-RateLimit / X-ReadRateLimit headers name (next quarter hour, or
midnight UTC once the daily count is spent). The backfill waits that
long before its next run. `walkPages` learns an `interrupted` page for
this, reported truncated like a walk cut off at its ceiling.
An update mapped from the summary alone (detail budget spent, or the
detail read failed) wrote a null over the calories an earlier detail
read had stored, and over its heart rate and description too. Those
detail-only fields are now kept unless this run read the detail.
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.
The workouts leg recorded its failures under its own leg but left the
success to the vitals leg. A leg-scoped success clears only its own
leg, so one workouts failure stayed on the row for good and kept the
connection in error however healthy the exercises read became. Each
leg now records its own success.
AccessLink documents its 403 as missing consent for that data. A 403
on one collection while a sibling succeeded with the same token was
classified reauth_required and put a working connection behind a
reconnect prompt no reconnect clears. Like Oura's scope refusals, such
a collection (or the exercises read, once the vitals leg proved the
token) is now noted on the wide event and not counted as a failure. A
403 on every collection, or any 401, stays one.
HRV_RMSSD was read nowhere HRV is analysed: the correlation scan and the
illness engine only knew the SDNN series, so a strap or ring wearer's
HRV never took part in either. RMSSD is now its own outcome channel
(owned by the recovery module, like the summary tile) and its own
illness scan type with a drop read as adverse. It is not merged into
the SDNN channel: the estimators differ in scale, and one series of
both would correlate the change of device rather than the body.
Fitbit's `dailyRmssd` and Google Health's daily HRV average are both
RMSSD, but the mappers wrote them into HEART_RATE_VARIABILITY, the SDNN
series. The `hrv` ladder ranks both above Apple Health, so an account
with an Apple Watch and a Fitbit had a series that switched estimator
from day to day. The mappers now write HRV_RMSSD, and the `hrvRmssd`
ladder carries both sources where they sat on the SDNN one.
Migration 0420 moves the rows already written (tombstones included,
with a sync-version bump so the change reaches the phone), drops their
SDNN rollup buckets and the RMSSD buckets of every account whose rows
moved, so the boot-time rollup discovery folds those accounts again,
and deletes the SDNN personal records those rows had set. One DO
block, so the move and its cleanup land together.
The v2 recovery webhook carries the id of the associated sleep, and a
recovery's rows are keyed off that same sleep id. The delete branch
matched on the `<id>:` prefix, so a `recovery.deleted` event also
soft-deleted every sleep row of that night, and a `sleep.deleted`
event took the recovery with it. Recovery now names its exact ids
(pinned against the mapper's field tags); sleep keeps the prefix minus
those ids.
The targeted refresh for `recovery.updated` passed the same sleep UUID
into `/v2/cycle/{cycleId}/recovery`, which answered 404 and booked a
sync failure on every recovery webhook. It now reads the sleep for its
`cycle_id` first.
A faded primary fill reads about 2:1 in light mode, so a disabled chosen
segment swaps to the token pair Button uses instead of the opacity. The
small range rows keep 44 px under a mouse as well, as the chart tabs had,
and the changelog names the fill by what it is.
The shared control introduced a muted track with the chosen segment
raised on the background colour. In a chart header it read heavier than
the card around it, larger type and wider segments on a grey bar, and it
dropped the primary fill the range tabs had carried.
Segmented now draws the chart tabs' former look everywhere: no track, the
chosen segment filled in the primary colour, the others plain until
hovered. A small size keeps the range rows in chart headers at the former
type and padding; the touch floor of 44 px on a coarse pointer stays.
The signed-state callbacks (Oura, Polar, Strava) claimed the state before
the session, code and issuer checks. A denied consent comes back without a
code, so it spent the claim on a `nocode` error page, and allowing on the
same authorize URL afterwards never exchanged the valid code: the browser
was sent to the stored error page instead. Every check that refuses a
callback without touching the code now runs first; the claim still sits
strictly before the exchange.
Expired claims were swept only when a callback completed, so a quiet
instance kept its last rows. The daily OAuth-state cleanup at 03:20 now
sweeps them as well.
Under the refresh lock, with the row still holding the token just spent,
no peer rotated it, so refresh_token_reused is the issuer refusing the
whole family: it now parks the sign-in instead of being asked again
after every cooldown. Outside the lock it stays a lost race. A refresh
whose compare-and-set write finds the connection gone (the person
disconnected Codex meanwhile) now refuses with reconnect-required
instead of using the rotated token once more.
The keyed refold's day filter and delete compared the naive UTC
measured_at and bucket_start with a timestamptz[] parameter, which goes
through the session time zone; in any zone but UTC neither matched and
the refold left its buckets stale. Both now bind timestamp[] literals.
Keys are refolded in (type, day) order in chunks of 500, and the
rollup-recompute queue describes the runs of buckets a job now covers.
Two callers refreshing one Codex token raced: the loser heard
refresh_token_reused, read it as a dead grant and parked the connection
before the winner wrote its rotated tokens, leaving fresh tokens beside
an expired status. Refreshes now share one flight per process and queue
on the per-credential advisory lock across processes, re-read the row
inside it, use a token a peer already rotated, write the rotated tokens
compare-and-set on the spent one together with connected, and re-read
after a refusal before parking. refresh_token_reused no longer counts as
a dead grant, and neither does a bare 403 without an OAuth error code:
an edge challenge answers a valid grant so, and it keeps the ledger's
timed cooldown instead of parking until the next sign-in.
Resilience sits behind Oura's `stress` scope, not `heart_health`, so a
connection that granted every scope HealthLog asked for still answered 401
on `daily_resilience`. `src/lib/oura/scope.ts` now maps every collection the
sync reads to the scope the live API enforces and is the one source for the
authorize request, the per-collection permission detection, the reconnect
hint and the settings card. The request asks for `stress` as well.
Oura names a missing scope in its 401 body ("Token is not authorized access
stress scope."), while a dead token reads differently. A collection refusal
now carries that scope: it spends no token refresh, never counts toward the
reconnect threshold and marks only the collections it names. A 403 (a lapsed
membership) is a persistent failure rather than a reconnect. The callback
also records what the token response says was granted, so an unticked
permission shows on the card before the first sync.
A browser can deliver an OAuth callback twice with the same code. Both
requests exchanged it, and Oura answers a reused code by revoking the token
the first exchange issued. Every connect callback (Oura, Polar, Strava,
WHOOP, Withings, Fitbit, Google Health) now claims its state in
`oauth_callback_claims` before the exchange: one request exchanges, the
duplicate waits for it and redirects to the same page without touching any
token. The ledger flows claim inside the transaction that consumes their
state row. A failed exchange settles with its error page, and a new connect
mints a new state.
Oura token refreshes are serialised per user with an advisory lock, so
overlapping syncs no longer spend the same single-use refresh token, and the
write stays conditional so a reconnect's new grant is never overwritten by a
refresh of the old one.
The backup schedule case assumed the shared database had no queue schema,
but files that drive the queue create it and one leaves a finished backup
run behind, so the case failed whenever it ran after them. It now clears
that queue's history first. The delegate refusal case scanned the raw
response for a digit run that the random request id can contain; the id
is taken out before the scan.
The DAY tier is not recomputed on every path that removes readings: a
hard delete with no rollup recompute leaves a bucket counting readings
that are gone, and the extremes then repeated it. Those figures are what
the Coach snapshot checks its coarse history against, so they must stay
true to the live record.
A type is now folded from the tier only when the tier accounts for the
live record day by day: the same days, and per day the same reading
count. The live side is a count per day served by an index-only scan of
the live index, so the check reads no measurement rows. A type that
fails it is read raw, as before.
The shared demo account carries onboarding answers, so the setup
checklist stayed on its dashboard for every visitor although none of its
rows can be done there. Under DEMO_MODE the dashboard leaves it out, as
the shell already does with the tour.
The demo seed hides the mood and medication cards, achievements, recent
workouts and check-ups, opens weight, BMI and blood pressure on 90 days,
and resting heart rate on 30 with its 7-day trend and trend line.
Replace the June dashboard and the old gallery with current screens: the
dashboard, the timeline, the day panel, resting heart rate, medications,
labs and the coach. The resting heart rate view stays and is extended to
the same 16:10 frame as the others.
The proof gates now read whether an account has a password from its own
row rather than from the per-request user, so the suites that drive
them mock that read instead of carrying the hash on the session user.
Next preloads every route and page module right after the server
starts, about 670 here. The load runs on the event loop, so every
container start showed one stall of several seconds, the first
requests waited behind it, and the heap stood at about 430 MB before
the first sign-in, most of it code for routes an instance rarely
serves. With preloadEntriesOnStart off, each module loads on its
route's first request. The same image started locally at 185 MB of
heap instead of 428, with no stall, and a route's first request paid
30 to 60 ms once.
HealthLog defines no Server Actions, so no bundle of it, current or
stale, sends a Next-Action header. The requests that do arrive come from
the nightly scan probing that surface, and Next answered each with an
unstructured "Failed to find Server Action" at error level. The proxy
now refuses them with a 404 and one structured warn line, and a guard
keeps "use server" out of the tree so the refusal stays safe.
The provider rows, the unified connection health and the per-metric
freshness were awaited one after another although none depends on the
others, so a cold first visit to the settings card paid their latencies
end to end. They now run in one round.
The summary's all-time aggregate grouped every live reading of the
account by type: one index entry per reading, millions on an account
with a watch, two to four seconds whenever they were not in memory and
the slowest read of the request on every cache miss. The count now sums
the DAY buckets that already hold a per-day count of every source,
counts live only the readings before the fold window and any type not
folded yet, and takes lastSeenAt from the newest reading the
latest-reading read already returns. Parity with the live count is
pinned against a real Postgres.
The heap sample named old_space alone, so a heap that stood well above
it could not be explained from the log: a buffer past V8's large-object
threshold lives in large_object_space, compiled code and bytecode in the
code and trusted spaces. The sampled and pressure lines now carry the
used size of every space, with any space the runtime reports that is
not named summed into other_spaces_mb, so the fields account for the
whole used heap whatever the Node version.
An audit row whose address no database can place stayed without a
location, so every hourly pass looked it up again for its whole 30-day
window, and finding it walked every audit row of those 30 days through
the created_at index.
Migration 0418 adds audit_logs.geo_attempted_at and a partial index on
the rows still missing a location or carrier. Every row a pass looks at
is stamped, and a row tried less than seven days ago is not picked
again, which still lets a range a later GeoLite2 release learns resolve.
The candidate read goes through the partial index, which holds only the
backlog.
readAllTimeExtremes read every raw reading of weight, blood pressure and
pulse twice on each call, a sequential pass over the measurements table
that made it the largest read on the database. The DAY tier already
holds the same per-source day fold except for the extremes of the
readings themselves: min_value and max_value widen to a row's own
intra-hour range.
Migration 0417 adds reading_min_value and reading_max_value to
measurement_rollups. The writer fills them from the same aggregate, and
the boot re-fold refills existing rows through the arm that already
watches sum_xy. The reader folds the tier for whole UTC days inside the
fold window and the raw rows for the rest: older days, the partial day
a lookback floor falls in, and any type whose tier rows lack the new
columns. The raw arm reads narrow columns once instead of twice.
An integration test compares the result with the previous query on a
seeded history with two sources per day, hourly pulse statistics with
their own range, readings older than the window, midnight edges and
deletions, and shows the tier is the path that answers.
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.
A manual full sync re-reads the whole history, and nearly all of it is
already stored. Each reading cost a findFirst plus an update, and every
re-read day was marked dirty, so the run refolded the DAY tier and queued
three coarse recomputes for every day of the account: about thirty
seconds of database work behind 1.4 s of Withings calls.
The measures leg now reads the stored rows of the window once, skips the
unchanged ones, updates the changed or tombstoned ones and inserts the
new ones in batches. Only inserted or changed readings count as touched.
recomputeBucketsForKeys refolds exactly the touched DAY buckets in one
aggregate and one transaction, and queues one coarse job per run of
adjacent buckets instead of three per day.
The session lookup and the Bearer resolver loaded the whole users row on
every authenticated request, including the profile picture bytes, the
cached briefing text and every stored integration credential. Both now
select SESSION_USER_SELECT, a named allowlist of the small columns the
request path reads, and so does the owner load for a switched record.
The few routes that need a credential or a large column read it
themselves at the moment of use: the factor verifiers load the password
hash and TOTP secret, the profile response loads the insurance number,
and the snapshot read loads the cached briefing text. A structural guard
holds the lookups to the projection and keeps every Bytes and
secret-shaped column out of it.
A dead refresh token was asked again on every hop of every run: the
proactive refresh failed and was swallowed, the request 401'd, the on-401
refresh failed again, and the nightly warm repeated that for the briefing
and every card. A refusal that proves the grant dead now flips the
connection to the existing expired state (compare-and-set on the refused
token, one signal per transition), so the chain, the presence probe and
the AI capabilities stop counting it. A new device-code sign-in clears the
refused credential's ledger rows so the fresh one resumes in place.
A reading entered here goes to Apple Health as well, and the quiet
verdict already counted it, but the row still showed the newest reading
that arrived from the phone, so a type entered two minutes ago read one
day old. The server now resolves lastSeenAt to the newer of the two for
the types the app writes back, and the row and the verdict agree.
Refs #1247
The muted foreground on the muted track reads 3.6:1 in the dark theme,
below WCAG AA. Tabs already solved this with the tab-inactive token;
Segmented now uses the same treatment, so both controls look alike.
The GET contract run counted 40 operations whose bodies held only empty
arrays, and an empty array parses against any item schema. The seed now
links the visit, lab, condition, document and dose to each other, adds a
procedure, a second and third lab reading, nutrients, a category, the
list order, courses, a rescheduled medication, a history fact, Coach
preferences, a plan and a fact, a reminder event, an invite, a delegate's
visit, a sign-in, a personal record, a trusted device, a marker alias, a
symptom on a cycle and an illness day, part-way import jobs and a chat
about the document. The run requires every operation to answer at least
once with a filled list, apart from eleven named exceptions, and a floor
of filled operations. The first finding is fixed: the document chat list
returns fenced, attachments and documentTitle, now declared.