mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
fb1bf710e6
commit
0e301b580c
9 files changed
+843
-95
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user