← Back to the course

This is the production spec — the contract the course builds toward. The guided course teaches you to reach exactly this runnable result. Skim it if you'd rather build straight from the target.

Vitals — Project Spec

Single source of truth for the Vitals course (src/content/projects/vitals.mdx). The course must teach toward this; every step’s prerequisites exist by the time the step needs them. Spotlight: Kotlin Multiplatform offline-first sync. Backends: Go (default) + Spring Boot (Kotlin), parity-checked.

1. Overview & definition of done

Vitals is an offline-first health & fitness tracker. The device is the primary store: you can add a weight or read steps with the network off, and the app reconciles with a server when connectivity returns, deterministically — two phones that edited the same record offline converge to one value, identically on iOS and Android. The hard parts (data model, unit math, validation, local store, the dirty/cursor protocol, and the merge rule) live once in Kotlin Multiplatform commonMain; SwiftUI and Jetpack Compose stay native to reach HealthKit / Health Connect; Flutter is a single-codebase alternative that re-implements the offline store in Dart over the same HTTP contract. The sync server is a thin shell (Go or Spring) that enforces the same version rule on the wire.

Definition of done — what the learner ends with, runnable for $0 locally:

  1. A KMP :shared module that builds for Android + iOS, with green commonTest proving the merge is total, commutative, and convergent.
  2. A sync server (Go or Spring) running against a local Docker Postgres, with db/schema.sql applying cleanly on an empty database, and a green integration test of the version guard + cursor pull.
  3. One native client (the default is SwiftUI) that: stores measurements locally via the shared store, reads the device-stored sync token, calls POST /sync/push + GET /sync/pull through a shared Ktor client, and merges results with the shared Syncer.
  4. The payoff the learner SEES: run the iOS simulator and an Android emulator (or two simulators) against the local server, turn networking off, edit the same weight on both to different values, turn networking on, sync, and watch both land on the same value (the higher version). No paid service.

Free-to-complete: local Docker Postgres, the Android emulator + iOS simulator (simulator/emulator are free; an iOS device needs an Apple account but the simulator does not), and — for the optional AI features only — a free Google AI Studio Gemini key. Nothing here costs money.

Platform prerequisite (state it up front in the course): building the iOS target of a KMP project requires macOS + Xcode. The Android target builds on macOS/Linux/Windows. A learner on Linux/Windows can complete the entire shared layer, the server, and the Jetpack Compose path; only the SwiftUI/iOS target needs a Mac. The default frontend is SwiftUI, so call the Mac requirement out before the scaffold.

2. Architecture (prose diagram)

  ┌─────────────── device (offline-first) ───────────────┐
  │  Native UI (SwiftUI / Compose)   Flutter (alt, Dart)  │
  │        │  consumes :shared             │ HTTP only    │
  │        ▼                               ▼              │
  │  ┌────────── KMP :shared (commonMain) ──────────┐     │
  │  │ Measurement model · canonical()/validate()   │     │
  │  │ MeasurementStore (SQLDelight/SQLite)         │     │
  │  │   upsert·upsertClean·byId·allByKind·dirty    │     │
  │  │   clearDirty · cursor/setCursor (sync_state) │     │
  │  │ Syncer.sync()  ── merge(local,remote) ──┐    │     │
  │  │ SyncApi (Ktor HttpClient) ──────────────┼──┐ │     │
  │  │ expect SecureStore · expect HealthBridge│  │ │     │
  │  └─────────┬───────────────┬───────────────┘  │ │     │
  │   actual: Keychain     actual: HealthKit /     │ │     │
  │   / EncryptedPrefs     Health Connect          │ │     │
  └─────────────────────────────────────────────┼─┘     │
                                                  │ HTTPS │
                        Authorization: Bearer <token>     │
                                                  ▼        ▼
        ┌──────────── sync server (Go default | Spring) ──────────┐
        │  auth middleware → user_id   POST /sync/push  GET /sync/pull
        │  same version guard:  ON CONFLICT … WHERE version < EXCLUDED.version
        │  append-only sync_log (seq = pull cursor)                │
        │  (optional) GET /insights/weekly · POST /parse  → Gemini │
        └──────────────────────┬──────────────────────────────────┘

                   Postgres 16 (users, measurements, sync_log)
  • Source of truth on-device is SQLite (via SQLDelight). The server is the durable cross-device rendezvous and the privacy/integrity boundary.
  • The merge rule is written once in commonMain and also enforced by the server’s conditional upsert, so the client and server can never disagree about who wins a conflict.
  • The cursor is sync_log.seq: the client stores the last seq it pulled; pull returns rows with a greater seq plus the new max. This is monotonic and append-only, so pulls are cheap and replayable.

