Let an Apple Watch ECG arrive without a 1.5 GB export archive

POST /api/insights/ecg takes one recording per request: the stable
recording id the client already has, the on-device timestamp, the
sampling rate, the waveform as integer micro-volts, the lead, the
average heart rate, and the device's own verdict. Same gating as the
reads (auth, the insights module, the insightStatus surface), userId
from the session, APPLE_HEALTH the only source a client may claim.
sampleCount and durationSeconds are derived here, and nothing about the
waveform is ever interpreted.

The write body now lives in one place, src/lib/ecg/persist-recording.ts,
shared by the Withings sync, the export-archive importer and the new
route. Three writers with three persistence bodies is how the two ends
of one rule drift apart.

Migration 0291 adds the second unique index on
(user_id, source, recorded_at, sampling_frequency). The archive importer
identifies a strip by a hash of its content and a live client by the
device's own sample uuid, so the same physical recording arriving
through both doors carried two different ids and would have landed
twice. One device does not record two different strips at the same
instant at the same sampling rate, so the constraint is sound and the
convergence stops being a convention.

The table already holds rows on deployed instances and two writers have
been filling it since v1.19.0, so the index cannot just be created: a
pre-existing collision would abort the migration. The migration resolves
duplicates first, keeping the row with the most samples (a stored strip
beats a verdict-only row), then the one linked to its event
measurement, then the most recently written, then the lowest id so the
choice is total. The losers' event link is salvaged onto the keeper
first, so resolving a duplicate never costs the recording its place in
the event timeline.

If that resolution is wrong on some instance, the failure is visible at
deploy time and not later: db:migrate:deploy runs on boot, so the
container crash-loops with the migration named in the logs and the
release never serves traffic. A deploy that finishes in under thirty
seconds did not run it.

The GET list and detail routes and the export-archive path are
unchanged; this is a second door, not a replacement.
This commit is contained in:
Marc-André Bombeck committed 2026-08-01 19:19:01 +02:00
1 parent fb1bf710e6
commit 0e301b580c
9 files changed
+843 -95

No files matched your search

