Commit Graph
811 Commits
Author SHA1 Message Date
Marc-André Bombeck b045703493 feat(apple-health): ingest the activity rings, mindfulness, State of Mind and AFib burden
Six Apple Health signals leave the deferred list and ride
POST /api/measurements/batch and the export.zip import end to end:
EXERCISE_MINUTES, STAND_MINUTES and STAND_HOURS (the exercise and stand
rings), MINDFUL_MINUTES, STATE_OF_MIND_VALENCE (iOS 18 State of Mind,
-1..1) and AFIB_BURDEN (AFib History, percent of the week). The ring
types and mindful minutes are cumulative, so the daily rollup is their
sum; AFib burden is scaled strictly from Apple's fraction and a value
above 1 is refused rather than guessed at. The archive import takes
mindful minutes from the session's dates and skips idle stand hours.
Every registry carries them: units, ranges, labels in all locales,
categories, chart tokens, report catalogue (mindful minutes and the
valence fenced as sensitive), signal registry with FHIR codings, and
MCP exposure for the three ring types only.

Workout GPS tracks are now an opt-in, off by default
(PATCH /api/auth/me/workout-routes, a switch on Data & Privacy). While
it is off, the workout batch route and the export import keep every
workout and drop its track with the same per-entry status. With it on,
the import reads the referenced GPX files from the archive one bounded
member at a time, thins each to the point cap and stores it sealed.
The climb a track carries is written to Workout.elevationM at write
time, so the workout insight no longer opens the track, and the AI
egress guard now freezes the set of files that read route geometry.
2026-10-21 20:14:20 +02:00
Marc-André Bombeck d44af06467 docs: publish the UI standards in docs/contributing
The binding UI rulebook (card anatomy, spacing scale, text colour,
tokens, primitive mapping, enforcement) lived outside the repository,
so a contributor could follow a code comment to a file they could not
open. It now lives at docs/contributing/ui-standards.md, every comment
that cited it points there, and the docs index and ui-guidelines.md
say which document wins when they disagree.
2026-10-21 19:37:22 +02:00
Marc-André Bombeck 9941db1549 refactor(insights): drop the readable cached-text column
Every write has gone to insights_cached_text_encrypted since 0411, and
the boot-time free-text backfill sealed the rows written before it.
Migration 0430 drops insights_cached_text and the backfill index that
served it. A host that skipped the intervening releases loses only a
cache of model output; the next warm writes a fresh, sealed payload.

The cipher module now reads and writes the sealed column only, the
backfill job loses its insights step, and consent withdrawal, the wipe
plan, the backup plan and the session projection follow the schema.
Backups never carried the column, and restore ignores unknown account
settings; a round-trip test now proves an old backup naming the field
still restores cleanly. The access guard pins exact counts for where
the sealed name may appear. Fixtures, the demo seed and the demo
migration script seed the sealed column instead of plaintext.
2026-10-21 19:24:21 +02:00
Marc-André Bombeck 8d16799479 Merge branch 'fix/v14311-visits' into release/v1.43.11 2026-10-21 14:13:52 +02:00
Marc-André Bombeck eabfeaef08 Merge branch 'fix/v14311-settings' into release/v1.43.11
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/nl.json
#	messages/pl.json
2026-10-21 14:13:51 +02:00
Marc-André Bombeck eaaff23858 Merge branch 'fix/v14311-integ' into release/v1.43.11 2026-10-21 14:13:37 +02:00
Marc-André Bombeck 6037ebd3d6 fix(settings): let a saved AI API key be removed
Every personal provider form showed a stored key and offered no way to
take it back, since saving with an empty key field keeps the stored one.
The OpenAI, Anthropic, local model and gateway forms now carry a
confirmed "Remove API key" while a key is stored. It sends the existing
PATCH /api/user/ai-provider with that one key as null, which clears the
column and leaves the provider choice, model and the other keys alone.
2026-10-21 12:36:14 +02:00
Marc-André Bombeck 6a9543b4f8 fix(settings): confirm before ending a grant from the sessions card
After sign-out-everywhere the sessions card lists the access grants it
left standing, each with an end button that revoked on the tap. It is the
same act the access card confirms, permanent for the other account, so it
now asks the same question with the same disclosure, audit window
included, and the row names the account the way the access card does.
2026-10-21 12:36:14 +02:00
Marc-André Bombeck 0dcafeeb70 fix(visits): open an upcoming appointment where it lives
Visits are the second tab of `/checkups`, and the bare path opens the
first one, the preventive-care list, which never shows an appointment.
Every surface that listed an upcoming appointment linked it there: the
dashboard's next-appointment line, the Today rail item, the Today
overview fact and the appointment's push notification. A tap on the
appointment landed on the list it is not on.