3. Runnable structure (the repo the learner ends with)

vitals/
  shared/                                   # KMP module, builds Android + iOS
    src/commonMain/kotlin/health/
      Model.kt          # MetricKind, MetricUnit, Measurement (incl. nullable label), bumpVersion()
      Units.kt          # canonical(), validate()
      MeasurementStore.kt  # wraps SQLDelight; the FULL store API the Syncer needs
      Syncer.kt         # merge(local,remote) + sync() loop
      SyncApi.kt        # interface: push(records)->Pushed, pull(cursor)->Pulled
      KtorSyncApi.kt    # SyncApi impl over Ktor HttpClient (base URL + Bearer token)
      Parsed.kt         # (feature nl-logging) ParsedEntry + toMeasurement()
      SecureStore.kt    # expect class
      HealthBridge.kt   # expect class
    src/commonMain/sqldelight/health/
      Measurement.sq    # measurement table + queries
      SyncState.sq      # one-row cursor table + queries
      migrations/1.sqm  # (feature nl-logging) adds the label column, v1 -> v2
    src/commonTest/kotlin/health/   # merge totality + two-device convergence tests
    src/androidMain/kotlin/health/  # SecureStore (EncryptedSharedPreferences), HealthBridge (Health Connect)
    src/iosMain/kotlin/health/      # SecureStore (Keychain), HealthBridge (HealthKit)
  iosApp/                # SwiftUI app; owns HealthKit capability + Info.plist usage keys
  androidApp/            # Compose app; owns Health Connect manifest perms + rationale activity
  flutterApp/            # (frontend=flutter) Dart UI + its own offline store, talks HTTP only
  server/                # the selected backend
    go/   cmd/server/main.go  internal/{auth,sync,store,httpx}/…   db/schema.sql   docker-compose.yml
    # or
    spring/ src/main/kotlin/…  (Application.kt, SyncController, SyncService)  db/schema.sql  docker-compose.yml
    prompts/                  # (features) Gemini prompt templates, server-side only

App entrypoint (composition) — the server main must COMPOSE everything

The Go cmd/server/main.go (and the Spring Application.kt) is a first-class, required deliverable. It must:

  1. Open the pool — local: parse DATABASE_URL; Cloud Run: build a DSN with host=/cloudsql/$INSTANCE_CONNECTION_NAME (see §9). Ping once; fail fast.
  2. Build the router with the routes in §5, wrapping protected routes in auth middleware that resolves a user_id (UUID) from the Authorization: Bearer <token> header and puts it in the request context.
  3. Register handlers: POST /sync/push, GET /sync/pull, plus (when features are on) GET /insights/weekly, POST /parse.
  4. Start the server and shut down gracefully — trap SIGINT/SIGTERM (signal.NotifyContext), then srv.Shutdown(ctx) with a timeout, and close the pool. Cloud Run sends SIGTERM; this keeps in-flight syncs from being cut. (Mirror the Go track’s graceful-shutdown step.)

Key interfaces named explicitly

Kotlin — commonMain (the contract the Syncer composes):

interface MeasurementStore {
    fun upsert(m: Measurement)                 // marks dirty = 1 (local edit, awaiting push)
    fun upsertClean(m: Measurement)            // merged-from-server: dirty = 0
    fun byId(id: String): Measurement?
    fun allByKind(kind: MetricKind): List<Measurement>
    fun dirty(): List<Measurement>             // rows with dirty = 1
    fun clearDirty(ids: List<String>)          // accepted ids -> dirty = 0
    fun cursor(): Long                          // last pulled sync_log.seq (0 if none)
    fun setCursor(value: Long)
}

interface SyncApi {                            // implemented by KtorSyncApi
    suspend fun push(records: List<Measurement>): Pushed   // { acceptedIds: List<String> }
    suspend fun pull(cursor: Long): Pulled                 // { records: List<Measurement>, nextCursor: Long }
}

Go — server (consumer-owned interface, Clean-Architecture friendly):

type Store interface {
    Push(ctx context.Context, userID string, recs []Measurement) (acceptedIDs []string, err error)
    Pull(ctx context.Context, userID string, cursor int64) (recs []Measurement, nextCursor int64, err error)
}
// PgStore implements Store over *pgxpool.Pool. Handlers depend on Store, not pgx.

4. Data model

On-device (SQLite via SQLDelight)

measurement (v1):