+190
View File
@@ -7888,6 +7888,44 @@ paths:
"403": *a5
"422": *a3
"429": *a4
post:
tags:
- Insights
summary: Ingest one ECG recording
description: "The live ECG ingest: one Apple Watch recording per request, for a client draining its HealthKit ECG
observer. Before this existed, a watch ECG could only reach HealthLog inside a full `export.zip`. One recording
per request because a 30 s / 512 Hz strip is ~15 360 samples. Samples are INTEGER MICRO-VOLTS (convert from
HealthKit's Volts), stored AES-256-GCM encrypted; `sampleCount` and `durationSeconds` are derived server-side.
The `classification` is the device's own verdict stored verbatim — HealthLog never reads the waveform to produce
or revise one. `source` accepts `APPLE_HEALTH` only; `userId` comes from the session and is never a body field.
Unknown body keys are rejected with a 422 naming them. No `Idempotency-Key` is needed: the recording carries its
own identity, so a retry resolves to the same row by construction — see `status` for what a re-post reports.
Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights` and the
operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer."
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/EcgIngestRequest"
responses:
"200":
description: "The recording was already stored: `updated` (same id, overwritten in place) or `duplicate` (already
present under a different id, nothing written)."
content:
application/json:
schema:
$ref: "#/components/schemas/EcgIngestResponseEnvelope"
"201":
description: "A new recording was stored (`status: inserted`)."
content:
application/json:
schema:
$ref: "#/components/schemas/EcgIngestCreatedEnvelope"
"401": *a1
"403": *a5
"422": *a3
"429": *a4
/api/insights/ecg/{id}:
get:
tags:
@@ -14329,6 +14367,81 @@ components:
properties: {}
description: No body fields. The user is taken from the session / Bearer and the locale from the session; the warm
covers every assessment for that user.
EcgIngestRequest:
type: object
properties:
externalRecordingId:
type: string
minLength: 1
maxLength: 120
description: "The recording's stable id AS THE CLIENT KNOWS IT — for an Apple Health client, the `HKSample.uuid`. It is
the source's own identity, not a content hash: re-posting the same id overwrites that recording in place. It
does not have to agree with the id the `export.zip` importer derives for the same strip; the server
reconciles the two doors itself (see `status: duplicate`)."
recordedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
description: "When the strip was recorded on-device, with offset. Part of the recording's identity — see `status:
duplicate`."
samplingFrequency:
type: integer
minimum: 1
maximum: 10000
description: Signal sampling rate in Hz (an Apple Watch ECG is 512). Part of the recording's identity, and the divisor
for the server-derived `durationSeconds`.
samples:
minItems: 1
maxItems: 32768
type: array
items:
type: integer
minimum: -1000000
maximum: 1000000
description: The waveform as INTEGER MICRO-VOLTS, in recording order — the same unit the `export.zip` CSV parser
produces, so convert from HealthKit's Volts before sending. Capped at 32 768 samples (a clean factor of two
above a 30 s / 512 Hz strip). Stored AES-256-GCM encrypted; never persisted as plaintext.
lead:
description: Recording lead label when the device reports one (e.g. `I`).
anyOf:
- type: string
minLength: 1
maxLength: 40
- type: "null"
averageHeartRate:
description: The device's average heart rate (BPM) for the strip.
anyOf:
- type: integer
minimum: 1
maximum: 300
- type: "null"
classification:
description: The RECORDING DEVICE's own verdict, stored verbatim. Only the three ECG values are accepted here.
`RhythmClassification` in the database has six members because walking-steadiness and neutral event verdicts
share it, and those appear on GET /api/insights/rhythm-events — a decoder generated from this schema must
NOT be reused there. HealthLog never reads the waveform to produce or revise a verdict.
anyOf:
- type: string
enum:
- IRREGULAR
- NOT_DETECTED
- INCONCLUSIVE
- type: "null"
source:
type: string
enum:
- APPLE_HEALTH
description: "The client's own source. `APPLE_HEALTH` only: `WITHINGS` rows are minted by the OAuth sync and `COMPUTED`
ones by the server, so neither is client-assertable — the same posture POST /api/measurements/batch takes."
required:
- externalRecordingId
- recordedAt
- samplingFrequency
- samples
- source
description: One Apple Watch ECG recording. Unknown keys are REJECTED with a 422 naming them rather than silently
dropped, because every field here is load-bearing metadata for a stored recording. `sampleCount` and
`durationSeconds` are derived server-side and must not be sent.
UpdateCorrelationPatternRequest:
type: object
properties:
@@ -29594,6 +29707,83 @@ components:
- source
- hasWaveform
additionalProperties: false
EcgIngestResponseEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/EcgIngestResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
EcgIngestResponse:
type: object
properties:
id:
type: string
description: The recording row's id (cuid).
status:
type: string
enum:
- inserted
- updated
- duplicate
description: What the write did. `inserted` — a new recording landed (HTTP 201). `updated` — this `externalRecordingId`
was already stored and the row was overwritten in place (HTTP 200); a retry is safe and creates nothing.
`duplicate` — this exact recording is already stored under a DIFFERENT id, i.e. it arrived earlier through
the `export.zip` importer (HTTP 200); nothing was written and `id` names the row that already holds it. A
recording is the same recording when `(source, recordedAt, samplingFrequency)` match, which is enforced by a
unique index rather than by convention. All three statuses mean the recording is stored and the client may
advance its cursor.
recordedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
description: The `recordedAt` that was submitted, echoed back.
sampleCount:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: "Server-derived: the number of samples received."
durationSeconds:
anyOf:
- type: number
- type: "null"
description: "Server-derived: `sampleCount / samplingFrequency`."
required:
- id
- status
- recordedAt
- sampleCount
- durationSeconds
additionalProperties: false
description: The outcome of one ECG ingest. Reported honestly rather than optimistically — a re-post never claims to
have inserted.
EcgIngestCreatedEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/EcgIngestResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
EcgDetailResponseEnvelope:
type: object
properties: