No description
  • TypeScript 94.7%
  • JavaScript 4.4%
  • Dockerfile 0.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Morten Olsen 953adbfe9e
All checks were successful
ci/woodpecker/push/compliance Pipeline was successful
ci/woodpecker/push/quality Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
feat: add first-class workout catalogue
2026-09-15 00:09:27 +02:00
.woodpecker ci: publish through fresh health-service repository 2026-09-14 22:34:23 +02:00
apps/server feat: add first-class workout catalogue 2026-09-15 00:09:27 +02:00
docs feat: add first-class workout catalogue 2026-09-15 00:09:27 +02:00
packages feat: add first-class workout catalogue 2026-09-15 00:09:27 +02:00
scripts feat: add first-class workout catalogue 2026-09-15 00:09:27 +02:00
.dockerignore feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00
.gitignore feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00
.prettierignore feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00
.prettierrc feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00
.taskrc.yml feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00
AGENTS.md feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00
CLAUDE.md feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00
compose.yaml ci: publish through fresh health-service repository 2026-09-14 22:34:23 +02:00
Dockerfile feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00
eslint.config.mjs feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00
mise.toml feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00
package.json feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00
pnpm-lock.yaml feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00
pnpm-workspace.yaml feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00
README.md feat: add first-class workout catalogue 2026-09-15 00:09:27 +02:00
renovate.json feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00
Taskfile.yml ci: publish through fresh health-service repository 2026-09-14 22:34:23 +02:00
tsconfig.json feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00
vitest.config.ts feat: establish health service with Docker delivery and data roadmap 2026-09-14 22:24:10 +02:00

Health

A local-first, single-user health and workout service for completed Gadgetbridge exports. It preserves source provenance and correction history in SQLite, resolves independently clocked heart-rate points with named priority policies, and keeps canonical workout sessions separate from their original device recordings.

This service has no authentication. Its API exposes sensitive health data and permits exclusion changes. Keep the published port on loopback; do not expose it directly to a LAN or the internet. There is no browser application, account model, or medical interpretation layer.

What is implemented

  • Read-only import of completed local Gadgetbridge exports, schema 138, with exactly one supported user.
  • Ordinary heart rate from Garmin, Huami/Amazfit, and Huawei adapters, including manufacturer-specific timestamps, signed encodings, missing/invalid values, and Huawei synthetic-marker filtering.
  • S3-compatible export acquisition with paginated inventory checks, ETag-conditional downloads, private staging, hash-verified retained snapshots, and durable ingestion outcomes.
  • Transactional, idempotent reconciliation: stable observation identities, immutable correction revisions, and historical readings retained when later exports omit them.
  • Named, immutable-revision device-priority policies and configurable UTC heart-rate aggregation (1m through 1w; average, minimum, maximum, first, or last), with quality-aware source fallback and explicit selection provenance.
  • A first-class workout catalogue with source-reported summary metrics, Garmin FIT/Huawei detail references, immutable recording revisions, explicit cross-device link/reject/unlink decisions, per-metric source selection, and user-owned manual sessions and annotations.
  • Persistent manual exclusions, ingestion status/freshness endpoints, generated OpenAPI, and an interactive API reference.

Not implemented: temperature and other health metrics, decoded FIT/Huawei workout samples or routes/laps/intervals, interpolation, local-time/DST aggregation, descriptive workout analytics, authentication, multi-user ingestion, PostgreSQL, or a frontend. Support for the named manufacturers means the adapter tables in this repository, not every device or export format. Unsupported schemas/users are rejected rather than guessed.

Project records

  • Data discovery: dated source inventory, coverage, anomalies, interpretation traps, and open questions.
  • Implementation plan: implemented baseline, architectural decisions, next milestones, acceptance criteria, and a future-session handoff.

Architecture

Path Responsibility
packages/health (@health/domain) Vendor-neutral observations, policies, workout sessions/recordings, source selection and UTC window aggregation; no HTTP, database, or vendor dependencies
packages/config (@health/config) Shared TypeScript and test configuration
apps/server/src/adapters/{garmin,huami,huawei} Manufacturer-specific interpretation
apps/server/src/ingestion/gadgetbridge Completed-export reader, schema checks, adapter dispatch
apps/server/src/ingestion/snapshots S3 transport, staging, retained exports, integrity receipts
apps/server/src/ingestion/status Durable source binding, leases, runs, export freshness
apps/server/src/ingestion/ingestion.ts Acquisition/import orchestration, scheduling, shutdown
apps/server/src/store / database Reconciliation, immutable revisions, policies, workout decisions, queries, exclusions, SQLite lifecycle and ordered migrations
apps/server/src/api Fastify routes with Zod request/response schemas generating OpenAPI

The runtime is Node 24, Fastify, Zod v4, Kysely, and better-sqlite3. pnpm workspaces retain the same layout in the image under /app. SQLite migrations run on first database access. The application database uses WAL mode and foreign keys.

Local development

Install mise and activate it in your shell, then run from the repository root:

mise install
task install
task build
HEALTH_SYNC_ENABLED=false task start

If mise shims are not active, use mise exec -- task <name>. Node and Task versions are pinned in mise.toml; pnpm is pinned by package.json#packageManager and enabled through Corepack. Native SQLite dependencies need a working compiler toolchain when a prebuilt binary is unavailable; use the pinned Node runtime.

The empty service can start without exports or S3 credentials:

Command Purpose
task install Install workspace dependencies using the frozen lockfile
task check / task build Execute tsc -b across the TypeScript reference graph; both emit build output
task dev:server Run the server from source with watching
task start Build and run the compiled server
task import Import one completed local export
task sync Acquire, retain, and import the configured S3 export once
task test / task test:coverage Run isolated workspace regression tests, optionally with coverage
task lint Run ESLint, including configured formatting rules
task format / task format:check Write/check formatting
task ci:quality Run the CI quality gate locally
task license:check / task ci:compliance Run the production dependency-license gate locally; CI also runs Trivy separately
task docker:build Build the application image, including its runtime smoke check
task docker:up / task docker:down Build/start or stop the Compose deployment; down preserves its named volume

Tests use synthetic exports and isolated databases, not private .source data. See CLAUDE.md for contributor invariants.

Docker deployment

Docker Engine with Compose is required. The image runs as node, UID/GID 1000, not root. It stores durable state under /data, listens on container port 3000, and uses Node as its entrypoint. No source export or credentials are baked into the image.

Build and run locally

task docker:build
task docker:up
curl --fail http://127.0.0.1:3000/api/health
docker compose logs --tail=50 health

The checked-in Compose service is health, uses code.olsen.cloud/incubator/health-service:latest, and binds 127.0.0.1:3000:3000. Its health-data named volume is mounted at /data. The default deployment has no source bind mount or S3 credentials. Stop it with task docker:down; do not add --volumes/-v unless you intend to delete durable health history and exclusions.

The image's synthetic smoke imports into an isolated directory, exercises the actual HTTP server/API documentation and SQLite runtime, then restarts without the source to check persistence. It does not contact S3 or use your exports. Run it explicitly against an image without mounting private data:

docker run --rm code.olsen.cloud/incubator/health-service:latest scripts/container-smoke.mjs

Run a published image

The Forgejo repository is private. Before pulling published images, authenticate as an account allowed to read its packages, using a dedicated personal access token scoped to read:package. Pass the token over stdin from a protected file or secret manager; never paste it into a command, commit it, or put it in shell history:

docker login code.olsen.cloud --username YOUR_FORGEJO_USERNAME \
  --password-stdin < /absolute/private/forgejo-read-package-token

Protect the token file and Docker's credential storage; use a Docker credential helper where available. Registry credentials are separate from the application's read-only S3 credentials. Once authenticated and the main-branch pipeline has published an image:

docker pull code.olsen.cloud/incubator/health-service:latest
docker run -d --name health --restart unless-stopped \
  -p 127.0.0.1:3000:3000 \
  --mount type=volume,source=health-data,target=/data \
  -e HEALTH_SYNC_ENABLED=false \
  code.olsen.cloud/incubator/health-service:latest

