Merge remote-tracking branch 'origin/main' into release/v1.40.0

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 11:13:49 +02:00
commit ad0f35eadd
26 files changed
+1439 -61

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ Every JSON route answers the same shape: `{ data, error }`, with `meta` on the e
- Refresh tokens rotate per device and are one-time-use. Reusing an old one revokes that device's whole token family, on purpose. Persist the newest pair atomically and never retry a refresh with a token that already succeeded once.
- Bearer token scopes are fail-closed. A token minted for one purpose does not open unrelated routes, and a route that does not declare a scope refuses narrow tokens outright. Expect `403` to be a normal, recoverable answer, not an anomaly.
- Admin surfaces are cookie-session only, by construction. A native client cannot be an admin client, whatever scopes its token carries. Do not build admin features.
- Mutations accept an `Idempotency-Key` header. Replays answer with the original result plus `X-Idempotent-Replay`, so retrying on a dropped connection is safe. Use it for anything a user would notice twice.
- Mutations accept an `Idempotency-Key` header. A repeat of the same key with the same body answers with the original result plus `X-Idempotent-Replay`, so retrying on a dropped connection is safe. A different body under the same key is treated as a new request, not a replay; still, give each distinct write its own key. Use it for anything a user would notice twice.
## Data semantics worth knowing before you sync
+8 -6
View File
@@ -162,12 +162,14 @@ paths:
pattern: ^[A-Za-z0-9_\-:.]{8,128}$
minLength: 8
maxLength: 128
description: "Makes this write safe to retry. Send the same key again and the first response is replayed rather than the
work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED
rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no
key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different
route is a different key. While a first request under this key is still in flight, a second is refused with
409 rather than queued."
description: "Makes this write safe to retry. Send the same key with the same body again and the first response is
replayed rather than the work repeated, marked with `X-Idempotent-Replay: true`. The body is compared as
JSON with key order ignored; a different body under the same key is not a retry and runs as a new write,
while the first response stays stored for retries of the first request. A key that does not match the
pattern is IGNORED rather than refused — the write then proceeds unprotected and looks exactly like a
request that carried no key, so validate the key on your side. Scoped per user and per (method, path): the
same key on a different route is a different key. While a first request under this key is still in flight, a
second with the same body is refused with 409 rather than queued."
requestBody:
required: true
content:
+3 -2
View File
@@ -171,8 +171,9 @@ tells you which rows to retry.
## Retries and duplicates
Send an `Idempotency-Key` header with a value your automation can reproduce for
the same reading. A retry with the same key returns the original response
instead of writing a second row:
the same reading. A retry with the same key and the same body returns the
original response instead of writing a second row. A different body under the
same key is a different reading and is written:
```yaml
headers:
@@ -0,0 +1,60 @@
# Blood pressure readings missing their diastolic half
## What happened
The iPhone app (every App Store build up to 1.0.3 and TestFlight 1.1.0)
saves a manually entered blood pressure as two requests to
`POST /api/measurements`: one `BLOOD_PRESSURE_SYS`, one
`BLOOD_PRESSURE_DIA`, both carrying the same `Idempotency-Key`. Until the
replay cache learned to compare request bodies, the server treated the second
request as a retry of the first. It answered with the first request's cached
`201` (header `X-Idempotent-Replay: true`) and never wrote the diastolic
value. The app showed success, so nobody noticed.
The fix makes the cache replay only a request with the same body. A different
body under the same key now runs normally. Nothing recovers the values that
were never sent to the database: they exist only on the person's phone, if at
all.
## Finding the affected readings
Read-only. Run it against the HealthLog database (with the bundled compose
file: `docker compose exec db psql -U healthlog healthlog`). It lists every live systolic reading that has no
live diastolic reading for the same account at the same instant.
<!-- bp-missing-diastolic:start -->
```sql
SELECT u.username,
m.id,
m.measured_at,
m.value AS systolic,
m.source
FROM measurements m
JOIN users u ON u.id = m.user_id
WHERE m.type = 'BLOOD_PRESSURE_SYS'
AND m.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1
FROM measurements d
WHERE d.user_id = m.user_id
AND d.measured_at = m.measured_at
AND d.type = 'BLOOD_PRESSURE_DIA'
AND d.deleted_at IS NULL
)
ORDER BY u.username, m.measured_at;
```
<!-- bp-missing-diastolic:end -->
Readings from the phone app carry `source = 'MANUAL'`. Rows from a synced
source (Withings, Apple Health) that show up here are a different gap, not
this one: those sources write both halves themselves.
## What to tell users
There is nothing the server can repair: the diastolic value never reached
it. A person whose reading appears in the list can re-enter it: delete the
incomplete reading, then add the blood pressure again with both values and
the original date and time. From this release on, both halves are saved on
the first try, from any app version.