Commit Graph
780 Commits
Author SHA1 Message Date
Marc-André Bombeck cfbb57ab3e test(workouts): format the manual-entry integration suite
Refs #1085
2026-10-02 21:28:07 +02:00
Marc-André Bombeck 92ce699792 test(workouts): drive a hand-entered workout through the real routes
Builds the entry the form sends, posts it through the batch route, reads
it back from the list, re-posts it as a duplicate, and deletes it; a
synced workout is refused.

Refs #1085
2026-10-02 21:25:36 +02:00
Marc-André Bombeck 82ccac2fd0 fix(ecg): let a whole sweep of recordings through the ingest limit
The iPhone app posts every ECG its sweep has not yet consumed and, on a
429, stops and restarts the same sweep on its next wake. With a limit of
60 per minute a sweep of 61 or more could never finish: the same last
recording was refused on every sync, so the newest ECG never arrived. The
limit is 600 per minute now, enough for more than a decade of weekly
strips in one sweep.

Refs #1060
2026-10-01 21:53:37 +02:00
Marc-André Bombeck 661378bfa9 Merge remote-tracking branch 'origin/main' into fix/v1397-units2 2026-10-01 13:47:10 +02:00
Marc-André Bombeck d11c165675 fix(coach): read the cycle contrast and the metric table in the reader's units
The cycle block's headline contrast labelled weight in kg and the
temperatures in °C, and left a glucose contrast without any unit. The
metric table, which the person sees and whose summary the model reads,
listed weight in kilograms, temperatures in degrees Celsius, walking
speed in metres per second and distance in metres.

The cycle contrast now converts both means and the gap into the
reader's units (the gap by the factor alone) and names the unit in its
sentence. The table resolves each column's unit and decimals from the
reader's preferences and converts every folded period value once; the
snapshot carries the preferences it was written in, so the table and
the snapshot of a turn always agree.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck 8b84e08145 Merge remote-tracking branch 'origin/main' into fix/v1397-account-backup 2026-10-01 13:21:55 +02:00
Marc-André Bombeck ff8277695a test(backup): round-trip every account setting through a real restore
Fill every User column on the source account, export through the streaming
writer, recreate the account with credentials of its own, restore through the
route, and compare: each setting column must come back with the value it left
with, and every identity, credential and bookkeeping column must still hold
the target's value. The section in the file is checked to carry nothing but
setting columns.
2026-10-01 13:12:25 +02:00
Marc-André Bombeck 0dfce73570 fix(ecg): accept the fractional heart rate and frequency HealthKit reports
The ingest route required integers for the average heart rate and the
sampling frequency. HealthKit reports both as doubles and a watch's average
heart rate is often fractional, so most new recordings were refused with
422 while older ones, whose value happened to be whole, went through. Both
are now accepted as numbers and rounded once before they are stored; the
columns stay integers. A refused body now logs which fields were refused
and why, never their values.

Refs #1060
2026-10-01 12:50:08 +02:00
Marc-André Bombeck 03eced171e Merge remote-tracking branch 'origin/main' into fix/v1397-meta 2026-10-01 11:25:06 +02:00
Marc-André Bombeck b7a3b0d331 test: round-trip every column of every two-ended model
The round-trip test proved that a row of each two-ended model comes back,
not that it comes back whole. A restore that writes the row with a column
defaulted returns the right count and loses the value; the medication
category went missing that way.

The new case lists each model's columns from the client's data model,
with optional/default/key attributes from schema.prisma (the two field
lists must agree). Columns the fixture left empty get a value that cannot
pass for a default, one column at a time so a refused value names its
column. Then it exports, deletes the account, restores, and compares each
column as a multiset of values: sealed columns by their plaintext, a
legacy plaintext column together with its sealed twin, declared foreign
keys by presence, primary keys, owner and @updatedAt excluded by rule.