Use either this standalone container or Compose, not both on the same host port. For repeatable deployments, replace latest with the published eight-character commit SHA tag, and preferably pin the registry digest (@sha256:…): latest moves, and a tag is not registry-enforced immutability. A deployment rollback still needs a compatible database; take a backup before upgrading across migrations.

An empty named volume is initialized from the image's writable /data directory. If using a host bind mount or an existing volume instead, provision it so UID/GID 1000 can traverse and write it; do not solve permissions by running the application as root. Do not mount private directories into the container unless needed.

S3 configuration and one-shot commands

Keep credentials in a private file outside the checkout (restrict it to the deploying user), never in Compose YAML, image build arguments, shell history, or commits. For Docker --env-file, use NAME=value lines containing the variables in the configuration reference below. Docker does not load that file unless you pass it. Environment values remain visible to privileged Docker operators.

To run scheduled synchronization, omit the explicit HEALTH_SYNC_ENABLED=false from the standalone example and add --env-file /absolute/private/health.env before the image name. Include all three required S3 variables and explicitly set HEALTH_SYNC_ENABLED=true if scheduling is intended. With Compose, an override can supply a private env_file; the shipped file deliberately does not auto-load credentials.

Run a single S3 synchronization against the same durable volume:

docker run --rm \
  --mount type=volume,source=health-data,target=/data \
  --env-file /absolute/private/health.env \
  code.olsen.cloud/incubator/health-service:latest apps/server/dist/sync.js

Node is already the entrypoint: pass the JavaScript path, not node again. sync.js exits 0 on imported/unchanged, 1 on failure, and 2 when another process holds the source lease. HEALTH_SYNC_ENABLED=false disables scheduling only; an explicit sync still runs.

Import a completed local export with an optional read-only source mount:

docker run --rm \
  --mount type=volume,source=health-data,target=/data \
  --mount type=bind,source=/absolute/completed-export,target=/source,readonly \
  code.olsen.cloud/incubator/health-service:latest apps/server/dist/import.js

The host export must already exist and be readable by UID 1000. Import exits 1 on failure. Neither one-shot command exposes an HTTP port. For Compose's configured volume/environment, docker compose run --rm --no-deps health apps/server/dist/sync.js runs the same entrypoint; supply S3 settings through your private override first.

Configuration reference

Configuration is loaded once at process startup:

  1. Built-in defaults.
  2. Optional JSON file selected by HEALTH_CONFIG.
  3. Explicit environment overrides from the table below.

The JSON is validated before environment overrides, so an invalid file cannot be repaired by an environment value. The application does not load .env automatically. Local built-in data/source paths resolve from the repository, not the current working directory; custom relative paths resolve from the process working directory. Prefer absolute paths, particularly with workspace commands and containers. Inside the image the working directory is /app.

Environment variable JSON property Default and constraints
HEALTH_CONFIG — Unset; optional readable JSON file path. In a container, mount the file read-only and use its container path.
HEALTH_DB database.filename Repository .data/health.db; Docker /data/health.db. Nonempty; :memory: is ephemeral and intended for isolated tests.
HEALTH_SOURCE_DIR source.directory Repository .source; Docker /source. Nonempty completed-export directory.
HEALTH_HOST server.host 127.0.0.1; Docker 0.0.0.0 so Docker port forwarding works. This does not make a non-loopback host publish safe.
HEALTH_PORT server.port 3000; integer 0–65535. Zero requests an ephemeral port and is not useful with the default Docker port mapping.
HEALTH_SYNC_ENABLED sync.enabled Inferred from the presence of all three S3 credential/endpoint environment variables unless explicitly configured. Environment accepts exactly true or false; JSON uses a boolean.
HEALTH_SYNC_SOURCE_ID sync.sourceId gadgetbridge-local; 1–200 characters. Shared by local import and S3 sync.
HEALTH_SYNC_DIR sync.directory Repository .data/ingestion; Docker /data/ingestion. Nonempty; durable retained exports and integrity receipts, not a disposable cache.
HEALTH_SYNC_INTERVAL_SECONDS sync.intervalSeconds 86400 (one day); integer 60–604800.
HEALTH_SYNC_STALE_AFTER_SECONDS sync.staleAfterSeconds 172800 (two days); integer 60–2592000.
HEALTH_SYNC_MAX_RUN_SECONDS sync.maxRunSeconds 1800; integer 1–1800. Bounds the synchronization attempt; lease includes a further 300-second safety window.
S3_ENDPOINT_URL sync.endpoint Unset; valid URL, required for S3 sync. Prefer HTTPS.
S3_ACCESS_KEY sync.accessKeyId Unset; nonempty, required for S3 sync.
S3_SECRET_KEY sync.secretAccessKey Unset; nonempty, required for S3 sync.
S3_REGION sync.region us-east-1; nonempty signing region.
S3_BUCKET sync.bucket shared; nonempty.
S3_PREFIX sync.prefix gadgetbridge/; nonempty, trailing slash added when absent. The whole prefix inventory is acquired.