Each now opens the visit itself (`/checkups?visit=<id>`), or the visits
tab (`/checkups?view=visits`) for a line naming several appointments.
The checkups page reads `view` to pick a tab. The rail item stops
borrowing the check-up item's action: it carries its own label
("View appointment") and intent `visit.view`. The appointment push names
its visit through the producer's `metadata.url` override; a check-up's
push keeps the checkups list.

The same mis-route sat on three links that lead to visits without
being upcoming: a condition's visit rows, the address book's back link
and the onboarding and timeline prompts to add a visit.

`visit-link-guard.test.ts` keeps the visit paths in the builder and
counts every bare `/checkups` per file, each with the reason it names
the checkups list.
2026-10-21 12:22:36 +02:00
Marc-André Bombeck a66c54a1f1 feat(insights): scan the nightly RMSSD series for correlations and illness
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.
2026-10-21 12:13:39 +02:00
Marc-André Bombeck 9a8b95cfd0 fix(ui): let ConfirmButton's dialog wear the destructive style
ConfirmButton took a `destructive` prop and dropped it on the way to its
dialog, so every delete, revoke and remove built on it confirmed with a
neutral primary button while ConfirmDialog confirmed the same kind of act
in red. The prop is forwarded now, and the call sites whose act takes
something away pass it. A guard holds every confirmation whose slot names
a destroying act to the destructive style; resets stay neutral.
2026-10-21 12:02:13 +02:00
Marc-André Bombeck 79d905f089 fix(labs): reserve and re-check each page of a scan, and read digits from the text layer
The route reserved one read's budget and checked the wire once, and a
20-page report then ran up to 40 calls for many minutes on that single
admission. Every read after the first now reserves its own share of the
day's budget and asks the wire again (aiEgressRefusal) before it is sent;
when either refuses, the scan stops there, keeps the pages already read
and names the rest with stoppedBy budget or unavailable. The run settles
against everything it reserved, with the provider's reported usage net of
its cached share, and a scan that fails after several calls is charged
for them instead of refunded in full.

A rendered PDF page now goes out with its own text layer, read by pdfjs
beside the image, so a digitally produced report's digits, decimal
separators and units come from its characters rather than from a 1600 px
picture. Splitting the PDF into single-page documents would need a PDF
writer the project does not ship; the text layer needs none and helps
every provider. A scanned page has no text layer and reads as before.

Codex's cut reply keeps the text it streamed (on a non-enumerable
property, so no log or event serialises it), and its closed rows are
salvaged like every other provider's. The merge folds a reading only into
one an earlier page listed, never two rows of the same page, and the
printed range is part of the key. OCR'd text the deadline cannot split
small enough for one read is refused up front as labs.ocr.tooLarge.
2026-10-21 11:10:02 +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 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 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 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 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 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 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 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 ec2c067e60 fix(retention): keep a tombstone while a live record still points at it
The nightly purge nulled every non-cascading key into a purged row, so a
live visit lost the practitioner it was with, a live dose its visit, a
symptom its condition. Each purge entry now lists the keys that do not
cascade, and a tombstone stays while a live row holds one. The register
test derives those keys from the schema's relations, so a new nulling key
into a purged model fails until it is listed.
2026-10-20 16:01:13 +02:00
Marc-André Bombeck 4a0db28713 Merge branch 'fix/v1438-api' into release/v1.43.8 2026-10-20 15:39:39 +02:00
Marc-André Bombeck a934e948d8 fix(units): show every measurement unit in the reader's language
The vitals tiles printed the canonical storage token beside the value, so
a German screen read "15,4 breaths/min". The list view had a private map
for a few count types; every other path passed the token through.

