Database Schema

Health Observability uses Cloudflare D1 — a serverless SQLite-compatible database. The schema is managed through eight ordered migration files in migrations/.

Entity Relationship Diagram

erDiagram metric_types { INTEGER id PK TEXT name UK TEXT unit TEXT category TEXT description } measurement_sessions { TEXT id PK DATETIME recorded_at TEXT context TEXT notes } observations { TEXT id PK TEXT session_id FK INTEGER metric_id FK REAL value INTEGER reading_number BOOLEAN is_best DATETIME created_at } tags { INTEGER id PK TEXT name UK TEXT category } session_tags { TEXT session_id PK_FK INTEGER tag_id PK_FK } profile { INTEGER id PK REAL height_cm DATETIME updated_at } weight_entries { TEXT id PK REAL weight_kg TEXT notes DATETIME recorded_at } cpap_data { TEXT log_date PK REAL ahi INTEGER usage_minutes REAL mask_leak_pct INTEGER myair_score REAL pressure_min REAL pressure_max DATETIME synced_at } measurement_sessions ||--o{ observations : "has" metric_types ||--o{ observations : "defines" measurement_sessions ||--o{ session_tags : "tagged with" tags ||--o{ session_tags : "applied to"

Tables

metric_types

Seed table defining the five supported metric types. Populated once at migration time.

ColumnTypeNotes
idINTEGERAuto-increment primary key
nameTEXTUnique; one of systolic, diastolic, pulse, pulse_pressure, mean_arterial_pressure
unitTEXTe.g. mmHg
categoryTEXTGrouping label
descriptionTEXTHuman-readable description

measurement_sessions

One row per blood pressure measurement event. A single session captures three readings taken in sequence.

ColumnTypeNotes
idTEXTUUID v4 primary key
recorded_atDATETIMEAuto-set to CURRENT_TIMESTAMP on insert
contextTEXTOptional free-text context (e.g. “morning”, “post-exercise”)
notesTEXTOptional additional notes

observations

One row per individual metric value per reading per session. A typical session with 3 readings produces 9 observation rows (3 readings × 3 metrics: systolic, diastolic, pulse) plus 2 more for the best reading’s derived metrics (pulse_pressure, MAP) = 11 rows total.

ColumnTypeNotes
idTEXTUUID v4 primary key
session_idTEXTFK → measurement_sessions.id
metric_idINTEGERFK → metric_types.id
valueREALMeasured or computed value
reading_numberINTEGER1, 2, or 3 — which reading within the session
is_bestBOOLEAN1 for the best-selected reading; 0 otherwise
created_atDATETIMEAuto-set on insert

tags

Lookup table of user-defined session labels.

ColumnTypeNotes
idINTEGERAuto-increment primary key
nameTEXTUnique tag name (e.g. “post-exercise”, “stressed”)
categoryTEXTOptional grouping

session_tags

Many-to-many junction between sessions and tags.

ColumnTypeNotes
session_idTEXTFK → measurement_sessions.id, part of composite PK
tag_idINTEGERFK → tags.id, part of composite PK

profile

Singleton user record. Enforced as a single row via CHECK (id = 1) and seeded by a migration.

ColumnTypeNotes
idINTEGERAlways 1; constraint CHECK (id = 1)
height_cmREALUser’s height in centimetres; nullable
updated_atDATETIMEUpdated on every PUT /api/profile

weight_entries

Date-stamped weight measurements.

ColumnTypeNotes
idTEXTUUID v4 primary key
weight_kgREALWeight in kilograms (validated: 20–300)
notesTEXTOptional free-text
recorded_atDATETIMEAuto-set on insert

cpap_data

One row per sleep night, sourced from ResMed MyAir via the external sync worker.

ColumnTypeNotes
log_dateTEXTYYYY-MM-DD primary key
ahiREALApnea-Hypopnea Index (events/hour)
usage_minutesINTEGERTotal mask-on time in minutes
mask_leak_pctREALLarge leak percentage
myair_scoreINTEGERResMed proprietary score 0–100
pressure_minREALMinimum CPAP pressure (cmH₂O)
pressure_maxREALMaximum CPAP pressure (cmH₂O)
synced_atDATETIMETimestamp of last sync from MyAir

Indexes

IndexTableColumnsPurpose
idx_sessions_recorded_atmeasurement_sessionsrecorded_at DESCChronological paging of sessions
idx_obs_session_idobservationssession_idObservation lookup by session
idx_obs_metric_idobservationsmetric_idMetric-type filtering
idx_obs_is_bestobservationsis_bestBest-reading lookup
idx_weight_recorded_atweight_entriesrecorded_at DESCChronological paging of weight
idx_cpap_log_datecpap_datalog_date DESCChronological paging of CPAP

Migration History

Migrations are applied via wrangler d1 migrations apply health_observability. They must be run in order; Wrangler tracks applied migrations automatically.

timeline title Schema Migration History Migration 01 : CREATE metric_types Migration 02 : CREATE measurement_sessions Migration 03 : CREATE observations (FK to sessions + metric_types) Migration 04 : CREATE tags Migration 05 : CREATE session_tags (junction) Migration 06 : ADD indexes (sessions · observations) Migration 07 : ADD profile (singleton) · ADD weight_entries Migration 08 : ADD cpap_data

Migration 01 — metric_types

Creates the metric type registry. Seed data for the five metrics (systolic, diastolic, pulse, pulse_pressure, mean_arterial_pressure) is inserted separately.

Migration 02 — measurement_sessions

Creates the blood pressure session container table.

Migration 03 — observations

Creates the per-metric per-reading value store with foreign keys to sessions and metric types.

Migration 04 — tags

Creates the tag label registry.

Migration 05 — session_tags

Creates the session–tag junction table with a composite primary key.

Migration 06 — Indexes

Adds performance indexes on measurement_sessions(recorded_at DESC), observations(session_id), observations(metric_id), and observations(is_best).

Migration 07 — Profile & Weight

Adds the singleton profile table and the weight_entries table, including the idx_weight_recorded_at index and a seed INSERT OR IGNORE for the profile row.

Migration 08 — CPAP Data

Adds the cpap_data table and idx_cpap_log_date index to support the ResMed MyAir integration.


Key Query Patterns

Fetch paginated blood pressure sessions

SELECT s.id, s.recorded_at, s.context, s.notes,
       mt.name AS metric, o.value, o.reading_number, o.is_best
FROM measurement_sessions s
JOIN observations o ON o.session_id = s.id
JOIN metric_types mt ON mt.id = o.metric_id
ORDER BY s.recorded_at DESC
LIMIT ? OFFSET ?

The Worker fetches limit * 10 rows to account for the multiple observation rows per session, then slices to limit sessions in application code.

Delete a blood pressure session (atomic batch)

DELETE FROM session_tags   WHERE session_id = ?;
DELETE FROM observations   WHERE session_id = ?;
DELETE FROM measurement_sessions WHERE id = ?;

All three statements are executed as a single D1.batch([]) call to maintain referential integrity.

Insert a blood pressure session (batch)

The Worker builds an array of prepared statements:

  1. INSERT INTO measurement_sessions
  2. For each of the 3 readings × 3 metrics → INSERT INTO observations
  3. For the best reading’s 2 derived metrics → INSERT INTO observations
  4. For each tag → INSERT INTO session_tags

The entire array is committed atomically via env.DB.batch(stmts).