columntypenotes
idTEXT PKclient-generated UUID, stable across sync
kindTEXT NOT NULLMetricKind name
value_REAL NOT NULLcanonical unit; trailing _ dodges the SQLDelight/SQL keyword — the Kotlin/Go/Postgres field is value (call this out so a learner doesn’t mis-map)
unitTEXT NOT NULLcanonical MetricUnit name
recorded_atINTEGER NOT NULLepoch millis (see wire format below)
updated_atINTEGER NOT NULLepoch millis, drives merge
versionINTEGER NOT NULLmonotonic per record
dirtyINTEGER NOT NULL DEFAULT 11 = not yet pushed
labelTEXTadded at v2 by migrations/1.sqm (feature nl-logging); nullable, default null

Index: measurement_dirty ON measurement(dirty).

sync_state (the on-device cursor — a one-row key/value table so cursor()/setCursor() have a home):

CREATE TABLE sync_state (
  id     INTEGER NOT NULL PRIMARY KEY DEFAULT 0 CHECK (id = 0),  -- exactly one row
  cursor INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO sync_state(id, cursor) VALUES (0, 0);      -- seed the single row

Server (Postgres 16) — db/schema.sql, must apply on an EMPTY DB

Order matters — users is created BEFORE measurements because of the FK. A minimal users table exists so the schema is self-contained and the version guard has a real owner; full auth is out of scope (a one-paragraph note). Seed one user so the local path runs end-to-end.

-- 1) Identity. Minimal on purpose; real auth/issuance is out of scope (see note).
CREATE TABLE users (
  id    UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
  token TEXT NOT NULL UNIQUE        -- the Bearer token the device stores; maps a request to this user_id
);

-- Seed a deterministic dev user so a fresh local DB runs end-to-end with no auth UI.
INSERT INTO users (id, token)
VALUES ('00000000-0000-0000-0000-000000000001', 'dev-token')
ON CONFLICT DO NOTHING;

-- 2) The synced records. FK target (users) now exists.
CREATE TABLE measurements (
  id          UUID             NOT NULL,
  user_id     UUID             NOT NULL REFERENCES users(id),
  kind        TEXT             NOT NULL,
  value       DOUBLE PRECISION NOT NULL,        -- canonical unit only
  unit        TEXT             NOT NULL,
  recorded_at TIMESTAMPTZ      NOT NULL,
  updated_at  TIMESTAMPTZ      NOT NULL,
  version     BIGINT           NOT NULL CHECK (version > 0),
  label       TEXT,                              -- nullable; symptom text (feature nl-logging)
  PRIMARY KEY (user_id, id)
);
CREATE INDEX measurements_user_time    ON measurements (user_id, recorded_at);
CREATE INDEX measurements_user_updated ON measurements (user_id, updated_at);

