Releasesv0.1.0

How it works

Collect, keep the raw payload, normalize, serve. One binary, three roles, one database.

Apple Health

Your iOS app uploads gzip NDJSON parts.

Cloud providers

OAuth pulls and push notifications.

Raw store

Every payload kept verbatim, on disk or S3, never rewritten.

ETL

Map, convert units, validate, de-duplicate, roll up local days.

Postgres

One canonical model, scoped by tenant and user.

REST API

Described by OpenAPI 3.1.

MCP server

Read only, for AI agents.

Collect

Data arrives two ways. A device upload: your iOS app posts HealthKit data to POST /v1/ingest/apple-health as gzip NDJSON parts, with a user token that has the upload scope. A provider pull: for an OAuth provider such as Withings or Oura, the server pulls each active connection when the provider sends a notification to /inbound/{provider}, and on a backstop schedule in case one was lost (background sync).

Keep the raw payload

Before anything else, the payload is written to the raw store (a local folder or any S3 compatible bucket), verbatim, as gzip NDJSON. A raw object is never rewritten. In the same transaction as its registry row, a job is queued in Postgres.

Normalize

The etl role maps each line to the canonical model, converts units, validates values against plausible ranges, drops duplicates by content hash, attributes the source and device, and rolls up daily summaries per local calendar day.

Serve

Reads are GET /v1/users/{id}/... with the tenant key, paged with a cursor. The MCP server reads the same data, read only. Both go through one query layer that always filters on tenant and user.

Three roles, one binary

ohs runs up to three roles, set by OHS_ROLES, in one process or split across several.

RoleRuns
apiThe HTTP server: REST API, uploads, OAuth callbacks, provider notifications, MCP, health and metrics.
collectThe pull scheduler and OAuth token refresh.
etlThe ETL workers, user deletions, and the upkeep jobs (orphan scanner, drainer, retention).

One process with all three is the simplest deployment, and the default. To scale, run several api instances behind a proxy and a separate etl process. The queue lives in Postgres (FOR UPDATE SKIP LOCKED): no message broker, cache or worker fleet.

Always run etl somewhere

Uploads are processed and user deletions are carried out by the etl role. A deployment that runs api without etl anywhere accepts both and never finishes them.

Why keep the raw payload

Because it makes mistakes cheap. When a mapper is fixed (a new type mapped, a unit corrected), any user can be re-processed from their raw files, and records are de-duplicated by content hash, so nothing is stored twice. A crash between writing the raw file and queuing its job is caught by the orphan scanner.

The full design, with storage schemas and reliability details, is in Architecture.

Edit on GitHub

On this page