A column added tomorrow is compared automatically: it comes back, or it
is named in COLUMN_EXCLUSIONS with a reason. Entries naming a column that
no longer exists fail. A pure case shows the comparison catches a column
it was never told about, and the run asserts at least 620 columns
compared (632 today). Removing the workout source marker, a schedule
label, the folded-cycle pointer or the nutrient createdAt from either end
each fails the run naming that column.
2026-10-01 11:17:44 +02:00
Marc-André Bombeck 533b75fdcf Merge remote-tracking branch 'origin/main' into fix/v1397-derive 2026-10-01 10:30:10 +02:00
Marc-André Bombeck a66976874e fix(mood): report the tag and factor keys a mood write did not store
An unknown or archived tag key, or a factor the catalog no longer carries,
was dropped on the way in while the entry still reported "inserted" (bulk)
or 201 (create, edit). A client had no way to tell that part of what it sent
never landed.

Each write now names those keys: the bulk result per entry, the create and
edit responses on the entry, as droppedTagKeys and droppedFactorKeys. Both
are present only when non-empty, so a response where everything landed is
unchanged. An archived tag an edit preserved is on the entry and is not
listed. The web shows a short notice after saving when a picked tag was left
off.
2026-10-01 10:01:06 +02:00
Marc-André Bombeck 5f1eb05445 fix(measurements): bound the per-night sleep list to a year ending at to
The sleep list capped its read to the last year only when no `from`
was sent. An explicit early `from` walked every per-stage row back to
that date, on a multi-source account several rows per night for years.

The read window is now at most a year ending at `to` (or now). `from`
narrows it but never widens it, older nights are reached by moving
`to` back, and `meta.windowFrom` names the instant a cut window
started, so a client can tell a cut from an empty history.
2026-10-01 08:58:42 +02:00
Marc-André Bombeck 730e9e7015 fix(doctor-report): take the day path for dense heart-rate variability and blood oxygen
The report's dense exception covered pulse and glucose. Heart-rate
variability and blood oxygen were read raw for any window, and a watch
or ring writing them every minute puts six figures of readings into a
long report that draws them as one sparkline.

Their report sections are a statistics row and a series, the same as
pulse, so they take the same rule: beyond 90 days per local day, and
inside 90 days per local day once the window holds more than 10 000 of
their readings. One grouped count decides each type on its own. Count,
minimum, maximum and the latest reading stay exact; the mean is the
readings' mean. Glucose keeps its own rule because its clinical panel
needs the per-reading counts.

The bucket query now takes a list of types instead of two flags.
2026-10-01 08:56:19 +02:00
Marc-André Bombeck b2c484e25e fix(series): bucket heart-rate variability and blood oxygen like pulse
The dense rule in /api/measurements/series covered pulse and glucose
only. Heart-rate variability and blood oxygen read every raw row for
any window up to 3650 days, and a watch or ring that writes them every
minute makes that the same unbounded read pulse had.

Both kinds now fold per local day beyond 90 days and per local hour
inside 90 days once the window holds more than 10 000 readings. A
bucket keeps the point shape of its kind: only pulse carries
valueMin / valueMax. Statistics stay computed over every reading, and
a sparse series inside 90 days is unchanged. The OpenAPI description
names the four kinds.
2026-10-01 08:52:10 +02:00
Marc-André Bombeck 8949606de9 fix(cycle): read the insights crosstab as per-source day aggregates
GET /api/cycle/insights read a year of raw readings for its outcome
channels, glucose and heart-rate variability included. On a sensor
account that is over 100 000 objects per request.

The crosstab uses each local day's sum and count after picking one
source and device per day, so the route now reads readSourceDayAggregates
like the coach's cycle snapshot does, and each row carries its reading
count into metricDayMap.

The integration test runs the route over the fold and over every raw
reading handed in as a one-reading group, and compares the answers.
2026-10-01 08:50:31 +02:00
Marc-André Bombeck 7f2349e56f fix(targets): fold the glucose year and the heart-rate month in SQL
The targets page read a year of raw glucose readings to keep the newest
one per meal context, and thirty days of raw heart rate to derive one
resting figure per day. A sensor reporting every five minutes puts
105 000 glucose readings in that year and a watch recording once a
minute puts 43 000 heart-rate readings in the month, and the page is
read on every visit from the web and the app.