Docker sets the five path/listen defaults shown above as environment variables. They therefore take precedence over those same properties in a mounted JSON file; override the corresponding Docker environment variables when changing them.

A credential-free JSON example:

{
  "database": { "filename": "/data/health.db" },
  "server": { "host": "0.0.0.0", "port": 3000 },
  "source": { "directory": "/source" },
  "sync": {
    "enabled": false,
    "sourceId": "gadgetbridge-local",
    "directory": "/data/ingestion",
    "intervalSeconds": 86400,
    "staleAfterSeconds": 172800,
    "maxRunSeconds": 1800
  }
}

If endpoint and credentials live only in JSON, their presence does not enable scheduling: inference checks the process environment, not the parsed JSON. Set sync.enabled: true or HEALTH_SYNC_ENABLED=true explicitly. Explicit JSON false also wins over credential inference unless overridden by HEALTH_SYNC_ENABLED.

Least-privilege S3 access

The service only lists and reads: grant s3:ListBucket on the configured bucket, restricted to the configured prefix, and s3:GetObject on objects under that prefix. An encrypted backend may additionally require its read/decrypt permission. No PutObject, DeleteObject, bucket administration, or remote writes are needed or performed. There is no ambient AWS credential-chain/session-token configuration; the application supplies the configured access key and secret explicitly. Use a dedicated read-only principal rather than the publisher's credentials.

The prefix must contain a completed export layout, not unrelated objects. A changed inventory causes a full new snapshot download. An identical inventory reuses a retained snapshot only after integrity verification. Inventory comparison before/after acquisition and conditional downloads establish an observed-stable prefix, not an atomic publisher snapshot. Publish complete exports, and avoid updating them during acquisition.

Imports, scheduling, and durable state

A local export directory needs gadgetbridge.json (Gadgetbridge app ID, backup version 1, UTC millisecond backupDate) and database/Gadgetbridge (SQLite schema 138). Use a completed export, not a live database or one dependent on -wal/-journal sidecars. The reader checks metadata/file stability and SQLite integrity and decodes the exact bytes it fingerprints; it never opens a writable connection against the source.

HEALTH_SOURCE_DIR=/absolute/completed-export task import
# With S3 variables explicitly exported in your shell:
task sync

The local import CLI does not acquire S3 snapshots or create a synchronization run; it imports directly into the canonical store. Both CLIs default to the same source identity, suitable for one continuous single-user export lineage. Do not reuse that identity for an unrelated export or change it on each run. Local vendor database IDs are not globally stable device identities. A synchronized source is durably bound to its endpoint/bucket/prefix; changing that origin under the same source ID is rejected. A genuinely new lineage needs its own source ID and will have separate observations/exclusions.

The listening server starts the scheduler when enabled. It checks immediately if due; later checks follow persisted run start times, so restarts do not reset the interval. Keep the server running for automatic checks. Source leases in the shared application database prevent concurrent S3 runs across processes. Expired owners are recorded as interrupted; shutdown coordinates cancellation before closing SQLite. Separate application databases do not share that lease.

