TimescaleDB (PostgreSQL 16) with the TimescaleDB extension enabled.
Migrations are managed by Flyway (db/migrations/). Never run manual SQL against the
production database — always add a new migration file.
| File | Contents |
|---|---|
V1__initial_schema.sql |
Core tables, hypertables, compression policies |
V2__user_auth.sql |
Adds garmin_email, garmin_linked, is_active, authelia_username to users |
V3__user_password.sql |
Adds password_hash to users |
V4__intensity_training_status.sql |
Adds intensity_moderate, intensity_vigorous, training_status to daily_summary |
V5__activity_training_effect.sql |
Adds aerobic_effect, anaerobic_effect to activities |
V6__sync_trigger.sql |
Adds sync_requested, last_sync_at to users |
V7__app_user.sql |
Creates least-privilege garmin_app DB user |
V8__ml_predictions.sql |
Creates ml_predictions table for ML model outputs |
V9__libre_glucose.sql |
Adds libre_linked/libre_email to users; creates glucose_readings hypertable |
V10__ml_trigger.sql |
Adds ml_requested, last_ml_at to users for on-demand ML trigger |
V11__activity_rpe.sql |
Adds user_rpe SMALLINT to activities (Foster RPE 1–10) |
V12__user_profile.sql |
Adds date_of_birth DATE, sex TEXT to users (required for Banister TRIMP) |
V13__running_economy.sql |
Adds running dynamics columns to activities (ground contact time, vertical oscillation, stride length, vertical ratio, running power) |
V14__fix_stride_length_precision.sql |
Widens avg_stride_length to NUMERIC(6,1) — Garmin returns cm, not meters |
V15__epilepsy.sql |
Adds epilepsy_mode BOOLEAN to users; creates seizure_events table |
V16__spo2_settings.sql |
Adds spo2_enabled BOOLEAN to users |
V17__account_lockout.sql |
Adds failed_login_attempts SMALLINT, locked_until TIMESTAMPTZ to users |
V18__email_verification.sql |
Adds email_verified_at TIMESTAMPTZ to users; backfills existing users |
V19__user_consents.sql |
Creates user_consents audit table for DSGVO Art. 5(2) accountability |
V20__user_tokens.sql |
Creates user_tokens table for Fernet-encrypted Garmin/LibreLink session tokens |
V21__consent_ip_hash.sql |
Replaces ip_address INET (PII) with ip_address_hash TEXT (SHA-256 prefix) in user_consents |
V22__password_reset_token.sql |
Adds password_reset_token_hash TEXT, password_reset_expires_at TIMESTAMPTZ to users |
V23__pending_deletion.sql |
Adds pending_deletion_at TIMESTAMPTZ to users (two-step account deletion with e-mail confirmation) |
V24__per_service_roles.sql |
Creates per-service least-privilege DB roles pulse_sync (ingest) and pulse_ml (read-only inference + ml_predictions write); column-level grants (ADR-0001) |
V25__ml_feedback.sql |
Creates ml_feedback table for 👍/👎 item-level feedback on ML predictions |
V26__single_use_tokens.sql |
Adds verification_token_hash, verification_token_expires_at, deletion_token_hash, deletion_token_expires_at to users — single-use, server-side-verified tokens (replaces replayable stateless itsdangerous payloads) |
V27__user_consent_events.sql |
Creates immutable user_consent_events audit table — append-only log of every consent change for GDPR Art. 5(2) accountability; user_consents remains the current-state table |
V28__session_version.sql |
Adds session_version INTEGER NOT NULL DEFAULT 0 to users — bumped on password reset to invalidate all existing signed session cookies |
V29__round_gps_precision.sql |
Rounds activity_records.lat/lng to 4 decimal places (~11 m) for GDPR Art. 5(1)(c) data minimisation |
V30__consent_audit_set_null.sql |
Changes user_consents and user_consent_events FK on user_id from ON DELETE CASCADE to ON DELETE SET NULL — pseudonymise instead of erase, preserving the consent audit trail (GDPR Art. 5(2)) |
V31__user_weight.sql |
Adds weight_kg FLOAT CHECK (weight_kg > 0 AND weight_kg <= 500) to users |
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK |
|
name |
TEXT NOT NULL |
Display name |
email |
TEXT NOT NULL UNIQUE |
Login identifier |
password_hash |
TEXT |
bcrypt hash |
garmin_linked |
BOOLEAN DEFAULT false |
Garmin account connected |
garmin_email |
TEXT |
Garmin Connect email |
is_active |
BOOLEAN DEFAULT true |
Soft-disable users |
authelia_username |
TEXT UNIQUE |
Unused (Authelia removed) |
libre_linked |
BOOLEAN DEFAULT false |
LibreLinkUp account connected (V9) |
libre_email |
TEXT |
LibreLinkUp email (V9) |
date_of_birth |
DATE |
Used for age calculation + Banister TRIMP (V12) |
sex |
TEXT CHECK (sex IN ('m','f','diverse')) |
Required for Banister TRIMP coefficients (V12) |
epilepsy_mode |
BOOLEAN DEFAULT false |
Enables seizure diary + risk indicator (V15) |
spo2_enabled |
BOOLEAN DEFAULT false |
SpO2 tracking opt-in (V16) |
failed_login_attempts |
SMALLINT DEFAULT 0 |
Brute-force counter; resets on success (V17) |
locked_until |
TIMESTAMPTZ |
Account locked until this time after 5 failures (V17) |
email_verified_at |
TIMESTAMPTZ |
Set on first email verification; NULL = unverified (V18) |
password_reset_token_hash |
TEXT |
bcrypt hash of active reset token; NULL when no reset pending (V22) |
password_reset_expires_at |
TIMESTAMPTZ |
Expiry of active reset token; token cleared after use (V22) |
pending_deletion_at |
TIMESTAMPTZ |
Set when account deletion is requested; cleared/executed via signed confirmation link (V23) |
session_version |
INTEGER NOT NULL DEFAULT 0 |
Bumped on password reset to invalidate all existing signed session cookies (V28) |
weight_kg |
FLOAT CHECK (>0 AND ≤500) |
Body weight in kg (V31) |
created_at |
TIMESTAMPTZ DEFAULT NOW() |
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK |
|
user_id |
INTEGER → users(id) |
|
garmin_activity_id |
BIGINT UNIQUE |
Deduplication key |
started_at |
TIMESTAMPTZ NOT NULL |
Activity start (not start_time!) |
duration_seconds |
INTEGER |
|
sport_type |
TEXT NOT NULL |
e.g. running, cycling, swimming |
distance_meters |
FLOAT |
|
calories |
INTEGER |
(not total_calories!) |
avg_hr |
SMALLINT |
(not avg_heart_rate!) |
max_hr |
SMALLINT |
|
avg_pace_sec_per_km |
FLOAT |
Seconds per km |
avg_cadence |
SMALLINT |
Steps or RPM |
avg_power |
SMALLINT |
Watts (cycling) |
elevation_gain |
FLOAT |
Meters |
avg_speed_kmh |
FLOAT |
|
aerobic_effect |
FLOAT |
Garmin aerobic training effect 1–5 (V5) |
anaerobic_effect |
FLOAT |
Garmin anaerobic training effect 1–5 (V5) |
user_rpe |
SMALLINT CHECK (1–10) |
User-entered RPE via Foster method (V11) |
avg_ground_contact_time |
INTEGER |
Ground contact time in ms, running only (V13) |
avg_vertical_oscillation |
NUMERIC(4,1) |
Vertical oscillation in cm, running only (V13) |
avg_stride_length |
NUMERIC(6,1) |
Stride length in cm, running only — added in V13 as NUMERIC(4,2), widened to NUMERIC(6,1) in V14 |
avg_vertical_ratio |
NUMERIC(4,1) |
Vertical ratio %, running only (V13) |
avg_running_power |
INTEGER |
Running power in W, requires sensor (V13) |
created_at |
TIMESTAMPTZ DEFAULT NOW() |
Index: (user_id, started_at DESC)
Primary key: (date, user_id) — one row per user per day, upserted on sync.
| Column | Type | Notes |
|---|---|---|
date |
DATE NOT NULL |
|
user_id |
INTEGER → users(id) |
|
steps |
INTEGER |
(not total_steps!) |
calories_total |
INTEGER |
|
avg_stress |
SMALLINT |
0–100 |
max_stress |
SMALLINT |
|
avg_spo2 |
SMALLINT |
% |
min_spo2 |
SMALLINT |
|
body_battery_high |
SMALLINT |
0–100 |
body_battery_low |
SMALLINT |
|
resting_hr |
SMALLINT |
bpm |
intensity_moderate |
SMALLINT |
Minutes of moderate intensity (V4) |
intensity_vigorous |
SMALLINT |
Minutes of vigorous intensity (V4) |
training_status |
TEXT |
e.g. PRODUCTIVE, MAINTAINING (V4) |
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK |
|
user_id |
INTEGER → users(id) |
|
garmin_sleep_id |
BIGINT UNIQUE |
Deduplication key |
start_time |
TIMESTAMPTZ NOT NULL |
|
end_time |
TIMESTAMPTZ NOT NULL |
|
total_sleep_seconds |
INTEGER |
(not duration_seconds!) |
deep_sleep_seconds |
INTEGER |
|
light_sleep_seconds |
INTEGER |
|
rem_sleep_seconds |
INTEGER |
|
awake_seconds |
INTEGER |
|
sleep_score |
SMALLINT |
0–100 Garmin score |
Index: (user_id, start_time DESC)
Primary key: (date, user_id) — upserted on sync.
| Column | Type | Notes |
|---|---|---|
date |
DATE NOT NULL |
|
user_id |
INTEGER → users(id) |
|
hrv_last_night |
SMALLINT |
ms (not hrv_last_night_avg!) |
hrv_weekly_avg |
SMALLINT |
ms (not weekly_avg!) |
hrv_status |
TEXT |
balanced / unbalanced / poor (not status!) |
Primary key: (date, user_id, model) — one row per user per day per model, upserted on inference.
| Column | Type | Notes |
|---|---|---|
date |
DATE NOT NULL |
Inference date |
user_id |
INTEGER → users(id) |
|
model |
TEXT NOT NULL |
Free TEXT model key. ~15+ values written by ml-service, e.g. anomaly_hr, anomaly_sleep, anomaly_spo2, anomaly_steps, anomaly_stress, readiness_rf, correlation_sleep_hrv, correlation_sleep_rhr, correlation_bb_rhr, acwr, training_monotony, spo2_trend, body_battery_custom, stress_score_custom, running_economy, sleep_consistency, hrv_recovery, battery_pattern, energy_physical, energy_autonomic, energy_cognitive |
value |
FLOAT |
Primary output (z-score, predicted score, or Pearson r) |
metadata |
JSONB |
Additional fields (e.g. is_anomaly, p_value, n_samples) |
created_at |
TIMESTAMPTZ DEFAULT NOW() |
Index: (user_id, date DESC)
Binary 👍/👎 feedback on ML predictions — one row per user per model per day. Added in V25.
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK |
|
user_id |
INTEGER → users(id) ON DELETE CASCADE |
|
model |
TEXT NOT NULL |
Model key matching ml_predictions.model |
prediction_date |
DATE NOT NULL |
Stamped server-side to CURRENT_DATE |
helpful |
BOOLEAN NOT NULL |
true = 👍, false = 👎 |
created_at |
TIMESTAMPTZ NOT NULL DEFAULT NOW() |
|
updated_at |
TIMESTAMPTZ NOT NULL DEFAULT NOW() |
UNIQUE on (user_id, model, prediction_date) — makes feedback idempotent/toggleable via ON CONFLICT upsert.
Index: (user_id, prediction_date DESC)
Hypertable for continuous glucose monitor (CGM) readings via LibreLinkUp (Libre 3). ~1 reading per minute = ~1,440 rows/day/user. Added in V9.
| Column | Type | Notes |
|---|---|---|
time |
TIMESTAMPTZ NOT NULL |
Reading timestamp (partition key) |
user_id |
INTEGER → users(id) |
|
value_mgdl |
REAL NOT NULL |
Glucose in mg/dL |
trend |
SMALLINT |
1=FallingQuickly, 2=Falling, 3=Stable, 4=Rising, 5=RisingQuickly |
is_high |
BOOLEAN |
Sensor-flagged high value |
is_low |
BOOLEAN |
Sensor-flagged low value (hypoglycemia) |
UNIQUE INDEX on (user_id, time) — upserted with ON CONFLICT DO NOTHING.
Compression: after 7 days, segmented by user_id.
Manual seizure diary entries. Added in V15.
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK |
|
user_id |
INTEGER → users(id) |
Cascade delete |
occurred_at |
TIMESTAMPTZ NOT NULL |
When the seizure happened |
duration_seconds |
INTEGER |
Optional |
type |
TEXT CHECK ('focal','generalized','unknown') |
Default unknown |
severity |
SMALLINT CHECK (1–5) |
1=very mild, 5=very severe; optional |
notes |
TEXT |
Free text; optional |
created_at |
TIMESTAMPTZ DEFAULT NOW() |
Index: (user_id, occurred_at DESC)
Entries are editable and deletable from the diary UI via PATCH/DELETE /api/seizures/{id}; both DB helpers (update_seizure, delete_seizure) filter on
id AND user_id so a user can only mutate their own rows.
Audit log for user consent (DSGVO Art. 5(2) — Rechenschaftspflicht). Added in V19.
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK |
|
user_id |
INTEGER → users(id) ON DELETE SET NULL |
Pseudonymised (not erased) on user delete to preserve the audit trail (V30; was ON DELETE CASCADE in V19) |
consent_type |
TEXT NOT NULL |
e.g. health_data (Art. 9 explicit consent) |
accepted |
BOOLEAN NOT NULL |
true = consent given |
timestamp |
TIMESTAMPTZ NOT NULL DEFAULT NOW() |
When consent was recorded |
ip_address_hash |
TEXT |
SHA-256 prefix hash of request IP (DSGVO Art. 5(1)(c) data minimisation, V21) |
privacy_policy_version |
TEXT NOT NULL DEFAULT 'v1.0' |
Policy version the user consented to |
UNIQUE on (user_id, consent_type) — upserted on re-registration via ON CONFLICT DO UPDATE.
Fernet-encrypted session tokens for third-party service accounts. Added in V20.
| Column | Type | Notes |
|---|---|---|
user_id |
INT → users(id) ON DELETE CASCADE |
Token deleted automatically when user account is deleted |
service |
TEXT CHECK (service IN ('garmin', 'libre')) |
|
token_data |
BYTEA NOT NULL |
Fernet-encrypted JSON blob (filename-agnostic serialization of garth token dir) |
updated_at |
TIMESTAMPTZ NOT NULL DEFAULT NOW() |
Set on every upsert |
PRIMARY KEY on (user_id, service) — upserted on every successful connect/sync via ON CONFLICT DO UPDATE.
Tokens are encrypted with FERNET_KEY (AES-128-CBC + HMAC-SHA256). If FERNET_KEY is not set, tokens are stored unencrypted with a startup warning. The same key must be set in both env/.env.api and env/.env.sync.
Hypertables partition data by time. Used for high-frequency intraday data.
GPS + HR time-series per activity (~1 row/sec during activity).
| Column | Type |
|---|---|
time |
TIMESTAMPTZ NOT NULL |
activity_id |
INTEGER → activities(id) |
user_id |
INTEGER |
heart_rate |
SMALLINT |
pace_sec_per_km |
FLOAT |
cadence |
SMALLINT |
power |
SMALLINT |
elevation |
FLOAT |
distance |
FLOAT |
lat |
DOUBLE PRECISION |
lng |
DOUBLE PRECISION |
Compression: after 7 days, segmented by activity_id.
| Column | Type | Notes |
|---|---|---|
time |
TIMESTAMPTZ NOT NULL |
|
user_id |
INTEGER |
|
value |
SMALLINT NOT NULL |
0–100 |
Compression: after 30 days.
Same structure as body_battery_intraday. Value 0–100.
Compression: after 30 days.
Same column structure as body_battery_intraday (time, user_id, value); value = SpO2 percentage.
No compression policy — this hypertable is not compressed (unlike body_battery_intraday and stress_intraday).
| Column | Type | Notes |
|---|---|---|
time |
TIMESTAMPTZ NOT NULL |
|
sleep_session_id |
INTEGER → sleep_sessions(id) |
|
user_id |
INTEGER |
|
level |
TEXT NOT NULL |
deep / light / rem / awake |
-- Resting HR trend last 90 days
SELECT date, resting_hr
FROM daily_summary
WHERE user_id = 1 AND date >= NOW() - INTERVAL '90 days'
ORDER BY date;
-- Sleep score average per month
SELECT date_trunc('month', start_time) AS month, AVG(sleep_score)
FROM sleep_sessions
WHERE user_id = 1
GROUP BY 1 ORDER BY 1;
-- Body battery hourly average (TimescaleDB time_bucket)
SELECT time_bucket('1 hour', time) AS hour, AVG(value)
FROM body_battery_intraday
WHERE user_id = 1 AND time >= NOW() - INTERVAL '7 days'
GROUP BY 1 ORDER BY 1;
-- HRV last night vs resting HR correlation
SELECT h.date, h.hrv_last_night, d.resting_hr
FROM hrv_daily h
JOIN daily_summary d USING (date, user_id)
WHERE h.user_id = 1
ORDER BY h.date DESC;