Reference

gohealthcli sync

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. total-calories is Rollup-only; daily and physical requests are split into at most 14-day Provider spans, and physical chunk boundaries align to multiples of the requested window. 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 now, today, yesterday, civil dates (YYYY-MM-DD, interpreted as start-of-UTC-day), and RFC3339 timestamps. The named-boundary timezone resolves by precedence: sync --timezone, root config timezone, then UTC for legacy configs. It never comes from the machine timezone, Provider settings, Data Point metadata, locale, or IP, and never reinterprets explicit dates or RFC3339 instants. Named physical boundaries emit UTC RFC3339, civil boundaries emit local YYYY-MM-DDTHH:mm:ss, and daily boundaries emit YYYY-MM-DD. Yesterday is calendar-based, so a physical yesterday to today window can span 23 or 25 hours across daylight-saving transitions; a timezone that skipped the resolved civil date fails locally.

The emitted shape for explicit rollup inputs remains per rollup kind:

  • daily: emits civil dates (YYYY-MM-DD). RFC3339 inputs are projected to their UTC calendar day so the upstream dailyRollUp body carries the catalog-required civil interval.
  • hourly / weekly / window=<duration>: emits RFC3339 so the windowed rollUp body carries the upstream-required RFC3339 range.

Shape-rejection messages name the supported forms so operators no longer see an opaque upstream HTTP 400.

--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. Its range_requested_json object keeps from and to as the exact resolved Provider boundaries and adds the resolution timezone, UTC resolved_at, and from_input / to_input only when the operator supplied a named boundary. Historical rows with only from and to remain readable. 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:

  • Invalid or empty flag/config timezone, an unsupported named boundary, a skipped civil date, or an otherwise unparseable --from / --to.
  • Inverted range (--from > --to).
  • Zero-width range (--from == --to).
  • Unsupported --rollup kind (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.
  • --rollup combined with --source-family (mutually exclusive).
  • No Connection on file (connection lookup failure).
  • --all combined with --types (mutually exclusive).
  • Duplicate entries in --types.
  • --all expanding 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, stored resolved from / to, resolution timezone and timestamp when available, original named inputs when available, counts, duration, heartbeat age, and a truncated error summary — and performs no provider I/O or historical range recomputation. 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, --timezone, --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.

--plan resolves one operation per requested Data Type without making a Provider request, reading the Credential Store, refreshing a token, opening a writable Health Archive, migrating, fencing runs, advancing a Sync Cursor, or creating an Attachment sidecar. In planning mode, --types preserves requested order and may be repeated; --all uses catalog order. Every operation resolves its own range and Sync Cursor, and a local blocker affects only that Data Type. Planning reports the resolved range and source, endpoint, page policy, required scopes, sanitized first-request preview, conditional exercise TCX operation, and predicted effects of the future sync. Readiness covers local facts only: credential availability, Google Identity match, and Provider reachability remain explicitly unchecked. A fan-out with any locally blocked operation exits nonzero after reporting every operation.

Reviewed Sync and Health Archive failures may add remediation to JSON results and zero-based remediation.N fields to explicit plain results; human/default output stays unchanged. An Initial Backfill uses the fixed gohealthcli sync --from YYYY-MM-DD template without copying invocation arguments. Fan-out keeps actions only on the affected child. Canceled, corrupt, invalid-query, and unknown failures omit remediation. Building or rendering these steps performs no Provider I/O, OAuth, or archive mutation and never interpolates error text.

#Flags

FlagTypeDefaultDescription
--configstringconfig file path
--dbstringSQLite Health Archive path
--jsonboolfalsewrite stable JSON to stdout
--plainboolfalsewrite plain key/value output to stdout
--no-inputboolfalsenever prompt, never wait for browser input
--typesstringcomma-separated Data Types (string); repeatable with --plan; defaults to "steps" when neither --types nor --all is set
--allboolfalsesync every default Data Type
--fromstringinclusive sync range start; optional once a Sync Cursor exists
--tostringexclusive sync range end
--timezonestringIANA timezone for now, today, and yesterday (default UTC)
--rollupstringrollup kind to sync; supported: daily | hourly | weekly | window=<duration>
--source-familystringsource family filter; supported: wearable
--planboolfalseprint per-Data-Type resolved Sync Run plans without Provider, credential, or archive write effects
--statusboolfalselist recent Sync Runs from the local archive instead of syncing
--windowstringwith --status: how far back to list finished Sync Runs (Go duration, default 15m, max 24h)