Glucose now reads one row per context (DISTINCT ON) for the newest
value and one row per context and local day for the thirty-day mean
and the consistency strip; the strip takes day sums directly through
rollupConsistencyFromDays. Heart rate reaches the resting-pulse card
through readRestingPulseProxy, the SQL fold the insights page already
uses, merged per day with the native resting rows exactly as before.

The cards are unchanged: the integration test rebuilds them from the
raw readings through the in-memory folds and compares.
2026-10-01 08:47:18 +02:00
Marc-André Bombeck c777e9e9e4 Merge pull request #1065 from MBombeck/fix/v1396-small-fixes
Quick-add follows intakeActionable; hourly buckets for dense CGM inside 90 days
2026-09-30 23:57:44 +02:00
Marc-André Bombeck 20752fb4bb Merge pull request #1066 from MBombeck/fix/backfill-lane-expiry
Keep a full-history import from running beside its own retry; tombstone purge steps around a restore
2026-09-30 23:46:16 +02:00
Marc-André Bombeck 85d11bc364 Merge branch 'main' into fix/v1396-small-fixes 2026-09-30 23:27:25 +02:00
Marc-André Bombeck a5d19bfe98 Merge pull request #1064 from MBombeck/fix/backup-summary-manifest
Answer the restore preview from counts kept with the backup
2026-09-30 23:25:26 +02:00
Marc-André Bombeck a0c290e140 fix(jobs): let the nightly tombstone purge step around a running restore
A restore clears every row of the account, tombstones included, inside one
long transaction under the account's exclusive restore lock. The nightly
purge deleted expired tombstones across accounts, one statement per batch,
without that lock: on an account under restore it waited on the restore's
row locks for as long as the restore ran, past its statement timeout, or
deadlocked with it once each side held rows the other needed.

Each batch is now deleted one account at a time, in a transaction that
first takes the restore lock in shared mode, the same order the
consolidation passes keep. An account under restore is refused at once,
kept out of the rest of the run, and purged the next night. Still one
delete per account per batch, never one per row. The job reports how many
accounts it deferred.

The integration test holds a stand-in restore on one account while the
purge runs: the purge returns without waiting, leaves that account alone
and purges the other. With the lock call removed it waits out the hold.

Refs #1031
2026-09-30 23:11:03 +02:00
Marc-André Bombeck 029d6d881c fix(jobs): keep a full-history import from running beside its own retry
The shared admission queue sent its jobs without an expiry, so every one
took pg-boss's fifteen minutes. Against pg-boss 12.34, a handler that runs
past its expiry is not stopped: the job is failed, its abort signal fires,
the worker and group slots are freed, and the retry of the same job starts
in the same worker beside the run it replaced. A per-minute heart-rate
history is thousands of pages, so on a slow link one import turned into up
to four copies of itself, on the one queue meant to run a single import at
a time.

- The admission job now carries a sixteen-hour expiry, sized for the
  longest legitimate import and under pg-boss's 24-hour ceiling.
- Each run holds a session advisory lock on its identity (kind, provider,
  account) on a connection of its own. A delivery that finds it taken does
  nothing and says so in the log; the running import stamps its marker or
  is offered again at the next boot. Ending the connection or the process
  releases the lock.
- The Google Health backfill runs under the job's budget and abort signal.
  The page walk asks before each request, stops there, and reports the run
  truncated, so it throws without the completion stamp and is retried.
- The drain moves out of the registrar into its own module so it can be
  driven against a real pg-boss.

The integration test pins the pg-boss behaviour and runs the lane's
handler with a two-second expiry: without the lock the import ran twice.

Refs #1023
2026-09-30 23:11:03 +02:00
Marc-André Bombeck 731e073284 fix(measurements): hour-bucket a CGM glucose series inside the 90-day window
A sensor reading every five minutes puts about 26 000 glucose readings in
a 90-day window, and the per-kind series sent every one of them raw. The
hourly bucketing that already applies to a dense heart-rate stream now
applies to glucose too, with the same 10 000-reading cap: the hour's mean
converted once to the user's unit, statistics over every raw reading.
A fingerstick meter stays far under the cap and keeps its raw points.