useUnitDisplay().unitFor now resolves the token through one module
(lib/measurements/unit-label): word tokens map to bundle keys, "celsius"
to its symbol, kind markers (score, rating, count, event, level, ratio) to
nothing. The list, the day view and the vitals tiles all read it. A guard
walks every measurement type and every display source and refuses a unit
that is neither a shared symbol nor resolved through the bundle.
2026-10-20 15:07:11 +02:00
Marc-André Bombeck 534523c462 test(guards): let the deprecation registry name the route it retires 2026-10-20 14:13:44 +02:00
Marc-André Bombeck 1d9758e3e8 Merge branch 'fix/v1438-db' into release/v1.43.8 2026-10-20 14:09:44 +02:00
Marc-André Bombeck c402e23656 fix(mcp): say when a tool's list was cut
get_ecg_recordings stopped at 200, get_visits at 50, the booked
appointments in get_preventive_care at 25, and search at its per-source
scan, all without a word, so a model read the last row as the oldest there
is. capped() reads through listPage with the same semantics as the REST
list cap: one row past the limit, truncated true only when that row exists.

The ECG and visit results carry truncated and limit, the preventive-care
result carries appointmentsTruncated and appointmentsLimit, and search
carries truncated with the sources that were cut, on every page. Every
capped read orders by its id as a tiebreaker; list-page-guard now checks
capped() reads too, and accepts a distinct read ordered by its own key.
2026-10-20 14:06:43 +02:00
Marc-André Bombeck d0677adac9 perf(bundle): keep zod out of the documents and Coach client routes
Both routes reached the schema library through a constant: the list of
document kinds, the list and bulk limits, the attachment cap, the Coach
data clusters and preference defaults, the conversation title length,
the scope source list and the report leaf order. Each sat in a schema
module, so importing the number shipped the library. The constants now
live in zod-free modules (lib/documents/inbound-constants,
lib/ai/coach/prefs-constants, lib/ai/coach/scope-sources,
lib/report-selection/catalogue) and the schema modules re-export them
for server code; the Coach page checks its scope parameter against the
closed list instead of parsing it. A guard walks each route's static
client graph with the TypeScript parser and fails on a zod import.

Two guards follow earlier commits in this branch: the fetch-limit
allowlist names the shared document picker sheet, and the card density
test states a floor on the cards it finds.
2026-10-20 14:04:06 +02:00
Marc-André Bombeck 39d23dbb7d fix(api): meter every authenticated write where the caller is resolved
169 of 364 mutating handlers met no rate limit, every PATCH and DELETE on
an [id] among them. The shared per-account write bucket (300 a minute,
keyed on the acting account) was called by hand in a dozen creates; it is
now charged by the identity resolvers for any POST, PUT, PATCH or DELETE,
once per request, so a route is metered by resolving its caller.

The bulk ingest routes the native client syncs through in bursts
(measurements, workouts, intake, mood and cycle bulk, nutrients, the ECG
sweep) declare writeBucket: "own" and keep their dedicated ceilings, so a
large HealthKit backfill cannot lock the person out of logging a dose. A
read-over-POST stays free.

write-rate-limit-guard fails on any mutating route that neither resolves
its caller through a metering resolver nor calls a limiter, unless it is
on a short reasoned exception list; an opt-out must be on its own reviewed
list and call its own limiter.
2026-10-20 13:53:54 +02:00
Marc-André Bombeck 3e9594b3b8 test(guards): read whole calls in the Bearer scope guard
The guard's matchers stopped at the first closing parenthesis and knew one
spelling of a Bearer lookup. requireRecordAuth(need(), "x", { scope: X })
read as naming no scope, a findFirst or findUniqueOrThrow by tokenHash was
invisible, and the /mcp route sat on the allowlist only because a comment
named the resolver.