Successful imports publish atomically. Replaying unchanged input is a no-op. Corrections preserve observation and workout-recording UUIDs and add immutable revisions. User exclusions, workout annotations, manual sessions, link rejections and metric-source choices are stored independently from imported snapshots. An older export cannot replace newer imported data, and an absent row in a new export is not a deletion instruction. Import commit and synchronization-run completion are separate transactions; leases and idempotent replay recover interrupted completion.

Status and freshness

curl --fail http://127.0.0.1:3000/api/ingestion/status
curl --fail 'http://127.0.0.1:3000/api/ingestion/runs?limit=20'
  • Run states are running, imported, unchanged, failed, and interrupted. Counts, timing, hashes, and sanitized error codes/messages are durable. Runs are newest first; limit is 1–100, default 20.
  • lastRun and lastSuccess are distinct: a recent failure does not erase the last successful import/check.
  • exportFreshness measures age of export metadata capture time, not the latest successful check or sensor coverage. It is never without a known capture, stale when age is at least the configured threshold, otherwise fresh. Local imported snapshots supply capture time when no successful synchronization exists.
  • nextCheckAt is null when scheduling is disabled, otherwise follows the active lease or persisted run schedule. It is a scheduling indication, not proof that a running server will complete that check.
  • /api/health returns { "status": "ok" } for HTTP liveness; it does not certify S3 connectivity, fresh data, or complete sensor coverage.

A successful unchanged check of an old export remains stale. Retained snapshots, measurement revisions, exclusions, and run history are durable state. There is no automatic pruning policy; monitor disk space and back up before managing retention manually.

Querying health data and workouts

The API reference at /api/docs/ and OpenAPI at /api/openapi.json are the canonical HTTP contract.

Client-correctable 400 responses include issues with a JSON-pointer-like path and human-readable message; for example, an unqualified timeline query before creating a default policy explains No default device priority policy. Responses never echo submitted values.

Endpoint Purpose
GET /api/measurements Discover scalar metric IDs, descriptions, units, ranges, and query capabilities
GET /api/devices Discover imported device UUIDs
GET /api/device-priority-policies List the current revision of each named policy
POST /api/device-priority-policies Create a named policy and immutable revision 1
GET /api/device-priority-policies/:id?revision=N Read the current or a historical revision
PUT /api/device-priority-policies/:id Save the complete rules as the next immutable revision
GET /api/measurements/:metric Resolve and aggregate a scalar metric using a saved policy or explicit device order
GET /api/measurements/:metric/observations Page raw measurements; optional deviceId, limit 1–100, offset 0–1000000
GET /api/observations/:id Inspect an observation's original quality, provenance, and exclusion
PUT / DELETE /api/observations/:id/exclusion Flag or restore a bad datapoint
GET /api/exclusions?limit=20&offset=0 List excluded observations
GET / POST /api/workouts Page canonical sessions or create a user-owned manual session
GET /api/workouts/:id Inspect all source recordings, reported metrics, detail references, and metric selections
PUT /api/workouts/:id/annotations Replace the user-owned title and note
PUT /api/workouts/:id/manual-definition Replace a manual session's authoritative activity and bounds
GET /api/workout-link-candidates Page compatible cross-device recording pairs with substantial temporal overlap
PUT /api/workout-link-decisions Link, reject, or clear a rejection between two source recordings
DELETE /api/workouts/:sessionId/recordings/:recordingId Split a recording back into its own session and prevent automatic re-suggestion
PUT /api/workouts/:sessionId/metric-sources/:metricKey Select one linked recording for one source-reported metric; null restores automatic source choice

The service stores scalar samples in a metric-keyed measurement table; heart_rate is currently the only supported metric. Use /api/measurements as the machine-readable catalogue rather than hard-coding IDs, units, valid ranges, supported windows, or aggregation methods.

Workout queries unlock frequency, duration, activity-type and like-session comparisons while retaining the evidence needed for later workout-to-recovery analysis. The catalogue endpoint is bounded and returns summaries; detail is requested one session at a time. Imported BASE_ACTIVITY_SUMMARY rows are the source-recording identity. A matching Huawei summary enriches that recording rather than creating another workout, and a Garmin details path remains a FIT reference. Source metric keys and units remain verbatim because equivalence across vendors has not been established.

