The schema — one table per domain
Everything downstream inherits the shape you choose here. Get the tables right and the API almost writes itself; get them wrong and every query starts with an apology. This is the first Members' lesson and it is deliberately the least glamorous one.
The decision: many small tables, not one big one
The tempting design is a single events table with a type column and a JSON payload. It feels flexible. It is a trap:
- Every query begins
WHERE type = …and every index has to carry that column. - Nothing can be
NOT NULL, because a column required for sleep is meaningless for a meal. The schema stops being able to state facts. - The database cannot help you. A typo in a JSON key is a silent null, not an error.
The working design has a table per domain — measurements, meals, workouts, sleep sessions, notes, plus the plumbing tables for OAuth and logging. A weight and a sleep session share almost nothing, so stop pretending they do.
A worked example
CREATE TABLE measurements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
taken_utc TEXT NOT NULL, -- always UTC, always ISO-8601
day TEXT NOT NULL, -- 'YYYY-MM-DD', derived, indexed
kind TEXT NOT NULL, -- weight | waist | systolic | resting_hr | vo2max
value REAL NOT NULL,
unit TEXT NOT NULL,
source TEXT NOT NULL, -- scale | watch | manual | import:apple
source_key TEXT, -- id from the source system, if any
created_utc TEXT NOT NULL
);
CREATE INDEX idx_meas_day ON measurements (day, kind);
CREATE UNIQUE INDEX idx_meas_dedup ON measurements (source, source_key)
WHERE source_key IS NOT NULL;
Four decisions are hiding in there, and each one is load-bearing.
1 · day exists even though taken_utc contains it
Deliberate duplication. You will group by day constantly, and substr(taken_utc,1,10) in a WHERE clause cannot use an index. Storing the date separately costs ten bytes and buys every query you will ever write.
It also survives the timestamp-format mess from lesson 8.3: however inconsistent taken_utc becomes over the years, day is one shape forever.
2 · source is not decoration
When two devices disagree about your weight — and they will — you need to know which row came from where before you can decide anything. A dataset that cannot tell you its own provenance is a dataset you cannot debug.
3 · The partial unique index is the idempotency key
This is the fix for the trap at the end of lesson 8.3. Aggregating imports are correct and dangerous: run the same file twice and you double the day. The unique index on (source, source_key) makes a repeat impossible rather than merely unlikely:
INSERT INTO measurements (taken_utc, day, kind, value, unit, source, source_key, created_utc)
VALUES (?1, substr(?1,1,10), ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(source, source_key) DO UPDATE SET
value = excluded.value, -- a corrected reading should win
taken_utc = excluded.taken_utc;
The WHERE source_key IS NOT NULL clause matters: manual entries have no source key, and you genuinely may weigh yourself twice in a morning. The constraint binds imports, not you.
4 · NOT NULL wherever it is true
Every nullable column is a branch you will write in every consumer, forever. Make the database refuse the bad row instead. A schema that permits nonsense will receive nonsense, on a Tuesday, from a script you wrote and forgot.
Daily aggregates: a second table, not a view
Some things are naturally per-day rather than per-event — steps, active minutes, total sleep. Give them their own table with the day as the primary key, and apply the aggregate-not-replace rule from lesson 8.3 there:
CREATE TABLE activity_daily (
day TEXT PRIMARY KEY,
steps INTEGER NOT NULL DEFAULT 0,
active_minutes INTEGER NOT NULL DEFAULT 0,
updated_utc TEXT NOT NULL
);
health_raw table with the untouched blob — until you trust the pipeline.Migrations, on a project with one user
You do not need a migration framework. You do need to stop editing schema.sql as if it were the database. Keep numbered files, apply them in order, and never rewrite one that has run:
migrations/0001-initial.sql
migrations/0002-add-source-key.sql
migrations/0003-nutrition-days.sql
npx wrangler d1 execute health-db --remote --file=migrations/0003-nutrition-days.sql
wrangler d1 export takes seconds and is the difference between a mistake and an incident. This is also where the free Level 3 lesson on backups stops being theoretical.