Calls are now parsed whole, comments stripped, with the first requireAuth
argument read as the scope and any later options ignored. A Bearer
resolution is the shared resolver, the MCP edge's wrapper, any apiToken
find whose arguments name tokenHash, or a raw SELECT by token_hash. The
stricter matcher found one resolver the guard never saw: the local stdio
MCP entrypoint, now on the allowlist with its reason. Planted cases prove
each matcher fires.
2026-10-20 13:53:54 +02:00
Marc-André Bombeck 3bae93256d chore(settings): retire the unused IntegrationStatusPill
Three status pills paint a connection state: HealthBadge on the shared
integration card, the notification channel badge, and
IntegrationStatusPill. The first two describe different state models
and stay. The third had no production caller since the integration
cards moved to HealthBadge; it goes with its test and the nine strings
only it used. formatRelative, which two cards still import, moves to its
own module.
2026-10-20 13:53:51 +02:00
Marc-André Bombeck 6885377f35 fix(ui): four hand-rolled page titles go through PageHeader
The insights sub-page shell (text-xl sm:text-2xl semibold), the
medication detail, the check-in screen and its result (semibold) each
read a step lighter than every other page title. They now render
through PageHeader, which gains a focusable heading with a visible ring
for screens that move focus to their title, and an accessory slot
beside the H1 that stays outside its accessible name. A structural test
lists where a different H1 is the point (heroes, sign-in, error and
prose pages, the first-run steps) and refuses any other.
2026-10-20 13:40:40 +02:00
Marc-André Bombeck e7ee416e19 refactor(ui): move 62 hand-written empty states onto EmptyState
The admin sections, the cycle, illness, medication, mood, records and
settings cards, the pickers and dialogs said nothing-here-yet in a
hand-written muted paragraph in five sizes and three alignments. List
and section states now render through EmptyState (plain, compact; the
three with a next step carry it as the action), and the six chart
bodies through ChartEmptyState at their former height. The thirteen
that stay are a field without a value or a hint inside an editor, not
a list state. The ratchet ceiling drops from 75 to 13.
2026-10-20 13:31:02 +02:00
Marc-André Bombeck 8db7c71b0e fix(api): one helper for list pages, exact cut signal, total order
listPage (src/lib/http/list-query.ts) reads one row past the limit and
cuts it back, on top of the existing list-cap signal rather than beside
it. Every capped list now gets its page there, and every read passed to
it orders by something ending in the row id, so rows that share a sort
value cannot repeat or vanish at a page boundary.

- practitioners, vaccinations and encounters were cut at their take
  with no signal; they now answer meta.truncated and accept offset, and
  their web readers walk the pages instead of stopping at the first
- symptoms/events reported hasMore on a page that was exactly full; it
  is now true only when a row past the page exists
- the document list's hand-rolled limit + 1 goes through the helper
- security-activity answers an out-of-range limit with the 422 its
  published schema already promised instead of clamping it

list-page-guard binds every apiListSuccess route to listPage, forbids
hand-cut lists and length-derived hasMore, and requires the id
tiebreaker in every listPage read.
2026-10-20 13:29:14 +02:00
Marc-André Bombeck fe021c0ffd test(ui): freeze the hand-written empty states with a ratchet
Seventy-five surfaces say nothing-here-yet in a hand-written muted
paragraph instead of EmptyState. The ratchet counts them, fails on a new
copy, and fails when a site moves over until its ceiling is lowered.
2026-10-20 13:28:09 +02:00
Marc-André Bombeck ee2bc8019c feat(ui): ScorePill for every grade chip
Confidence levels, the weekly score gain, target bands and lab range
flags were drawn by hand in two looks: a /15 wash without border in the
mood boards, a /10 wash with a /40 border on the correlation and trend
cards, and medium confidence was blue on one card and amber on the
next. They now render through one ScorePill (rounded, xs, soft wash,
tone success / info / warning / destructive / neutral, optional tabular
figures) with one confidence scale. The target band keeps its own
AA-tuned tokens in the shared anatomy. A structural test refuses a new
hand-rolled grade chip.
2026-10-20 13:24:00 +02:00
Marc-André Bombeck 03aec3ec12 perf(db): index every acting foreign key and drop prefix indexes
Twenty-one foreign keys with ON DELETE CASCADE or SET NULL had no index
leading with their column, so deleting a parent scanned the child table
once per row; medication_intake_events.medication_id was the one an
account delete paid for. Seventeen small-table indexes and the measurements
(user_id, type, measured_at) index repeated the leading columns of another
index and only cost writes.