The doctor report keeps glucose raw inside 90 days. Its clinical panel is
computed from the raw readings of every source, while the day path works
from canonical-source day buckets, so the two do not give the same figures
when a meter and a sensor overlap.
2026-09-30 23:01:57 +02:00
Marc-André Bombeck a2fceb5585 fix(backups): answer the restore preview from counts kept with the copy
The restore dialog asked GET /api/admin/backups/{id}/summary for the
contents of a copy, and the route read the whole stored copy to answer:
every piece opened, every measurement parsed, the rest validated. On an
account of 1.8 million readings on slow hardware that took about two
minutes. The dialog stopped waiting after 15 s and said the contents
could not be read, and its automatic retry made the server read the
whole copy a second time.

The copy passes through the process once anyway while it is written, so
the preview is worked out there and kept on the row (DataBackup.preview,
migration 0364): the summarizeBackup counts, the schema version, and
the inner key ids with the sections they sit in and one sample value
each. Every creation path goes through storeBackupBlob (the weekly and
manual pass, and an upload), so every new copy carries one. The column
is plain JSON: counts and dates only, and the samples are ciphertext
that stands for what is inside the copy, which a key rotation must not
re-seal.

The route answers from it without opening the copy. Both verdicts are
taken again on every read, from what the preview keeps: whether this
release restores the schema version, and whether this server holds the
keys the copy needs. The pieces are still counted against the row, so
a copy that lost one is refused as the restore and the download refuse
it. A preview names the copy it describes and is ignored once the copy
changes; converting a single stored value into pieces carries it over.

A copy written before this has no preview. It is read whole once, as
before, and the result is kept on the row, so it is slow only the first
time; concurrent requests for the same copy share the one read.

Only a file that fails the schema answers 422 "Backup payload failed
schema validation". Read refusals keep their codes; anything else is
the server failing and answers 500.

The dialog no longer retries the preview, waits up to 55 s, and when it
stops waiting on an older copy it says the copy is still being read and
to open the dialog again in a few minutes.

Refs #1031
2026-09-30 22:51:02 +02:00
Marc-André Bombeck e7166cb3c6 feat(tokens): mint a workouts:write token for headless workout ingest
A bridge that pushes workouts from a watch vendor had no credential that
reached POST /api/workouts/batch: the route called requireAuth() with no
scope, so under the fail-closed default only a cookie session or a
wildcard token got through, and a measurements:write token was refused
403. The route's header still described the pre-fail-closed behaviour.

Add a separate workouts:write scope rather than widening
measurements:write, so a token already handed to a scale does not start
accepting workouts.

- POST /api/tokens/workouts mints it, mirroring the measurement and
  document mints: cookie session plus a fresh proof, a literal permission
  array, 10 mints a minute, at most 10 live tokens (409
  tokens.workouts.ceiling_reached), a one-year default lifetime, audited.
- POST /api/workouts/batch names the scope. Sessions and wildcard tokens
  behave as before. A scoped caller's workouts are stored as EXTERNAL,
  resolved from the credential, and a batch in which any entry names a
  source is refused 422 (workout.batch.source_not_permitted), the same
  rule the measurement batch applies. EXTERNAL is on no source ladder, so
  when the phone recorded the same session the phone's copy is shown.
- Settings > API offers a workout token card next to the measurement one,
  and the endpoint list shows the batch route.
- Guards: the mint site, the declared scope vocabulary, the recent-proof
  routes, and a new check that only the batch declares the scope.
- OpenAPI documents the mint, the batch's narrow-token behaviour and both
  error codes; docs/api/README.md lists the workout and document mints.

Refs #1054
2026-09-30 22:21:46 +02:00
Marc-André Bombeck add6d1fadc fix(restore): order the restore and the consolidation passes by one lock
A restore on a freshly booted instance failed its clearing step with a
deadlock (40P01) while the boot-time step consolidation was folding the
same account. A consolidation day updates or mints the day's total and
then soft-deletes the day's per-sample rows; the restore deletes every
reading of the account in one statement. When the restore reached a
day's samples before its total, each side held what the other needed
next.

