Pull raw Data Points for the requested Data Types within an inclusive --from / exclusive --to window and append them to the Health Archive. Sync is the primary write path; everything else in the binary either reads from the archive or refreshes metadata.
--types accepts a comma-separated list (for example steps,heart-rate,sleep); multi-type invocations fan out into one Sync Run per Data Type, each with its own outcome and Sync Cursor. When neither --types nor --all is set, sync falls back to a single-type run against steps. --all is shorthand for every default Data Type in the catalog. Per-type failures stay isolated: one Data Type erroring does not stop the others. --rollup switches the sync from raw Data Points to upstream Rollup records: daily calls the dailyRollUp endpoint (civil-time windows), hourly / weekly / window=<duration> call the windowed rollUp endpoint (RFC3339 windows) with a 1h / 7d / parsed-duration window size respectively. Daily heart-rate Rollups are summary-history records and do not replace or imply a backfill of raw heart-rate samples. Unsupported combinations error with the Data Type's actual SupportedEndpoints quoted in the message. --source-family wearable restricts the result set to Data Points whose Data Source family is a watch or tracker.
--from and --to accept both civil dates (YYYY-MM-DD, interpreted as start-of-UTC-day) and RFC3339 timestamps. The emitted shape is per rollup kind:
daily: emits civil dates (YYYY-MM-DD). RFC3339 inputs are projected to their UTC calendar day so the upstreamdailyRollUpbody carries the catalog-required civil interval.hourly/weekly/window=<duration>: emits RFC3339 so the windowedrollUpbody carries the upstream-required RFC3339 range.
Shape-rejection messages name both supported forms per rollup kind so operators no longer see an opaque upstream HTTP 400 for civil-on-hourly or similar.
--from is optional once an initial backfill has succeeded — subsequent runs read the durable Sync Cursor for the same (connection_id, data_type, source_family_filter, rollup_kind) key and resume from it. Each rollup kind (daily / hourly / weekly / window=<duration>) carries its own cursor, so syncing weekly aggregates does not disturb the hourly cursor for the same Data Type. The cursor advances only when a Sync Run finishes with sync_completed, so failed or cancelled runs re-read the same window on the next attempt (ADR-0008). The terminal Sync Run status and the cursor advance are written in one SQLite transaction, so a crash between them cannot leave the audit trail and the cursor disagreeing.
A Sync Run row is recorded for every invocation that reaches upstream — succeeded, failed, or cancelled — so the archive carries an audit trail of attempts as well as records. Every --json envelope carries a non-empty status from the enum sync_completed | sync_failed | sync_canceled; the empty string is structurally impossible because every code path emits a non-empty status.
Preflight failures exit before contacting the provider and do NOT write a sync_runs audit row. The full list of no-audit-row rejections is:
- Unparseable
--fromor--to(range parse). - Inverted range (
--from > --to). - Zero-width range (
--from == --to). - Unsupported
--rollupkind (parse failure). --rollup <kind>requested for a Data Type whose catalog entry does not support that kind (e.g.--rollup hourly --types daily-resting-heart-rate).- Unsupported Data Type (not syncable yet).
- Source-family vs Data Type mismatch.
--rollupcombined with--source-family(mutually exclusive).- No Connection on file (connection lookup failure).
--allcombined with--types(mutually exclusive).- Duplicate entries in
--types. --allexpanding to zero supported Data Types.- SIGINT received before any Data Type has started its audit row (no run is in flight to mark).
SIGINT (Ctrl-C) aborts the in-flight Provider request (every request is scoped to the run's context, and a Retry-After backoff sleep is cut short the same way), marks the in-flight Sync Run sync_canceled, leaves its Sync Cursor un-advanced, and stops cleanly; prior Data Types remain sync_completed and later ones are skipped.
Terminal writes are resilient to SQLite contention: on SQLITE_BUSY, the terminal write retries with bounded exponential backoff plus full jitter. If the retry budget is exhausted, the run surfaces as sync_failed with a contention-aware message and a separate short-transaction recovery write drives the row to a terminal state under the same retry budget so a sync_running row never lingers. sync_canceled outcomes are preserved through the recovery path — they are never reclassified as sync_failed.
Live progress (#236): before every page fetch the Sync Run heartbeats — the counts archived so far plus a last_progress_at timestamp land on the sync_runs row as a best-effort autocommit write — so a concurrent reader can watch progress from another terminal while the run is in flight, and a slow first page (large backfill, 429 retry backoff) still shows a live heartbeat from second zero. Heartbeats are advisory; the finalize transaction's terminal counts stay authoritative, and a heartbeat write failure never fails the sync.
sync --status is that concurrent reader, packaged: it lists recent Sync Runs from the local archive — one row per run with id, Data Types, status, counts, duration, heartbeat age, and a truncated error summary — and performs no provider I/O. Finished runs are listed when they finished inside --window (Go duration, default 15m, max 24h); sync_running rows are window-exempt, so a long in-flight run never ages out of the default view. --status cannot be combined with --types, --all, --from, --to, --rollup, or --source-family, and --window requires --status. The shared --json / --plain flags shape the output like every other read command.
Abandoned-run fencing: on entry to sync, sync --status, and status, any sync_running row whose heartbeat (or started_at, for rows that died before their first page) is older than 5 minutes is flipped to sync_failed with error_summary abandoned (no heartbeat for 5m) and finished_at set — so orphans from killed processes stop reading as alive without manual SQL. The fence is idempotent and never touches the Sync Cursor (ADR-0008: only a completed finalize advances it). Because it keys on heartbeat staleness rather than wall-clock age, a multi-hour backfill with a fresh heartbeat is never mis-flagged; and if a fenced process turns out to be alive after all, its eventual finalize overwrites the fence so the row converges to its true terminal status.
How long does a sync take? A cursor-resumed incremental sync finishes in seconds — a steps delta covering ~7 hours archived 97 Data Points in 7s. An explicit backfill window costs time in proportion to how many Data Points it covers: sustained throughput on large completed runs measures roughly 2,000–5,000 Data Points per minute (plan with ~2,000/min), so the Data Type's density per day decides the wall-clock. Densities measured 2026-06-10 from a real archive backed by a Pixel Watch 4 (continuous heart-rate sampling), and what two weeks of data costs at the conservative rate: heart-rate ~27,500 points/day, so two weeks is ~385,000 points and 1.5–3 hours of syncing; time-in-heart-rate-zone ~960/day, ~13,400 points, ~5 minutes; active-energy-burned ~630/day, ~8,800 points, ~4 minutes; oxygen-saturation ~480/day, ~6,700 points, ~3 minutes; steps ~260/day, ~3,600 points, ~2 minutes; sleep and the daily-* types are a point or so per day and finish in seconds. Density is account-specific — a phone-only account with no continuously-sampling wearable runs far lower across the board. Runs longer than an access token's ~1-hour lifetime survive it: a mid-run upstream 401 triggers a single token refresh and a retry of the failed request, and the refreshed token carries the rest of the run — at most one refresh per fetch, so a revoked grant still fails, and 403 is never retried because a fresh token cannot fix a missing scope. Mid-run refresh requires a Connection that supports auto-refresh (the standard init --oauth-client-file setup does); without it, a run that outlives its token keeps the historical behavior — it fails with Google Health rejected stored Connection token and leaves the Sync Cursor un-advanced — so chunk such backfills into --from/--to windows under ~100,000 points (2–3 days of continuously-sampled heart-rate). While a long run is in flight, sync --status from a second terminal shows the live counts and heartbeat age.
#Flags
| Flag | Type | Default | Description |
|---|---|---|---|
--config | string | — | config file path |
--db | string | — | SQLite Health Archive path |
--json | bool | false | write stable JSON to stdout |
--plain | bool | false | write plain key/value output to stdout |
--no-input | bool | false | never prompt, never wait for browser input |
--types | string | — | comma-separated Data Types; defaults to "steps" when neither --types nor --all is set |
--all | bool | false | sync every default Data Type |
--from | string | — | inclusive sync range start; optional once a Sync Cursor exists |
--to | string | — | exclusive sync range end |
--rollup | string | — | rollup kind to sync; supported: daily | hourly | weekly | window=<duration> |
--source-family | string | — | source family filter; supported: wearable |
--status | bool | false | list recent Sync Runs from the local archive instead of syncing |
--window | string | — | with --status: how far back to list finished Sync Runs (Go duration, default 15m, max 24h) |