Migration 0415 adds and drops the small-table ones in one DO block under a
bounded lock wait; 0416 drops the measurements index concurrently, in a
file of its own. An integration test holds the schema to both rules from
the catalog, each with a counter-check, and compares the plans of the
user-and-type query shapes on a seeded table with and without the dropped
index. The lock-timeout guard now also requires a CONCURRENTLY statement
to stand alone in its file.
2026-10-20 13:07:24 +02:00
Marc-André Bombeck 60be63bcd1 feat(ui): InlineAction for small text actions, with a 44 px touch floor
Thirty-three hand-rolled underlined text links and buttons (Open
schedule, Select all, View all, Retry, Learn more, the device-code copy
action, ...) carried three different focus rings, none on half of them,
and a 16 px target on a phone. They now render through one InlineAction
primitive: tone primary / muted / foreground, size xs / sm, a focus ring,
and min-h-11 on a coarse pointer so the desktop layout does not move. A
structural test refuses a new hand-rolled copy.
2026-10-20 13:03:15 +02:00
Marc-André Bombeck 41de0ca126 refactor(medications): drop the compliance rollup table that nothing reads
medication_compliance_rollups lost its last reader when compliance moved
to the schedule-anchored computation, yet every dose write still upserted
it, a boot backfill refolded it, and the wipe and backup plans carried it.

The per-dose hook keeps the one derived row that still hangs off a dose
day, the supplement nutrients, under dose-day-refresh. The pending-row
mints (today projection, reminder) no longer call it, since an untaken
dose adds no nutrients. The import job finishes without its per-day
recompute phase; its progress keeps touchedDays and rollupProcessed on
the wire, inert and documented as deprecated. The backfill queue is
retired on boot and migration 0414 drops the table under a bounded lock
wait.
2026-10-20 13:01:45 +02:00
Marc-André Bombeck 5a6c78efe5 fix(jobs): give the full rollup fold an expiry, a budget and a lock
The fold was declared short on the strength of its 60-second
statements, but four aggregates and four persist transactions of up
to 120 s come to about thirteen minutes at worst under the defaults,
and nothing bounds them once an operator lifts the statement timeout.
Past pg-boss's fifteen-minute default the retry folded the account a
second time. The send now carries an hour's expiry, the fold stops
between granularities on the job's budget, and the binding holds a
per-account lockedPass outside the lane. The runtime guard now requires
every heavy queue to be declared long.
2026-10-20 11:06:17 +02:00
Marc-André Bombeck 2be262d574 refactor(api): drop apiPostList, which no capped list reads
Every capped route is read with GET, so the POST reader had no caller
and failed the dead-code gate. The list-cap guard now binds a capped
route to apiGetList alone, which is the reader that exists.
2026-10-20 10:58:03 +02:00
Marc-André Bombeck b08d527045 test(auth): mint session fixtures in the shape the lookup accepts
The session time-zone test stored sessions under readable secrets that
the cookie shape check now refuses before the query, so it tested the
refusal instead of the expiry. A shared helper mints hls_ plus 32 hex
bytes, and the two route tests that set a session cookie use it too.
2026-10-20 10:56:55 +02:00
Marc-André Bombeck 4f02004146 test(guards): derive the rounding guard's directories from the connection keys
The guard kept its own provider list and its own comment stripper next
to the shared PROVIDER_DIRS and stripComments helpers, so a new
integration could slip past it. It now uses both, plus the file
importers, which are not a connection.
2026-10-20 10:55:42 +02:00
Marc-André Bombeck 9c89503019 fix(migrations): bound 0412's lock wait where it takes effect and retry a lock timeout on boot
migrate deploy runs a migration file statement by statement, so the
top-level SET LOCAL lock_timeout in 0412 did nothing: the migration
waited as long as the lock was held, with every write to measurements
queued behind it, and a file that failed half-way stayed half-applied.
0412 now takes its locks inside one DO block with a statement-scoped
timeout, which rolls back whole, after an idempotent CREATE OR REPLACE.