Temporal overlap and a matching known activity create only a link candidate. The service never automatically merges recordings. Linking keeps every recording's original bounds and totals; resolved session metrics select one recording per key and are never added across overlapping sources. Rejections, unlinking, manual metric choices, annotations and manual sessions survive later imports. Clearing a rejection is explicit. Current imports retain Huawei detail counts and FIT paths, but do not yet decode samples, routes, laps, sections or intervals.

Create the initial default after obtaining the real UUIDs from /api/devices. Arrays are in descending priority. For the current wearing pattern, put every workout-capable device first, the Helio Strap next, and the Helio Ring last:

curl --fail -X POST \
  -H 'Content-Type: application/json' \
  -d "{\"name\":\"Workout then continuous\",\"isDefault\":true,\"defaultDevicePriority\":[\"$WORKOUT_DEVICE_1\",\"$WORKOUT_DEVICE_2\",\"$WORKOUT_DEVICE_3\",\"$STRAP_ID\",\"$RING_ID\"],\"metricOverrides\":{}}" \
  http://127.0.0.1:3000/api/device-priority-policies

curl --fail --get http://127.0.0.1:3000/api/measurements/heart_rate \
  --data-urlencode 'from=2026-01-01T00:00:00Z' \
  --data-urlencode 'to=2026-01-02T00:00:00Z' \
  --data-urlencode 'interval=5m' \
  --data-urlencode 'aggregation=avg'

interval accepts 1m, 5m, 15m, 30m, 1h, 6h, 1d, or 1w; aggregation accepts avg, min, max, first, or last. They default to 1m and avg. from and to must align to the requested UTC interval and select [from,to). A request is limited to 100,000 possible output windows; choose a coarser interval for longer spans. The limit follows response cardinality rather than an arbitrary number of days, so multi-year hourly, daily, or weekly trend requests are supported.

Supply policyId and optionally policyRevision to reproduce a saved revision. A comma-separated devicePriority is an explicit request-only policy: only those devices participate, so a one-device list isolates that source. It cannot be combined with policy parameters. When neither is supplied, the current default is resolved once and its exact revision is returned in selection.

Policy names are unique and immutable. PUT stores complete rules as the next revision; historical revisions remain readable. Selecting a new default clears the previous default atomically. A current default cannot simply be unset, because that would break unqualified timeline requests. Unknown, duplicate, empty, and over-32-device lists are rejected.

Source selection happens before output aggregation. Each observed UTC minute independently selects the highest-priority device with at least one valid, non-excluded point; lower-priority devices fill otherwise uncovered minutes. avg first averages the selected samples within each covered minute, then weights those minute means equally so device sampling frequency cannot bias a wider window. min and max inspect all selected samples; first and last follow observation time. Empty output windows are omitted; a window containing observations but no eligible points is null. There is no carry-forward, interpolation, inferred recording span, or implicit inclusion of unlisted devices.

Each aggregate reports start, exclusive end, every selected device in deviceIds, sampleCount, and minute-level observedMinutes/coveredMinutes. It deliberately does not embed contributing observation IDs: aggregate payload scales with output windows, not raw input cardinality. The response reports the effective policy identity/revision or explicit priority, plus the interval and aggregation method. Imported points remain unchanged; all selection and aggregation happen at request time, so late imports, corrections, exclusions, policy revisions, and alternative aggregation requests do not rewrite source data.

Use the separately paginated raw-measurement endpoint to inspect provenance and obtain IDs for exclusion. It selects [from,to) without applying aggregation or a priority policy; pass deviceId to isolate a source:

curl --fail --get http://127.0.0.1:3000/api/measurements/heart_rate/observations \
  --data-urlencode 'from=2026-01-01T00:00:00Z' \
  --data-urlencode 'to=2026-01-01T00:05:00Z' \
  --data-urlencode "deviceId=$WATCH_ID" \
  --data-urlencode 'limit=100'