-- 3) Append-only audit + pull cursor.
CREATE TABLE sync_log (
  seq        BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id    UUID        NOT NULL,
  record_id  UUID        NOT NULL,
  version    BIGINT      NOT NULL,
  applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX sync_log_user_seq ON sync_log (user_id, seq);

gen_random_uuid() is built into Postgres 13+ (the pgcrypto/core function); no extension needed on PG16.

Auth / identity (out of scope, but pinned so everything connects): a real app issues the token after login. Here, users.token is the device’s stored Bearer token; the server’s auth middleware looks it up to resolve user_id. The course seeds dev-token so the device can sync immediately. The KMP SecureStore holds that token on-device; KtorSyncApi attaches it as Authorization: Bearer <token>. No sync query ever trusts a body-supplied user id — it always comes from the looked-up principal.

The recordedAt / updatedAt wire format (the cross-platform contract — pin it)

LayerRepresentation
KMP domain (Measurement)kotlinx.datetime.Instant
On-device SQLiteepoch millis (INTEGER) — mapped in MeasurementStore
On the wire (JSON)RFC 3339 / ISO-8601 string ("2026-06-20T07:30:00Z") — Instant serialises to this with kotlinx-serialization; Go uses time.Time (RFC3339); Postgres TIMESTAMPTZ
Server PostgresTIMESTAMPTZ

The single rule: the wire is RFC 3339 UTC strings; millis is an on-device storage detail only. Client, Go, Spring, and Postgres all agree on RFC 3339 over JSON.

5. API & event contract (canonical — every step, client, and test shares this)

All sync routes require Authorization: Bearer <token>; the server resolves user_id from it. Bodies are JSON. Measurement JSON shape (one canonical shape everywhere):

{
  "id": "f1c2…-uuid", "kind": "WEIGHT", "value": 72.3, "unit": "KG",
  "recordedAt": "2026-06-20T07:30:00Z", "updatedAt": "2026-06-20T07:31:00Z",
  "version": 3, "label": null
}

kind ∈ {WEIGHT, STEPS, HEART_RATE, ACTIVE_ENERGY, DISTANCE, SYMPTOM}; unit ∈ {KG, LB, COUNT, BPM, KCAL, KM, MI}. value/unit are canonical on the wire.

label is feature-gated, not in the base shape. The base course (Model.kt, Measurement.sq, the server schema.sql, the DTOs, and both push handlers) carries no label field; MetricKind is the 5-value base set. The label column/field/JSON key and MetricKind.SYMPTOM are added only by the nl-logging feature — on-device via migrations/1.sqm (v1 → v2), server-side via an ALTER TABLE, and on the wire as a nullable key (null except for SYMPTOM). The JSON above shows the v2 (post-nl-logging) shape; without the feature, omit label everywhere. This keeps the base build minimal and lets the migration prove the backward-compatible column.

POST /sync/push

  • Request: { "records": [Measurement, …] }
  • Response 200: { "acceptedIds": ["id", …] } — ids whose write won the version guard (stale ones omitted).
  • Behaviour: one transaction; per record INSERT … ON CONFLICT (user_id,id) DO UPDATE SET value, unit, recorded_at, updated_at, version, label = EXCLUDED.* WHERE measurements.version < EXCLUDED.version RETURNING id. A returned row → append sync_log (same tx) + add id to acceptedIds. No row → stale, skip.
  • Errors: 400 malformed JSON; 401 missing/unknown token; 500 DB error (whole tx rolls back).

GET /sync/pull?cursor=N

  • Request: query cursor (int64, default 0).
  • Response 200: { "records": [Measurement, …], "nextCursor": M } — every record whose latest sync_log.seq > N for this user, plus nextCursor = max(seq) (or N if nothing changed).
  • Query (the JOIN — sync_log has no payload): group sync_log by record_id to the per-record max(seq), keep only records whose max seq > $cursor, then join back to measurements to fetch the row body (sync_log stores only record_id/version/seq, never the value/unit/…). nextCursor is max(seq) across the whole post-cursor set (or echo the request cursor when the set is empty). All scoped by user_id. The course shows this SQL inline in both backend endpoint steps.
  • Errors: 400 non-integer cursor; 401 bad token.

GET /insights/weekly (feature ai-insights, off by default)

  • Response 200: { "insight": "…" | null }. Aggregates the user’s current week in SQL (numbers only), sends only those numbers to Gemini, returns a 2–3 sentence summary. Any Gemini error/timeout → 200 {"insight": null} (degrades gracefully). 401 bad token.

POST /parse (feature nl-logging, off by default)

  • Request: { "phrase": "ran 5k this morning, felt dizzy after, weight 72.3kg" }
  • Response 200: { "entries": [ { "kind", "value", "unit", "recordedAt", "label"? }, … ] } — typed candidates from Gemini structured output. Persists nothing. Any Gemini error/timeout → 200 {"entries": []}. 400 missing phrase; 401 bad token. Send only the phrase + request time (resolves “this morning”).

Parity point: Go and Spring expose byte-identical contracts for all four routes; the KMP SyncApi and toMeasurement() mapping are unchanged whichever backend the learner picks. A backend’s only job is to honour the version guard and the cursor semantics.

6. Build order (dependency-ordered — each step’s prerequisites already exist)

Common KMP core (spotlight):

  1. KMP scaffold (note the macOS/Xcode requirement for iOS) → :shared builds Android; iOS on a Mac.
  2. Shared domain model Measurement (+ the bumpVersion() helper: new = v1; edit = v+1, updatedAt = now, dirty = 1 — the version lifecycle the whole merge depends on).
  3. Unit normalisation + validation (canonical() / validate()).
  4. Local store with SQLDelight — the FULL MeasurementStore the Syncer will consume: measurement table, sync_state cursor table, and queries for upsert/upsertClean/byId/allByKind/dirty/clearDirty/ cursor/setCursor. (This is the step the review flagged as incomplete — it now builds the whole API.)
  5. expect/actual SecureStore (Keychain / EncryptedSharedPreferences) — holds the Bearer token.
  6. expect/actual HealthBridge (HealthKit / Health Connect) — with the Android manifest + rationale activity wiring inline, since the Compose track has no Health Connect step (parity fix).
  7. The SyncApi interface + KtorSyncApi (base URL, reads the token from SecureStore, attaches the Bearer header, RFC 3339 timestamps) — so the Syncer has a real client, not a hand-wave.
  8. ★ The sync engine: merge(local, remote) (total + commutative, with the tie rule below) and Syncer.sync() composing the store + SyncApi from steps 4 & 7.
  9. commonTest: merge totality table + two-device convergence (A-then-B == B-then-A).

Server (Go default + Spring parity): 10. Postgres db/schema.sqlusers (seeded) → measurementssync_log; applies on an empty DB. 11. Local Docker Postgres (docker-compose.yml), apply the schema. 12. The sync endpoints + main composition + auth middleware + graceful shutdown (Go) / Spring app. 13. Integration test: version guard + cursor pull against a real Postgres.

Run it end-to-end (the payoff): 14. Wire & run: point KtorSyncApi at the local server, store the dev-token in SecureStore, sync from a native client; then the “watch two devices converge” demo using the build already in hand.

Frontends (one chosen): 15. SwiftUI / Jetpack Compose (consume :shared) — Flutter re-implements the offline store in Dart.

Deploy (common): 16. Cloud Run + Cloud SQL via the connector socket (not a TCP postgres://), aligned with the GCP track.

Optional features (off by default), authored last: ai-insights (aggregate → Gemini → card), nl-logging (shared mapping + the .sqm label migration → Go /parse → Spring /parse → capture UI).

7. Backends — parity points

ConcernGo (default)Spring Boot (Kotlin)
HTTPnet/http + routerspring-boot-starter-web
DB accesspgx / pgxpool, parameterisedJdbcTemplate, parameterised
Transactionexplicit tx.Begin/Commit, defer Rollback@Transactional
Version guardON CONFLICT … WHERE measurements.version < EXCLUDED.version RETURNING idbyte-identical SQL
Stale writepgx.ErrNoRows → skipqueryForList empty → skip
Cursor pullsync_log.seq > $cursor, nextCursor = max(seq)identical
Authmiddleware → user_id in contextsecurity principal → user_id
Shutdownsignal.NotifyContext + srv.ShutdownSpring lifecycle (graceful by default)
/parse, /insightssame JSON contractsame JSON contract

Both must pass the same observable checks: stale push leaves the stored row intact and its id is not in acceptedIds; pull returns exactly the post-cursor records with a strictly larger nextCursor.

8. Optional feature modules (how each extends the spec; off by default)

  • ai-insights — adds the weekly SQL rollup + GET /insights/weekly (Gemini, numbers only) + a dismissible insight card per UI. Never load-bearing; tracking/sync work with it off. Privacy boundary: aggregates leave the device, raw rows never do.
  • nl-logging — adds MetricKind.SYMPTOM + a nullable label to the domain and the .sqm migration that adds the label column on-device (proves the “backward-compatible column” claim) and the label column server-side, then POST /parse (Go + Spring parity) and a confirm-before-save capture UI. Reuses the existing canonical()/validate()/MeasurementStore.upsert/Syncerno new sync path.

9. Free-to-complete ($0) & deploy

Local ($0): Docker Postgres (docker compose up -d), psql -f db/schema.sql, run the server, run the Android emulator and/or iOS simulator. The optional AI features use a free Google AI Studio Gemini key in the server env only.

Deploy (Cloud Run + Cloud SQL) — use the connector socket, NOT a TCP URL:

gcloud sql instances create vitals-pg \
  --database-version=POSTGRES_16 --tier=db-f1-micro --region=europe-west1
gcloud sql databases create vitals --instance=vitals-pg
gcloud sql users create app_user --instance=vitals-pg --password='CHANGE_ME'
CONNECTION_NAME=$(gcloud sql instances describe vitals-pg --format='value(connectionName)')

# Store the COMPLETE socket DSN (password included) in Secret Manager — the app reads DATABASE_URL whole,
# the same var as local `go test`. `--set-env-vars` is NOT interpolated and pgx does NOT expand $VAR inside
# a DSN, so the full string (not a template) must be what's stored.
printf 'postgres://app_user:CHANGE_ME@/vitals?host=/cloudsql/%s' "$CONNECTION_NAME" \
  | gcloud secrets create vitals-database-url --data-file=-

gcloud run deploy vitals-sync \
  --source . --region europe-west1 \
  --add-cloudsql-instances "$CONNECTION_NAME" \
  --set-env-vars "INSTANCE_CONNECTION_NAME=$CONNECTION_NAME" \
  --set-secrets "DATABASE_URL=vitals-database-url:latest"

--tier=db-f1-micro is the current shared-core tier (the GCP track uses the same). On Cloud Run the app reads the full /cloudsql/... socket DSN from DATABASE_URL (delivered via Secret Manager so the password never sits in plaintext env); locally the same DATABASE_URL points at a TCP Postgres. No public IP on the deployed instance — connection is over the mounted /cloudsql socket, matching the platform’s GCP track.