The restore already took an advisory lock per account before its first
delete. Every day transaction of the passes built on runConsolidation
(step and mean consolidation, the per-sample cumulative drain, the dense
intraday retention) now takes the same lock in shared mode as its first
statement. A restore that starts mid-day waits for that day to commit;
a day that would start while a restore runs is refused at once, and the
pass leaves that account for its next run instead of folding rows the
restore is about to replace. The passes still share the lock with each
other.

A new integration test builds the lock order that deadlocked, with a
row trigger holding the consolidation on the day's total while the
restore starts; before this change the consolidation was cancelled with
a deadlock. A guard test requires every transaction in a pass on
runConsolidation to open with the shared lock.

Refs #1031
2026-09-28 00:34:11 +02:00
Marc-André Bombeck f2569b6fdf fix(restore): carry at most a hundred readings per insert statement
Prisma's debug logger keeps the arguments of its last hundred calls in
memory whether or not logging is on, and the client and the Postgres
adapter both log every statement with its parameters. A restore that
wrote 1 000 readings per statement therefore kept a copy of about the
last 100 000 readings alive for the whole measurement write: about
60 MB on 1.8 million Apple Health readings, next to a V8 heap limit of
524 MB in the default 1 GB container.

The bulk insert now splits whatever it is handed into statements of at
most 100 rows, which holds about 7 MB. The Apple Health export import
goes through the same helper and gets the same bound. The write costs a
little time: 94 s instead of 76 s for 1.8 million readings on a fast
host.

Refs #1031
2026-09-28 00:06:47 +02:00
Marc-André Bombeck 6836830038 fix(doctor-report): report readings, not day buckets, for a dense type
A type served from day buckets (pulse or glucose past 90 days, and since
the previous change a dense pulse stream on any window) is drawn one point
per day, each the day's mean at the day's start. The summary line counted
those points as measurements, and the FHIR export stamped the last day's
mean as the latest observation: a value and a time nobody measured.

