8594 Commits
Author SHA1 Message Date
Marc-André Bombeck c1fb59fe39 Merge pull request #1255 from MBombeck/release/v1.43.9
Release v1.43.9
v1.43.9
2026-10-21 01:51:27 +02:00
Marc-André Bombeck bba33f1387 chore(release): v1.43.9 2026-10-21 01:23:43 +02:00
Marc-André Bombeck 4bb2096eea Merge branch 'fix/v1439-oura' into release/v1.43.9 2026-10-21 01:17:39 +02:00
Marc-André Bombeck 897fb10c5b Merge branch 'fix/v1439-jobs' into release/v1.43.9 2026-10-21 01:17:39 +02:00
Marc-André Bombeck 192b6593a4 fix(oauth): claim a signed callback only once its code can be exchanged
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.
2026-10-21 01:17:20 +02:00
Marc-André Bombeck 28c8406924 fix(ai): park a revoked Codex family and stop at a disconnect mid-refresh
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.
2026-10-21 01:16:51 +02:00
Marc-André Bombeck b997d4e673 Merge branch 'fix/v1439-jobs' into release/v1.43.9
# Conflicts:
#	src/lib/integrations/oauth-refresh.ts
2026-10-21 00:07:07 +02:00
Marc-André Bombeck 769d0334a7 fix(rollups): match the keyed refold's days in UTC whatever the session zone
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.
2026-10-21 00:06:21 +02:00
Marc-André Bombeck 1be2c27b27 fix(ai): serialize Codex refreshes so a lost race never parks a live sign-in
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.
2026-10-21 00:06:21 +02:00
Marc-André Bombeck 31e0957903 Merge branch 'fix/v1439-oura' into release/v1.43.9 2026-10-21 00:05:57 +02:00
Marc-André Bombeck 6224409678 fix(oura): request the stress scope and exchange a repeated callback once
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.
2026-10-21 00:05:22 +02:00
Marc-André Bombeck 23bc58660d test(integration): keep two cases independent of file order and random ids
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.
2026-10-21 00:03:08 +02:00
Marc-André Bombeck b4618f2ec3 Merge branch 'fix/v1439' into release/v1.43.9 2026-10-20 22:49:33 +02:00
Marc-André Bombeck 7bbd7c3811 fix(insights): read the all-time extremes from the tier only where it matches
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.
2026-10-20 22:48:24 +02:00
Marc-André Bombeck fed16a4c43 Merge branch 'fix/demo-dashboard' into release/v1.43.9 2026-10-20 22:43:38 +02:00
Marc-André Bombeck ba57c7ba97 Merge branch 'fix/v1439-jobs' into release/v1.43.9 2026-10-20 22:43:38 +02:00
Marc-André Bombeck 226942f722 Merge pull request #1254 from MBombeck/docs/readme-screenshots
docs(readme): current screenshots
2026-10-20 22:23:57 +02:00
Marc-André Bombeck 52c6c8dc9b fix(demo): keep the demo dashboard to the measurement charts
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.
2026-10-20 21:08:16 +02:00
Marc-André Bombeck 79072438de docs(readme): show the current app, the timeline and the day panel
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.
2026-10-20 21:04:05 +02:00
Marc-André Bombeck 652dd6943b test(auth): read the password hash off the stored row in proof tests
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.
2026-10-20 20:56:08 +02:00
Marc-André Bombeck 63a3e354c8 perf(boot): load route modules on first request, not all at start
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.
2026-10-20 20:46:34 +02:00
Marc-André Bombeck 7759729aca fix(proxy): refuse Server Action calls the app never makes
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.
2026-10-20 20:29:46 +02:00
Marc-André Bombeck 0534123d0d perf(integrations): read the status envelope in one round
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.
2026-10-20 20:28:21 +02:00
Marc-André Bombeck 05ae8b1e99 perf(dashboard): count all-time readings from the DAY rollups
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.
2026-10-20 20:27:32 +02:00
Marc-André Bombeck 73f2004c54 feat(observability): break the heap sample down by V8 space
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.
2026-10-20 20:27:24 +02:00
Marc-André Bombeck c6c51be280 perf(jobs): stop the geo backfill from retrying the same rows hourly
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.
2026-10-20 20:26:30 +02:00
Marc-André Bombeck 1cfae83578 perf(insights): fold the all-time extremes from the DAY rollup tier
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.
2026-10-20 20:22:19 +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 8cdb53cb0e perf(withings): write a measure sync in sets and refold only what changed
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.
2026-10-20 20:13:13 +02:00
Marc-André Bombeck 567a564913 perf(auth): resolve the caller to a narrow user projection
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.
2026-10-20 20:07:25 +02:00
Marc-André Bombeck 07d70cd275 fix(ai): park a Codex sign-in the issuer refused until the next sign-in
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.
2026-10-20 20:04:21 +02:00
Marc-André Bombeck 628f37bc62 fix(apple-health): show a written-back type's newest reading on its row
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
2026-10-20 19:47:07 +02:00
Marc-André Bombeck a0e725556b chore(deps): raise the hono floor to 4.13.11 2026-10-20 19:45:11 +02:00
Marc-André Bombeck 5502342ec5 Merge pull request #1253 from MBombeck/release/v1.43.8
Release v1.43.8
v1.43.8
2026-10-20 18:03:28 +02:00
Marc-André Bombeck f573969428 fix(ui): give unchosen segments the tab trigger's readable dark tone
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.
2026-10-20 17:37:17 +02:00
Marc-André Bombeck b0e67a8831 chore(release): v1.43.8 2026-10-20 17:19:51 +02:00
Marc-André Bombeck d1922da0e1 chore(bundle): re-measure the client budget after the shared primitives 2026-10-20 17:19:25 +02:00
Marc-André Bombeck 72ce7a0ead Merge branch 'fix/v1438-r2' into release/v1.43.8 2026-10-20 17:08:00 +02:00
Marc-André Bombeck c72b81fa66 test(api): read every published list with a row in it
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.
2026-10-20 17:02:44 +02:00
Marc-André Bombeck 6182d45354 fix(i18n): print server-built sentences in the reader's notation
The decimal lint covered components and app only, and sentences for
people are built in src/lib too: the weekday weight correlation printed
"0.8 kg" into a German sentence, and the no-key fallback and the period
narrative's fallback spelled fractions with a full stop. Those three use
the new formatDecimal from format-locale with the reader's locale. The
rule now covers src/lib and src/hooks; text for the model, operator log
lines, SVG data, zone checks and wire values a client re-formats carry a
reasoned directive.
2026-10-20 16:52:08 +02:00
Marc-André Bombeck c8649faddf test(ui): sharpen the page-title and empty-state guards
The H1 guard matched the raw text, so seven files sat on its allowlist
only for mentioning an H1 in a comment; it strips comments first now and
the entries are gone. The empty-state ratchet counted against a ceiling,
which let a new copy in wherever an old one left, and missed the no...Yet
keys: it freezes the remaining sites by file and key, recognises no...Yet
and nothing... keys, and the two it missed (the insight status card and
the screener card) render EmptyState.
2026-10-20 16:45:11 +02:00
Marc-André Bombeck 7ed953a029 fix(ui): route the hand-built segment rows through Segmented
The narrative period, the lab result type, both admin filters, the chart
range tabs, the mood chart's own copy of them, the sleep-stage window and
the re-authentication method each spelled a segmented control by hand,
with their own sizes and fills. They render Segmented now; the range tabs
keep their long names as a hover title, and the admin filters gain an
accessible group name in every language. A test refuses a new button row
whose aria-pressed compares a state to each button's value, and names
the rows that remain with why they are not one choice on one track.
2026-10-20 16:43:59 +02:00
Marc-André Bombeck 160da978e6 fix(insights): keep grid tiles on one baseline when a title wraps
Removing truncate from the grid tiles let a long title take a second
line and push its value and sparkline below the neighbours'. TileHeader
takes reserveLines={2}, a fixed two-line row with a clamp and the full
title in title, as the dashboard trend strip already does. The vitals
tiles, their building state and skeleton, the device score tiles and the
resilience tile use it, and a test holds those files to it.
2026-10-20 16:38:29 +02:00
Marc-André Bombeck 024620a7f1 fix(ui): give a wrapped card title room between its lines
CardTitle wraps but kept leading-none, so the two lines of a broken
compound touched. It is leading-tight now; a caller's own leading still
wins.
2026-10-20 16:38:29 +02:00
Marc-André Bombeck 54d215d377 chore: drop the schema modules' unused constant re-exports
The client-safe constants moved to zod-free modules and every caller
imports them from there; the re-exports from the schema modules had no
reader left.
2026-10-20 16:35:04 +02:00
Marc-André Bombeck a37d45ab3f fix(insights): take the crosstab units from the shared unit table
The cycle, mood-tag and mood-factor boards each kept a table of unit
words, and the German cycle board printed S/min where every other screen
says bpm. A board row now names the measurement it reads; MetricCrosstab
resolves the unit through measurementUnitLabel after the reader's unit
preference, and the callers use the same reading for their detail line.
A rating or score shows no unit word, as everywhere else. The boards' own
unit keys are gone from all bundles, and unit-label.test.ts refuses a
component that keeps a unit table again. The pair label wraps instead of
being cut off.
2026-10-20 16:16:13 +02:00
Marc-André Bombeck 5185edca74 fix(rate-limit): fail the shared write ceiling open and skip reads over POST
The shared write bucket threw on any SQL error, so a lock on rate_limits
answered every authenticated write with 500. It is a loop cutter, not an
access check: when the bucket cannot be read the write now goes ahead with
a warn signal. The login and other auth-surface limits call the limiter
directly and still fail closed. requireAuth now passes readOverPost to the
meter, the draft-only document assist declares it, and the three chat
routes, which meter their turns with buckets of their own, declare that
instead of being counted twice.
2026-10-20 16:09:28 +02:00
Marc-André Bombeck 408ae35b7c fix(migrations): retry a marked migration that lost a deadlock
A marked migration's DO block holds the locks it takes until it commits,
so an app transaction taking two of its tables in the other order can
deadlock with it. The block then rolled back whole, exactly as on a lock
timeout, so the boot retries 40P01 for marked migrations as it does 55P03.
Corrects 0415's note that the drops hold their lock for an instant.
2026-10-20 16:06:10 +02:00
Marc-André Bombeck 2b81b4f23e fix(migrations): retire the measurements prefix index without waiting at boot
0416 ran DROP INDEX CONCURRENTLY at boot, which waits without bound for
every transaction on the table; a pg_dump or a backfill outlasted the
start period and the next boot stopped at P3009. The migration now tries
a plain drop with a two-second lock wait and tolerates the timeout, and
the nightly measurement maintenance drops what is left concurrently under
a bounded wait, retrying the next night. A fresh or shadow database takes
the lock at once, so the history still ends where the schema does. The
guard refuses a concurrent drop in a migration and ties every retired
index to a tolerant one.
2026-10-20 16:06:10 +02:00
Marc-André Bombeck a275a5bb65 fix(records): show a deleted practitioner on the live visits that name it
The web record and the doctor report named the practitioner of a live
visit after the address-book entry was deleted; the MCP read, the search
and the appointment reminder did not. All of them now name it, the entry
stays out of the address book and the pickers, and a portable backup
carries it as a tombstone instead of dropping the visit's reference.
2026-10-20 16:01:13 +02:00