The entrypoint runs migrate deploy through scripts/migrate-deploy.mjs.
A 55P03 failure of a migration marked -- @retry-on-lock-timeout is
marked rolled back and retried after 15/30/60/120/240 s, read from
Prisma's report or, after P3009, from the stored row. Every other
failure stays fatal. A guard holds migrations to the shape that makes
the marker true, and 0411/0413 add their columns with IF NOT EXISTS so
a manual re-run is safe too.
2026-10-20 10:45:14 +02:00
Marc-André Bombeck e5b7eebe64 Merge branch 'fix/v1437-r2-jobs' into release/v1.43.7 2026-10-20 10:17:01 +02:00
Marc-André Bombeck 86eb743a69 Merge branch 'fix/v1437-r2-errors' into release/v1.43.7
# Conflicts:
#	src/components/charts/health-chart.tsx
#	src/components/insights/ecg-cross-link.tsx
#	src/components/insights/ecg-section.tsx
#	src/components/insights/rhythm-events-card.tsx
2026-10-20 10:17:01 +02:00
Marc-André Bombeck 923249dbf6 fix(ui): draw every failed read through a read-failure primitive
Around thirty surfaces drew a failed read their own way: a bare
destructive paragraph with no retry, a muted line saying the data was
still being learned, a skeleton that never ended, an error card nested
in a tile, or nothing at all. None of them told an ended session from a
refusal. They now use QueryErrorRow or QueryErrorCard with the query's
error and a retry, inset where a card already frames them, and keep
their card and title where the page would otherwise jump. Hydration and
caffeine now fail the same way.

The recovery page no longer points to Integrations while Oura's first
resilience level is simply on its way.

A guard reads every read hook's isError branch and refuses hand-rolled
markup there, and refuses a framed error row inside a card.
2026-10-20 10:10:49 +02:00
Marc-André Bombeck 3fbd16a0a6 fix(ui): let the chart error card decide from the error too
ChartErrorState took no error, so a chart whose read was refused or
whose session had ended offered a retry that could not help. It now
takes the error and branches the way QueryErrorCard and QueryErrorRow
do, the chunk boundary hands it the error it caught, and the error-prop
guard covers it.

QueryErrorRow gains an inset form for a failure inside a card body, and
its frame takes the md:p-6 step. QueryErrorCard drops its fixed px-6 for
the card's own inset and both icons move onto the size scale.
2026-10-20 10:10:31 +02:00
Marc-André Bombeck 985372f711 fix(insights): count failed fetches toward a bounded poll's ceiling
The status and advisor polls counted attempts on dataUpdateCount, which a
failed fetch does not move, while the last good payload stayed in place and
still said preparing. A request that kept failing polled an open page for
as long as it stayed open. Attempts now include the failed fetches.

The poll inventory moves from a comment into a guard held to every
refetchInterval in the tree; four polls were missing from the comment.
2026-10-20 10:06:11 +02:00
Marc-André Bombeck dc5120b59a feat(lists): read and show the list cap on every capped list
Twelve routes answer a capped list with meta.truncated / meta.limit on
the envelope, and the web client read only data, so no surface said a
list had stopped short. apiGetList / apiPostList are now the one client
reader of the cap and ListCapNotice the one line that shows it; every
capped list on the web reads its cap and shows the notice under the list,
the paged readers (side effects, illness episodes) stop on the route's
answer instead of a full-page guess, and the ECG list moves its five
readers onto one hook. The record activity feed sent its cap twice, in
data and on the envelope; it rides the envelope alone now. A guard
derives the capped routes from the tree and binds each to a client that
reads its cap.
2026-10-20 10:04:11 +02:00