The day-bucket stats now carry when their newest reading was taken
(`latestAt`). `latestReading` returns that reading when it is there, and
the summary line counts each type's readings from its stats. Raw series
are unchanged: their stats count equals their points and their last point
is the newest reading.
2026-09-27 21:20:24 +02:00
Marc-André Bombeck 3458100ac4 test: give the report's pulse-window count a mock and a typed route call 2026-09-27 21:16:20 +02:00
Marc-André Bombeck 0e991c3640 fix(measurements): fold a dense pulse stream inside the 90-day raw window
Past 90 days every pulse reader already folded in SQL. Inside it, three
still read every raw heart-rate row, which on a watch that records a
reading a minute is 130 000 rows per read (#1023):

- The insights page's resting-pulse proxy, used when an account has no
  resting heart rate, walked the whole window to take one percentile per
  day and held the event loop for over a second per view. It now folds in
  Postgres (`readRestingPulseProxy`) with the same percentile, rounding,
  minimum-sample rule and day anchor.
- The per-kind series sent every reading: 14.7 MB for 90 days. A pulse
  window with more than 10 000 readings is now bucketed per local hour,
  mean plus low/high band, with stats still over the readings.
- The doctor report drew every reading into one sparkline. A pulse window
  over the same cap now takes the per-day path longer reports already use.

Sparse pulse (a cuff, hourly imports, a watch sampling every few minutes)
stays under the cap and keeps its raw shape.
2026-09-27 21:07:29 +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 833fb0b465 fix(workouts): report no route when its track cannot be read 2026-09-27 15:11:43 +02:00
Marc-André Bombeck f98f8eaa24 Merge branch 'fix/chart-all-range-meaning' into release/v1.39.4 2026-09-27 14:22:57 +02:00
Marc-André Bombeck 206da07e8c Merge branch 'fix/meds-ended-and-mental-health' into release/v1.39.4 2026-09-27 14:18:09 +02:00
Marc-André Bombeck 6f37cbdb23 fix(backup): carry each medication's category through backup and restore
The clinical category a person picks for a medication lives in a side table
created at runtime, outside the schema. The backup builder and the restore
only walk schema models, so no backup ever carried it: after a restore every
medication read as "Other".

Both backup formats now carry the category inside its medication, and the
restore writes it back in the same transaction as the medication. A file
from before this change restores as before; a category this server does not
know reads as Other instead of refusing the file.

The backup plan gains a list for tables created with raw SQL, and a check
that every such table in the source either maps to a schema model or has a
verdict there, so the next one cannot drop out of backups unnoticed.
2026-09-27 14:17:42 +02:00
Marc-André Bombeck b45a668cf4 fix(charts): serve the All range's coarse buckets as their average day
The All range is read from the WEEK or MONTH rollup tier once a history is
long enough, from DAY rows for a short one, and from the live table on a
coverage miss. The chart folds every path into the average day of each
week or month, but a tier row carried a cumulative metric's whole total
and a level's reading-weighted mean. A month of steps therefore read about
thirty times higher for a long history than for a short one, under a
caption that said average.

A tier row now carries the average day of its bucket on every tier: a
cumulative metric's total over the days that hold a reading, a level's
mean of its daily means. The rollup fold counts the bucket's days and adds
up their means for it. Only the web chart's series reads (the chart
endpoint and the dashboard batch) use these rows.
2026-09-27 14:08:49 +02:00
Marc-André Bombeck c7e53176c1 Merge branch 'fix/v1394-review-correctness' into release/v1.39.4 2026-09-27 14:00:04 +02:00
Marc-André Bombeck eda87fd4a7 fix(sync): expire delta cursors issued before a restore or a data wipe
A restore writes rows back with their original updatedAt, behind every
paired client's watermarks, and removes rows without leaving tombstones.
A client that kept its cursor would miss both on its next incremental
pull. The same holds after the person deletes all their data.

`users.sync_reset_at` (migration 0363) records when the record was last
replaced: the restore stamps it as its last write and again right after
the commit, the per-account and the admin wipe stamp it inside their
transactions. Cursors now carry the time they were issued, and
/api/sync/changes answers its existing `cursorExpired` to a cursor issued
before the reset, or one without an issue time on an account that has
one. The comparison is on the issue time, because after a restore a
freshly drained cursor still carries old watermarks.
2026-09-27 13:46:38 +02:00
Marc-André Bombeck 5173049130 Merge branch 'release/v1.39.4' into fix/v1394-review-correctness 2026-09-27 13:27:59 +02:00
Marc-André Bombeck fc13b9de5c fix(restore): skip expired tombstones and give the chart rebuild its own limit
A restore now leaves out measurement tombstones deleted longer ago than
the tombstone retention. The nightly purge removes exactly those rows,
the delta feed answers cursorExpired for every cursor that could still
be served one, and the consolidation repairs look only inside the same
horizon. Younger tombstones keep their readers (a lagging paired client,
the undo of a recent delete, the repairs, the duplicate guard on
re-uploaded samples) and are written as before. The backup file is
unchanged. On a seeded account of 1.89 million readings whose 1.68
million tombstones were past retention, the restore on a slowed database
took 5.4 min instead of 15.

The rollup fold after the commit rebuilt five years of buckets under the
60 s request statement limit and could be dropped on a slow disk, leaving
the charts empty until the next boot. `recomputeUserRollups` takes a
`statementTimeoutMs` that scopes each aggregate and persist transaction
with SET LOCAL, and the restore passes its after-commit allowance.

Fixture tombstones in two round-trip tests were dated in July 2026 and
had aged past the retention; they are now relative to the run.
2026-09-27 13:21:18 +02:00
Marc-André Bombeck 874d02294e fix(backup): let the restore job's budget admit large accounts
A restore is refused before it deletes anything when its transaction limit
would run past three quarters of the job's expiry. At two hours that refused
a same-size replacement of about four million readings. The expiry is now six
hours, which admits ten million over ten million; a dead worker still frees
the account within minutes through the heartbeat, so the longer expiry does
not hold an account longer.