curl --fail "http://127.0.0.1:3000/api/observations/$OBSERVATION_ID"
curl --fail -X PUT \
  -H 'Content-Type: application/json' \
  -d '{"reason":"Known sensor artifact"}' \
  "http://127.0.0.1:3000/api/observations/$OBSERVATION_ID/exclusion"
curl --fail 'http://127.0.0.1:3000/api/exclusions?limit=20&offset=0'
# Restore only when intended:
curl --fail -X DELETE \
  "http://127.0.0.1:3000/api/observations/$OBSERVATION_ID/exclusion"

reason is required but can be null; strings are trimmed and must contain 1–500 characters afterward. Repeated PUT updates the reason while preserving the original exclusion time. DELETE removes the overlay; original vendor quality still determines eligibility. Deleting an already-restored exclusion succeeds, while an unknown observation UUID returns 404. Exclusions do not change vendor quality, raw values, or import history. They are keyed by stable (metric, source_id, external_id), so reimports, corrections, temporarily absent rows, and regenerated observation UUIDs do not silently restore data. A genuinely changed vendor source key is a different identity and is not fuzzy-matched. Timeline resolution can fall back to another selected device at the excluded minute.

Backups and recovery

Treat .data locally, or the entire /data volume in Docker, as private durable storage. The SQLite database contains canonical history, correction revisions, manual exclusions, source bindings, leases, and run records. /data/ingestion contains retained exports and integrity receipts. Source exports alone cannot reconstruct manual exclusions or all historical state.

Never copy only a live health.db file. WAL mode may have committed data in health.db-wal. For a straightforward consistent backup, stop all writers, including the server, one-shot imports/syncs, and any other process sharing the volume. Keep them stopped until the full-volume copy finishes; include any SQLite sidecars that remain.

For the shipped Compose deployment, with an existing private backup directory:

docker compose stop health
docker run --rm --user 0 --entrypoint tar \
  --mount type=volume,source=health-data,target=/data,readonly \
  --mount type=bind,source=/absolute/private/backups,target=/backup \
  code.olsen.cloud/incubator/health-service:latest \
  -czf /backup/health-data.tar.gz -C /data .
docker compose start health

Use a new archive filename for each backup instead of overwriting your only recovery point. The helper uses root only to read all owned files and write the archive; the application remains nonroot. With a standalone deployment use docker stop health / docker start health instead. Protect and, where appropriate, encrypt backups as health data; restrict access to the host backup directory. Back up external configuration separately using a secrets-aware mechanism, not in the repository.

For recovery, stop all writers, restore the whole archive into an empty replacement volume, preserve file ownership (UID/GID 1000), and attach that volume to the compatible application image before restarting. Keep the original volume until you have checked devices, exclusions, and ingestion status. Do not combine a database from one backup with retained snapshots from another or overwrite a live volume. Local backups follow the same stopped-writer rule for the configured database and ingestion directories.

For Docker volume mechanics, see the Docker volume backup documentation.

CI and image publishing

Woodpecker workflows in .woodpecker/ run quality and compliance gates on push, pull-request, and manual events. Quality builds the TypeScript reference graph, lints, and runs regression coverage. Compliance checks production dependency licenses and scans the repository with Trivy. The image build uses the shared Kaniko plugin, not Docker-in-Docker, and must pass the Dockerfile's executable synthetic runtime smoke before publishing. A subsequent image scan checks the published image; it is not a pre-push gate.

Publishing is configured only for push or manual events on main, after the quality and compliance workflows succeed. The target is code.olsen.cloud/incubator/health-service, tagged latest and the first eight characters of the commit SHA. PRs do not publish. There is no npm publishing or semantic-version release automation. CI registry credentials come from the shared forgejo_registry_auth secret, never application S3 credentials.

Renovate configuration and a cron workflow maintain dependencies, mise pins, and container references. The Forgejo/Woodpecker administrator must enable the repository, provide the Renovate token, and configure its cron schedule; checked-in YAML alone is not evidence of a scheduled run. Likewise, workflow configuration is not proof that an image is available: inspect the actual pipeline result and registry before deploying a new SHA.