The refusal now names both counts, the readings in the backup and the ones the
account holds now (removed readings included until the nightly clean-up),
since clearing the latter costs time too.
2026-09-27 13:13:50 +02:00
Marc-André Bombeck 722945702a fix(privacy): clear the last readable copies of practitioner contact details
The encryption backfill now also removes the two copies of a practitioner's
contact details that outlived the sealing. An appointment reminder's copy of
the address is cleared, since the reminder resolves it from the practitioner
when it fires. An address-book edit's audit row written before the sealing
kept the old phone number and address in its previous values; those values
leave the row and the changed fields stay named. Rows of a deleted account,
which no per-account job reaches, are scrubbed by the discovery pass itself.

A route whose readable geometry is a JSON null rather than SQL NULL had
nothing to seal but matched discovery every night. The pass now sets it to
SQL NULL so it drops out.
2026-09-27 13:07:17 +02:00
Marc-André Bombeck 3482581ce1 fix(encounters): name the practice address on an appointment nudge again
Sealing the practitioner's address left the appointment arm reading the old
readable column, which is now empty, so a booked visit's push lost the address
on booking, rescheduling and restoring a deleted visit.

The reminder no longer keeps its own copy of the address. The reminder tick
resolves it from the owning visit's practitioner when it builds the push and
decrypts it there, so an edit of the address also reaches a nudge that is
already booked. MeasurementReminder.location stays NULL on appointment rows:
booking writes none, a re-anchor clears one an earlier release wrote, and a
backup neither carries nor restores one. A checkup's own free-text location is
unchanged.
2026-09-27 13:07:10 +02:00
Marc-André Bombeck 49df950043 test(coach): pin all-time step months across history lengths and read paths 2026-09-27 13:04:30 +02:00
Marc-André Bombeck 02801bdfda Merge branch 'fix/restore-large-account-clearing' into release/v1.39.4 2026-09-27 12:14:04 +02:00
Marc-André Bombeck e0b4f3c427 fix(restore): size the restore budget by the rows written, and keep test housekeeping off the short limit 2026-09-27 11:57:37 +02:00
Marc-André Bombeck a967ccac44 feat(coach): metric tables and show_result for the chat tools
get_metric_table reads one metric as days, weeks or months through the
chart reader, cut in the user's zone and collapsed to one source per day,
with null for a period without a reading. The model reads a bounded
summary; the full table rides the turn, encrypted at rest. show_result
shows a table an earlier reply of the same conversation fetched, resolved
only against that conversation's own messages. Workouts, labs, sleep and
adherence results project into tables, earlier tables are named in the
context by metadata alone, and result:rN marks are stripped from the reply.
2026-09-27 11:54:30 +02:00
Marc-André Bombeck 93b6c086b4 fix(restore): let a large restore outlast the request statement timeout
A restore of an account with 1.9 million readings, most of them deleted
by the nightly consolidation and still awaiting the purge, was cancelled
in its clearing step: `DELETE FROM measurements` ran past the 60 s
statement timeout every pooled connection carries, and the whole restore
rolled back with a message that named no cause (#1031).

Most of that statement was not the delete. Two columns point into
`measurements` with ON DELETE SET NULL and had no index, so Postgres
scanned `personal_records` and `ecg_recordings` once per deleted reading:
38 of 40 s on a seeded account with 300 personal records. Migration 0362
indexes both, which also speeds up the tombstone purge and account
deletion.

The restore transaction now sets its own statement and idle-in-transaction
limits to the transaction budget with SET LOCAL, and the budget counts
the rows the account holds as well as the rows the file carries. A
rolled-back restore names what the database refused it over (timeout,
lock, constraint, storage, connection, other) on the job row, the
console, the audit row and the wide event.
2026-09-27 11:24:41 +02:00
Marc-André Bombeck 78fc22d0f8 Merge branch 'coach/wpF-contracts' into release/v1.39.4 2026-09-27 11:10:37 +02:00