mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
1695 lines
64 KiB
TypeScript
1695 lines
64 KiB
TypeScript
/**
|
||
* Google Health API client — OAuth + data-point reads (v1.27.0).
|
||
* Docs: https://developers.google.com/health (health.googleapis.com/v4, the
|
||
* successor to the Fitbit Web API, unifying Fitbit + Pixel Watch + Fitbit Air).
|
||
*
|
||
* Hand-rolled fetch over `safeFetch` (no SDK): the OAuth handshake
|
||
* (`getAuthorizationUrl` / `exchangeCode` / `refreshAccessToken`), a single
|
||
* profile fetch for the connection's external user id, and the paginated
|
||
* `dataPoints.list` walker + per-type mappers.
|
||
*
|
||
* KEY OAUTH SEMANTICS (verified 2026 contract):
|
||
* - Access-token TTL = 1 h (contrast the classic Fitbit Web API's 8 h).
|
||
* - Refresh tokens do NOT rotate — a routine refresh returns a fresh
|
||
* `access_token` WITHOUT a `refresh_token`; the sync layer keeps the stored
|
||
* one (see `getValidToken` in `sync.ts`). Refresh tokens are time-based:
|
||
* they expire after 6 months of disuse, or — in a consent screen still in
|
||
* "Testing" publishing mode — after 7 DAYS, at which point the user must
|
||
* re-consent. A revoked / expired refresh token surfaces `invalid_grant`
|
||
* (or a 401) on the token endpoint; `postToken` lifts that onto the
|
||
* `reauth_required` class so the connection prompts a reconnect rather than
|
||
* a generic hard error.
|
||
* - PKCE (S256): the authorize request carries a `code_challenge`; the
|
||
* callback presents the matching `code_verifier` on exchange. Google's
|
||
* web-server (confidential) client also sends the client secret via HTTP
|
||
* Basic auth (RFC 6749 §2.3.1) — Basic + PKCE together.
|
||
* - `access_type=offline` + `prompt=consent` are required to reliably receive
|
||
* a refresh token (and force one on every re-consent).
|
||
*
|
||
* The pure mapping layer (payload → Measurement / Workout shapes) lives in
|
||
* `mappers.ts` and is re-exported below, so the sync layer and tests keep a
|
||
* single `./client` import surface. The shared wire-shape symbols
|
||
* (`GoogleHealthDataType`, `GOOGLE_HEALTH_DATA_TYPES`, the point types) live in
|
||
* `mappers.ts` and are imported here type-only — that direction keeps the
|
||
* module graph acyclic (`client` → `mappers`, never back).
|
||
*/
|
||
import { AsyncLocalStorage } from "node:async_hooks";
|
||
import { createHash, randomBytes } from "node:crypto";
|
||
import { annotate, getEvent } from "@/lib/logging/context";
|
||
import { SafeFetchError, safeFetch } from "@/lib/safe-fetch";
|
||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||
import {
|
||
GoogleHealthApiError,
|
||
classifyGoogleHealthResponse,
|
||
type GoogleHealthClassification,
|
||
} from "./response-classifier";
|
||
import {
|
||
googleHealthSampleInstant,
|
||
type GoogleHealthDataPoint,
|
||
type GoogleHealthDataType,
|
||
type GoogleHealthRollupPoint,
|
||
} from "./mappers";
|
||
import { envValue } from "@/lib/env";
|
||
|
||
export * from "./mappers";
|
||
|
||
export interface GoogleHealthClientOutcome {
|
||
pages: number;
|
||
fetched: number;
|
||
mapped: number;
|
||
written: number;
|
||
truncated: boolean;
|
||
reasonCode:
|
||
| "collection_failed"
|
||
| "token_failed"
|
||
| "upsert_failed"
|
||
| "rollup_failed"
|
||
| "existing_page_limit"
|
||
| null;
|
||
}
|
||
|
||
const clientOutcomeStorage = new AsyncLocalStorage<GoogleHealthClientOutcome>();
|
||
|
||
function addOutcomeCount(current: number, value: number): number {
|
||
if (!Number.isFinite(value) || value <= 0) return current;
|
||
return Math.min(2_147_483_647, current + Math.trunc(value));
|
||
}
|
||
|
||
function notePagination(
|
||
pages: number,
|
||
fetched: number,
|
||
truncated: boolean,
|
||
): void {
|
||
const tracker = clientOutcomeStorage.getStore();
|
||
if (!tracker) return;
|
||
tracker.pages = addOutcomeCount(tracker.pages, pages);
|
||
tracker.fetched = addOutcomeCount(tracker.fetched, fetched);
|
||
if (truncated) {
|
||
tracker.truncated = true;
|
||
tracker.reasonCode = "existing_page_limit";
|
||
}
|
||
}
|
||
|
||
export function noteGoogleHealthMapped(count: number): void {
|
||
const tracker = clientOutcomeStorage.getStore();
|
||
if (tracker) tracker.mapped = addOutcomeCount(tracker.mapped, count);
|
||
}
|
||
|
||
export function noteGoogleHealthWritten(count: number): void {
|
||
const tracker = clientOutcomeStorage.getStore();
|
||
if (tracker) tracker.written = addOutcomeCount(tracker.written, count);
|
||
}
|
||
|
||
export function noteGoogleHealthOutcomeFailure(
|
||
reasonCode: Exclude<GoogleHealthClientOutcome["reasonCode"], null>,
|
||
): void {
|
||
const tracker = clientOutcomeStorage.getStore();
|
||
if (tracker && !tracker.reasonCode) tracker.reasonCode = reasonCode;
|
||
}
|
||
|
||
/** Whether a walk in the current resource scope has ended short so far. */
|
||
export function googleHealthOutcomeTruncated(): boolean {
|
||
return clientOutcomeStorage.getStore()?.truncated === true;
|
||
}
|
||
|
||
export async function runWithGoogleHealthClientOutcome<T>(
|
||
fn: () => Promise<T>,
|
||
): Promise<{ result: T; outcome: GoogleHealthClientOutcome }> {
|
||
const outcome: GoogleHealthClientOutcome = {
|
||
pages: 0,
|
||
fetched: 0,
|
||
mapped: 0,
|
||
written: 0,
|
||
truncated: false,
|
||
reasonCode: null,
|
||
};
|
||
const result = await clientOutcomeStorage.run(outcome, fn);
|
||
return { result, outcome };
|
||
}
|
||
|
||
/**
|
||
* A stop condition for the walks below, set by a caller that runs under a
|
||
* time limit (the full-history backfill, which runs under its pg-boss job's
|
||
* budget and abort signal). A walk asks it before each request and, when it
|
||
* says stop, ends there and reports itself truncated, the same outcome as
|
||
* reaching the page ceiling: the resource is incomplete, the cycle is not
|
||
* stamped, and the reason shown is that only part of the history was
|
||
* processed. Outside such a scope the walks never stop early.
|
||
*
|
||
* A backfill slice adds a second, tighter limit (`GoogleHealthSliceBudget`):
|
||
* minutes and pages rather than the job's hours. Only a walk that keeps its
|
||
* place across attempts (`resumable`) honours it, because only such a walk
|
||
* loses nothing by stopping; a walk that would start over from its first page
|
||
* runs on to the end under the job's limit alone.
|
||
*/
|
||
interface GoogleHealthWalkLimits {
|
||
shouldStop: () => boolean;
|
||
slice?: GoogleHealthSliceBudget;
|
||
}
|
||
|
||
/** Why a backfill slice stopped walking. */
|
||
export type GoogleHealthSliceStopReason =
|
||
"time_budget" | "page_budget" | "rate_limited" | "job_budget";
|
||
|
||
/**
|
||
* One backfill slice's share of the work: a span of wall-clock time and a
|
||
* page count, both well inside the job's own expiry. The clock starts the
|
||
* first time a walk asks, which is after the slice has the account's lock: a
|
||
* slice that waited for an hourly sync to finish does not start out with its
|
||
* time already spent. `pages` counts every page the slice's walks have read;
|
||
* `stopReason` is set the first time a limit is reached and stays set, so
|
||
* every walk after it stops too.
|
||
*/
|
||
export interface GoogleHealthSliceBudget {
|
||
ms: number;
|
||
/** When the slice's time is up; null until the first walk asks. */
|
||
deadline: number | null;
|
||
maxPages: number;
|
||
pages: number;
|
||
/**
|
||
* Of `pages`, those a walk only passed over on its way to its edge, because
|
||
* Google refused the edge filter (see `forEachDataPointPage`). They are not
|
||
* charged against `maxPages`, and the time they took moves `deadline` on.
|
||
*/
|
||
skippedPages: number;
|
||
stopReason: GoogleHealthSliceStopReason | null;
|
||
now: () => number;
|
||
}
|
||
|
||
/** A fresh slice budget of `ms` milliseconds and `maxPages` pages. */
|
||
export function googleHealthSliceBudget(
|
||
ms: number,
|
||
maxPages: number,
|
||
now: () => number = Date.now,
|
||
): GoogleHealthSliceBudget {
|
||
return {
|
||
ms,
|
||
deadline: null,
|
||
maxPages,
|
||
pages: 0,
|
||
skippedPages: 0,
|
||
stopReason: null,
|
||
now,
|
||
};
|
||
}
|
||
|
||
const stopStorage = new AsyncLocalStorage<GoogleHealthWalkLimits>();
|
||
|
||
/** Whether the slice in scope has used its budget (and why, once it has). */
|
||
function sliceExhausted(slice: GoogleHealthSliceBudget): boolean {
|
||
if (slice.stopReason) return true;
|
||
slice.deadline ??= slice.now() + slice.ms;
|
||
if (slice.now() >= slice.deadline) slice.stopReason = "time_budget";
|
||
else if (slice.pages - slice.skippedPages >= slice.maxPages) {
|
||
slice.stopReason = "page_budget";
|
||
}
|
||
return slice.stopReason !== null;
|
||
}
|
||
|
||
/**
|
||
* Whether a walk should stop before its next request. Every walk stops on the
|
||
* job's limit; a resumable one also stops on the slice's.
|
||
*/
|
||
function stopRequested(resumable = false): boolean {
|
||
const limits = stopStorage.getStore();
|
||
if (!limits) return false;
|
||
if (limits.shouldStop()) {
|
||
if (limits.slice && !limits.slice.stopReason) {
|
||
limits.slice.stopReason = "job_budget";
|
||
}
|
||
return true;
|
||
}
|
||
return (
|
||
resumable && limits.slice !== undefined && sliceExhausted(limits.slice)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Whether the backfill slice in scope has used its budget. A collection that
|
||
* has not started yet waits for the next slice rather than starting now.
|
||
* False outside a slice.
|
||
*/
|
||
export function googleHealthSliceExhausted(): boolean {
|
||
const limits = stopStorage.getStore();
|
||
if (!limits) return false;
|
||
if (limits.shouldStop()) {
|
||
if (limits.slice && !limits.slice.stopReason) {
|
||
limits.slice.stopReason = "job_budget";
|
||
}
|
||
return true;
|
||
}
|
||
return limits.slice !== undefined && sliceExhausted(limits.slice);
|
||
}
|
||
|
||
/** Pages the slice in scope has read so far (0 outside a slice). */
|
||
export function googleHealthSlicePagesRead(): number {
|
||
return stopStorage.getStore()?.slice?.pages ?? 0;
|
||
}
|
||
|
||
/** Count one page read against the slice in scope, if any. */
|
||
function noteSlicePage(): void {
|
||
const slice = stopStorage.getStore()?.slice;
|
||
if (slice) slice.pages += 1;
|
||
}
|
||
|
||
/**
|
||
* Give back to the slice in scope the pages and time a walk spent passing
|
||
* over samples beyond its edge (no-op outside a slice).
|
||
*/
|
||
function refundSliceSkip(pages: number, ms: number): void {
|
||
const slice = stopStorage.getStore()?.slice;
|
||
if (!slice) return;
|
||
slice.skippedPages += pages;
|
||
if (slice.deadline !== null) slice.deadline += ms;
|
||
}
|
||
|
||
/** The slice clock in scope, or the wall clock outside a slice. */
|
||
function sliceNow(): number {
|
||
return stopStorage.getStore()?.slice?.now() ?? Date.now();
|
||
}
|
||
|
||
/** End the slice in scope for `reason` (no-op outside a slice). */
|
||
function endSlice(reason: GoogleHealthSliceStopReason): void {
|
||
const slice = stopStorage.getStore()?.slice;
|
||
if (slice && !slice.stopReason) slice.stopReason = reason;
|
||
}
|
||
|
||
/** Whether a resumable walk can stop and keep its place here. */
|
||
function inSlice(): boolean {
|
||
return stopStorage.getStore()?.slice !== undefined;
|
||
}
|
||
|
||
export async function runWithGoogleHealthStop<T>(
|
||
shouldStop: () => boolean,
|
||
fn: () => Promise<T>,
|
||
slice?: GoogleHealthSliceBudget,
|
||
): Promise<T> {
|
||
return stopStorage.run({ shouldStop, slice }, fn);
|
||
}
|
||
|
||
/**
|
||
* Mark the resource in scope truncated without a walk: a collection a slice
|
||
* left for the next one. The cycle then reports itself incomplete, so the
|
||
* backfill does not mistake a skipped collection for an empty one.
|
||
*/
|
||
export function noteGoogleHealthDeferred(): void {
|
||
notePagination(0, 0, true);
|
||
}
|
||
|
||
export const GOOGLE_HEALTH_API_BASE = "https://health.googleapis.com/v4";
|
||
const GOOGLE_OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||
const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||
|
||
export interface GoogleHealthCredentials {
|
||
clientId: string;
|
||
clientSecret: string;
|
||
}
|
||
|
||
/**
|
||
* Resolve the OAuth `redirect_uri`, then assert it against an allowlist
|
||
* (defence-in-depth).
|
||
*
|
||
* The value is operator-controlled config (`GOOGLE_HEALTH_REDIRECT_URI`, else
|
||
* derived from `NEXT_PUBLIC_APP_URL`), not user input, so Google's
|
||
* registered-redirect check is the real backstop. But a misconfigured or
|
||
* `Host`-coerced `NEXT_PUBLIC_APP_URL` (a mis-deployed reverse proxy reflecting
|
||
* a forwarded Host) would otherwise send the authorization code's landing URL
|
||
* off-origin. Pin the target here so a malformed origin fails fast at the
|
||
* handshake rather than silently redirecting elsewhere:
|
||
* - must be an absolute, parseable URL,
|
||
* - must be https (the one exception is a localhost/loopback dev host, which
|
||
* Google itself permits over http),
|
||
* - must land on the fixed `/api/google-health/callback` path,
|
||
* - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it.
|
||
*/
|
||
export function getGoogleHealthRedirectUri(): string {
|
||
// An empty or blank value counts as unset: the compose whitelist
|
||
// materialises an unset var as an empty string, which must fall through to
|
||
// the derived URI rather than read as "not configured".
|
||
const explicit = envValue("GOOGLE_HEALTH_REDIRECT_URI");
|
||
const appUrl = envValue("NEXT_PUBLIC_APP_URL");
|
||
const raw =
|
||
explicit ?? (appUrl ? `${appUrl}/api/google-health/callback` : undefined);
|
||
|
||
if (!raw) {
|
||
throw new Error(
|
||
"Google Health redirect_uri is not configured — set GOOGLE_HEALTH_REDIRECT_URI or NEXT_PUBLIC_APP_URL",
|
||
);
|
||
}
|
||
|
||
let parsed: URL;
|
||
try {
|
||
parsed = new URL(raw);
|
||
} catch {
|
||
throw new Error(
|
||
`Google Health redirect_uri is not an absolute URL: ${raw}`,
|
||
);
|
||
}
|
||
|
||
const isLoopback =
|
||
parsed.hostname === "localhost" ||
|
||
parsed.hostname === "127.0.0.1" ||
|
||
parsed.hostname === "[::1]" ||
|
||
parsed.hostname === "::1";
|
||
if (
|
||
parsed.protocol !== "https:" &&
|
||
!(parsed.protocol === "http:" && isLoopback)
|
||
) {
|
||
throw new Error(
|
||
`Google Health redirect_uri must be https (or http on localhost): ${parsed.origin}`,
|
||
);
|
||
}
|
||
|
||
if (parsed.pathname !== "/api/google-health/callback") {
|
||
throw new Error(
|
||
`Google Health redirect_uri must target /api/google-health/callback, got ${parsed.pathname}`,
|
||
);
|
||
}
|
||
|
||
// When an explicit GOOGLE_HEALTH_REDIRECT_URI is set alongside
|
||
// NEXT_PUBLIC_APP_URL, require them to share an origin so the pinned value
|
||
// can't drift to an unexpected host relative to the app's own base URL.
|
||
if (explicit && appUrl) {
|
||
let appOrigin: string;
|
||
try {
|
||
appOrigin = new URL(appUrl).origin;
|
||
} catch {
|
||
throw new Error(`NEXT_PUBLIC_APP_URL is not an absolute URL: ${appUrl}`);
|
||
}
|
||
if (parsed.origin !== appOrigin) {
|
||
throw new Error(
|
||
`Google Health redirect_uri origin ${parsed.origin} does not match NEXT_PUBLIC_APP_URL origin ${appOrigin}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
return parsed.toString();
|
||
}
|
||
|
||
/**
|
||
* The four core Restricted read scopes HealthLog requests for v1. Every scope
|
||
* is Restricted → the operator's OAuth client needs Google verification + an
|
||
* annual CASA assessment before it leaves "Testing" publishing mode (staying in
|
||
* Testing with ≤100 test users avoids CASA at the cost of a 7-day refresh-token
|
||
* expiry).
|
||
*/
|
||
export const GOOGLE_HEALTH_CORE_SCOPES = [
|
||
"https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly",
|
||
"https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly",
|
||
"https://www.googleapis.com/auth/googlehealth.sleep.readonly",
|
||
"https://www.googleapis.com/auth/googlehealth.profile.readonly",
|
||
] as const;
|
||
|
||
/**
|
||
* Resolve the scope list HealthLog requests: the four core Restricted bundles.
|
||
* ECG/IRN are a future enhancement — add those two Restricted read scopes only
|
||
* together with an ECG/IRN reader, never before (Google penalizes requesting a
|
||
* Restricted scope the app never consumes).
|
||
*/
|
||
export function resolveGoogleHealthScopes(): string[] {
|
||
return [...GOOGLE_HEALTH_CORE_SCOPES];
|
||
}
|
||
|
||
/** The space-separated scope string sent on the authorize request. */
|
||
export function getGoogleHealthScopeString(): string {
|
||
return resolveGoogleHealthScopes().join(" ");
|
||
}
|
||
|
||
// ─── PKCE ──────────────────────────────────────────────────────
|
||
//
|
||
// Google's authorization-code flow accepts S256 PKCE. The verifier is a
|
||
// high-entropy random string; the challenge is BASE64URL(SHA256(verifier)). The
|
||
// verifier is stashed on the OAuth-state row at connect and presented on the
|
||
// token exchange at callback — never in the cookie or the URL.
|
||
|
||
export interface GoogleHealthPkcePair {
|
||
verifier: string;
|
||
challenge: string;
|
||
}
|
||
|
||
/**
|
||
* Mint a PKCE verifier + S256 challenge. 64 random bytes → 86 base64url chars,
|
||
* comfortably inside the RFC 7636 43–128 char verifier range and well past the
|
||
* 256-bit entropy floor.
|
||
*/
|
||
export function generatePkcePair(): GoogleHealthPkcePair {
|
||
const verifier = randomBytes(64).toString("base64url");
|
||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||
return { verifier, challenge };
|
||
}
|
||
|
||
/**
|
||
* Build the Google authorization URL (a browser redirect, not a fetch). `state`
|
||
* is the opaque CSRF nonce minted by `oauth-state.ts`; `codeChallenge` is the
|
||
* S256 PKCE challenge whose verifier the callback presents on token exchange.
|
||
*
|
||
* `access_type=offline` + `prompt=consent` are Google's requirement to receive
|
||
* a refresh token; `prompt=consent` forces the consent screen so a re-connect
|
||
* always returns a fresh refresh token even if the user previously granted.
|
||
*/
|
||
export function getAuthorizationUrl(
|
||
state: string,
|
||
creds: GoogleHealthCredentials,
|
||
codeChallenge: string,
|
||
): string {
|
||
const params = new URLSearchParams({
|
||
response_type: "code",
|
||
client_id: creds.clientId,
|
||
redirect_uri: getGoogleHealthRedirectUri(),
|
||
scope: getGoogleHealthScopeString(),
|
||
access_type: "offline",
|
||
prompt: "consent",
|
||
code_challenge: codeChallenge,
|
||
code_challenge_method: "S256",
|
||
state,
|
||
});
|
||
return `${GOOGLE_OAUTH_AUTH_URL}?${params}`;
|
||
}
|
||
|
||
export interface GoogleHealthTokenResponse {
|
||
access_token: string;
|
||
/**
|
||
* Present on the initial code exchange and whenever Google issues a new
|
||
* refresh token; ABSENT on a routine refresh because Google does not rotate.
|
||
* The sync layer keeps the stored token when this is missing.
|
||
*/
|
||
refresh_token?: string;
|
||
expires_in: number;
|
||
scope?: string;
|
||
token_type?: string;
|
||
}
|
||
|
||
/** Basic-auth header carrying the confidential client credentials. */
|
||
function basicAuthHeader(creds: GoogleHealthCredentials): string {
|
||
const raw = `${creds.clientId}:${creds.clientSecret}`;
|
||
return `Basic ${Buffer.from(raw, "utf8").toString("base64")}`;
|
||
}
|
||
|
||
async function postToken(
|
||
params: URLSearchParams,
|
||
creds: GoogleHealthCredentials,
|
||
verb: string,
|
||
): Promise<GoogleHealthTokenResponse> {
|
||
const start = performance.now();
|
||
const res = await safeFetch(GOOGLE_OAUTH_TOKEN_URL, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Authorization: basicAuthHeader(creds),
|
||
},
|
||
body: params.toString(),
|
||
});
|
||
|
||
const json = await res.json().catch(() => null);
|
||
const verdict = classifyGoogleHealthResponse(res.status);
|
||
const upstreamError =
|
||
typeof json?.error === "string" ? (json.error as string) : undefined;
|
||
|
||
// Google signals a revoked / expired refresh token — the user disconnected
|
||
// the app upstream, OR the 7-day "Testing"-mode refresh window lapsed — via
|
||
// `invalid_grant` on the token endpoint (sometimes a 400, sometimes a 401). A
|
||
// bare status classification buckets a 400 as `persistent` (never prompts a
|
||
// reconnect); lift an `invalid_grant` onto `reauth_required` so the connection
|
||
// surfaces the reconnect CTA. A 401 already classifies as `reauth_required`.
|
||
const classification: GoogleHealthClassification =
|
||
upstreamError === "invalid_grant"
|
||
? "reauth_required"
|
||
: verdict.classification;
|
||
|
||
getEvent()?.addExternalCall({
|
||
service: "google-health",
|
||
method: verb,
|
||
duration_ms: Math.round(performance.now() - start),
|
||
status: res.status,
|
||
error: verdict.classification === "success" ? undefined : verdict.reason,
|
||
});
|
||
if (classification !== "success") {
|
||
throw new GoogleHealthApiError({
|
||
verb,
|
||
classification,
|
||
httpStatus: verdict.httpStatus,
|
||
reason: verdict.reason,
|
||
upstreamError,
|
||
});
|
||
}
|
||
return json as GoogleHealthTokenResponse;
|
||
}
|
||
|
||
/**
|
||
* Exchange an authorization code for the initial token pair, presenting the
|
||
* PKCE verifier. `redirect_uri` must exactly match the one sent to the
|
||
* authorize endpoint.
|
||
*/
|
||
export async function exchangeCode(
|
||
code: string,
|
||
codeVerifier: string,
|
||
creds: GoogleHealthCredentials,
|
||
): Promise<GoogleHealthTokenResponse> {
|
||
return postToken(
|
||
new URLSearchParams({
|
||
grant_type: "authorization_code",
|
||
code,
|
||
client_id: creds.clientId,
|
||
redirect_uri: getGoogleHealthRedirectUri(),
|
||
code_verifier: codeVerifier,
|
||
}),
|
||
creds,
|
||
"exchangeCode",
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Refresh an expired access token. Google does NOT rotate refresh tokens — the
|
||
* response carries a fresh `access_token` + `expires_in` but usually omits
|
||
* `refresh_token`. The caller persists the new access token + expiry and keeps
|
||
* the stored refresh token unless a new one is returned. The original scope is
|
||
* preserved by Google, so no `scope` param is re-sent. A revoked / expired
|
||
* refresh token throws a `GoogleHealthApiError` classified `reauth_required`.
|
||
*/
|
||
export async function refreshAccessToken(
|
||
refreshToken: string,
|
||
creds: GoogleHealthCredentials,
|
||
): Promise<GoogleHealthTokenResponse> {
|
||
return postToken(
|
||
new URLSearchParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: creds.clientId,
|
||
}),
|
||
creds,
|
||
"refreshAccessToken",
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The Google Health profile carries the external user id used as the
|
||
* connection's `externalUserId`. A single GET, not a paginated collection.
|
||
*/
|
||
export interface GoogleHealthProfile {
|
||
/**
|
||
* External user identifier. Google returns `me`-relative profile data; the
|
||
* stable id is surfaced under `name` (a `users/{id}` resource name) or `id`
|
||
* depending on the API surface — both are captured here and resolved at the
|
||
* call site. Re-verify the exact field against a live account at build.
|
||
*/
|
||
name?: string;
|
||
id?: string;
|
||
}
|
||
|
||
export async function fetchProfile(
|
||
accessToken: string,
|
||
): Promise<GoogleHealthProfile> {
|
||
const start = performance.now();
|
||
const res = await safeFetch(`${GOOGLE_HEALTH_API_BASE}/users/me/profile`, {
|
||
method: "GET",
|
||
headers: { Authorization: `Bearer ${accessToken}` },
|
||
});
|
||
const json = (await res
|
||
.json()
|
||
.catch(() => null)) as GoogleHealthProfile | null;
|
||
const verdict = classifyGoogleHealthResponse(res.status);
|
||
const apiErrorDetail =
|
||
verdict.classification === "success"
|
||
? undefined
|
||
: extractGoogleApiErrorDetail(json);
|
||
getEvent()?.addExternalCall({
|
||
service: "google-health",
|
||
method: "fetchProfile",
|
||
duration_ms: Math.round(performance.now() - start),
|
||
status: res.status,
|
||
error:
|
||
verdict.classification === "success"
|
||
? undefined
|
||
: apiErrorDetail
|
||
? `${verdict.reason} ${apiErrorDetail}`
|
||
: verdict.reason,
|
||
});
|
||
if (verdict.classification !== "success") {
|
||
throw new GoogleHealthApiError({
|
||
verb: "fetchProfile",
|
||
classification: verdict.classification,
|
||
httpStatus: verdict.httpStatus,
|
||
reason: verdict.reason,
|
||
upstreamError: apiErrorDetail,
|
||
});
|
||
}
|
||
return (json ?? {}) as GoogleHealthProfile;
|
||
}
|
||
|
||
/**
|
||
* Resolve the stable external user id from a Google Health profile. The
|
||
* `users/{id}` resource name is the canonical anchor; fall back to a bare `id`
|
||
* or "me" so the connection always persists a non-empty `externalUserId`.
|
||
*/
|
||
export function resolveGoogleHealthUserId(
|
||
profile: GoogleHealthProfile,
|
||
): string {
|
||
if (profile.name) {
|
||
const tail = profile.name.split("/").pop();
|
||
if (tail) return tail;
|
||
}
|
||
if (profile.id) return profile.id;
|
||
return "me";
|
||
}
|
||
|
||
// ─── Data-point reads (Google Health `dataPoints.list` / `:dailyRollUp`) ──────
|
||
//
|
||
// The Google Health API exposes one uniform `DataPoint` resource shape across
|
||
// every data type. Spot / daily-summary / session types are read through
|
||
// `GET /v4/users/me/dataTypes/{dataType}/dataPoints` with `nextPageToken`
|
||
// pagination; the cumulative activity types (steps / distance / active energy /
|
||
// floors) are read through `POST …/dataPoints:dailyRollUp` — their `list`
|
||
// surface returns minute-grain observation buckets (or, for floors, does not
|
||
// exist at all), so the daily totals MUST come from the rollup.
|
||
//
|
||
// Casing gotcha (three encodings per type, all pinned in
|
||
// `GOOGLE_HEALTH_DATA_TYPES` so a fetcher can never mix them up):
|
||
// - request path: kebab-case (`body-fat`)
|
||
// - `filter` predicate: snake_case (`body_fat.sample_time.physical_time`) —
|
||
// EXCEPT daily-summary `.date` filters, where the docs contradict
|
||
// themselves and the worked example uses the camelCase payload key; the
|
||
// client sends camel first and falls back to snake on a first-page 400
|
||
// (see `GoogleHealthDateFilterStyle`).
|
||
// - response payload: camelCase — the `DataPoint` value is a union keyed by
|
||
// the camelCase type name (`bodyFat`, `dailyRestingHeartRate`, …) with
|
||
// camelCase nested objects (`sampleTime.physicalTime`,
|
||
// `interval.startTime`, `civilStartTime.date`).
|
||
//
|
||
|
||
// proto3 int64 fields arrive as JSON **strings** (`"12345"`) — every numeric
|
||
// extractor coerces numeric strings before the finite check.
|
||
|
||
/**
|
||
* Page-size ceiling for `dataPoints.list`. The daily/intraday reads default to
|
||
* 1440 (one-per-minute) and cap at 10 000; sleep/exercise cap at 25. The launch
|
||
* metrics use the daily/spot reads, so the default page size is the larger.
|
||
*/
|
||
export const GOOGLE_HEALTH_PAGE_SIZE = 1000;
|
||
/** Sleep/exercise read cap — matches the Google Health 25-cap for those types. */
|
||
export const GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE = 25;
|
||
|
||
/**
|
||
* Page ceiling for the intraday heart-rate walk.
|
||
*
|
||
* The default of 1000 pages is a million points, which a watch that records
|
||
* a reading a minute passes in under two years. A walk that hits the ceiling
|
||
* reports `truncated`, the backfill treats that as incomplete and throws, and
|
||
* pg-boss retries it — so an account with a longer history re-ran the whole
|
||
* walk on every retry and every boot and never completed. Ten thousand pages
|
||
* is nineteen years at that rate: still a bound on a runaway cursor, no longer
|
||
* a bound a real history reaches. The walk is paged (`forEachDataPointPage`),
|
||
* so the ceiling costs time, not memory.
|
||
*/
|
||
export const GOOGLE_HEALTH_DENSE_MAX_PAGES = 10_000;
|
||
|
||
/** Page ceiling for every other list walk. */
|
||
export const GOOGLE_HEALTH_DEFAULT_MAX_PAGES = 1000;
|
||
|
||
/** `dataPoints.list` envelope: `{ dataPoints, nextPageToken }`. */
|
||
interface GoogleHealthDataPointPage {
|
||
dataPoints?: GoogleHealthDataPoint[];
|
||
nextPageToken?: string | null;
|
||
}
|
||
|
||
interface DataPointQuery {
|
||
/** Lower-bound incremental cursor; omitted on a full backfill. */
|
||
start?: Date;
|
||
/** Page size (defaults to `GOOGLE_HEALTH_PAGE_SIZE`). */
|
||
pageSize?: number;
|
||
/** Hard ceiling on pages walked (defence against a runaway cursor). */
|
||
maxPages?: number;
|
||
/**
|
||
* The user's IANA zone — needed only by the `civilStart` filter (the
|
||
* offset-less civil bound must be the watermark's wall clock in the USER'S
|
||
* zone). Omitted → the bound forms in UTC.
|
||
*/
|
||
tz?: string;
|
||
/**
|
||
* Observes which daily-summary `.date` filter prefix style the walk settled
|
||
* on (`camel` worked-example vs `snake` fallback) — the structure probe
|
||
* surfaces it so a live account reports which grammar Google accepted.
|
||
*/
|
||
onDateFilterStyle?: (style: GoogleHealthDateFilterStyle) => void;
|
||
/**
|
||
* The walk keeps its place across attempts (a backfill's resume cursor), so
|
||
* it may stop on the backfill slice's budget and on a rate limit it cannot
|
||
* wait out, and report itself truncated instead of failing: the next slice
|
||
* picks up from the edge it recorded.
|
||
*/
|
||
resumable?: boolean;
|
||
/**
|
||
* Where an earlier attempt of this walk got to: the oldest sample it wrote
|
||
* when the walk ran newest first (`desc`), the newest when it ran oldest
|
||
* first (`asc`). The walk asks only for the samples at or beyond it; the
|
||
* one sample on the edge itself is read again and overwrites in place.
|
||
*
|
||
* `filter: false` reads without the edge bound and passes over the samples
|
||
* beyond the edge here instead (see `forEachDataPointPage`); the walk falls
|
||
* back to that on its own when Google refuses the bound, and calls
|
||
* `onFilterUnsupported` once when it does. `tz` reads an offset-less sample
|
||
* time the way the edge was recorded. `maxSkipPages` bounds how many pages
|
||
* the walk may pass over before it reaches the edge.
|
||
*/
|
||
edge?: {
|
||
at: Date;
|
||
order: "desc" | "asc";
|
||
filter?: boolean;
|
||
tz?: string;
|
||
onFilterUnsupported?: () => void | Promise<void>;
|
||
maxSkipPages?: number;
|
||
};
|
||
}
|
||
|
||
const pad2 = (n: number): string => String(n).padStart(2, "0");
|
||
|
||
/**
|
||
* Format a UTC instant as the offset-less civil wall clock an observer in `tz`
|
||
* reads at that moment (`YYYY-MM-DDTHH:MM:SS`, no `Z`, no offset) — the bound
|
||
* format the session `civil_start_time` filter expects. Without `tz` the bound
|
||
* forms in UTC.
|
||
*/
|
||
export function formatCivilBound(instant: Date, tz?: string): string {
|
||
if (!tz) return instant.toISOString().slice(0, 19);
|
||
const p = wallClockInTz(instant, tz);
|
||
return `${p.year}-${pad2(p.month)}-${pad2(p.day)}T${pad2(p.hour)}:${pad2(p.minute)}:${pad2(p.second)}`;
|
||
}
|
||
|
||
/**
|
||
* The daily-summary `.date` filter prefix style. The official docs contradict
|
||
* themselves on this one point:
|
||
* - The data-types index states "In a filter parameter … the data type name
|
||
* must be in snake case, such as `body_fat`" and its per-type "filter
|
||
* parameter" column lists `daily_heart_rate_variability` etc.
|
||
* - The `dataPoints.list` reference's ONLY worked daily-summary example is
|
||
* `dailyHeartRateVariability.date < "2024-08-15"` — the camelCase payload
|
||
* union key, NOT snake_case.
|
||
* Live behaviour: the snake_case daily filter is accepted (HTTP 200) but has
|
||
* returned zero rows on accounts whose companion app visibly holds daily HRV /
|
||
* RHR — consistent with a vacuously-unmatched predicate. The client therefore
|
||
* sends the worked-example camelCase form first and falls back to snake_case
|
||
* once if the very first page is rejected with a 400 (see `fetchDataPoints`).
|
||
*/
|
||
export type GoogleHealthDateFilterStyle = "camel" | "snake";
|
||
|
||
/**
|
||
* Build the incremental `filter` field + bound for one list-read data type.
|
||
* Centralised so the predicate and the read-time anchor resolution can never
|
||
* drift. The legal filter fields are per-shape (anything else 400s):
|
||
* - `sample` → `{filter}.sample_time.physical_time` (RFC-3339).
|
||
* - `date` → `{key|filter}.date` (`YYYY-MM-DD`) — prefix style per
|
||
* `GoogleHealthDateFilterStyle` (docs conflict; camel is the worked
|
||
* example, snake the fallback).
|
||
* - `sessionEnd` → sleep only: `{filter}.interval.end_time` (RFC-3339) — the
|
||
* ONLY filterable time field on sleep; watermark semantics improve too (a
|
||
* night is fetched when it ENDS after the cursor).
|
||
* - `civilStart` → exercise: `{filter}.interval.civil_start_time` with an
|
||
* offset-less civil bound in the user's zone.
|
||
* `rollup` types never build a list filter — they read via `:dailyRollUp`.
|
||
*/
|
||
export function incrementalFilter(
|
||
dataType: GoogleHealthDataType,
|
||
start: Date,
|
||
tz?: string,
|
||
dateStyle: GoogleHealthDateFilterStyle = "camel",
|
||
): { field: string; bound: string } {
|
||
switch (dataType.timeField) {
|
||
case "sample":
|
||
return {
|
||
field: `${dataType.filter}.sample_time.physical_time`,
|
||
bound: start.toISOString(),
|
||
};
|
||
case "sessionEnd":
|
||
return {
|
||
field: `${dataType.filter}.interval.end_time`,
|
||
bound: start.toISOString(),
|
||
};
|
||
case "civilStart":
|
||
return {
|
||
field: `${dataType.filter}.interval.civil_start_time`,
|
||
bound: formatCivilBound(start, tz),
|
||
};
|
||
case "date":
|
||
return {
|
||
field: `${dateStyle === "camel" ? dataType.key : dataType.filter}.date`,
|
||
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates
|
||
bound: start.toISOString().slice(0, 10),
|
||
};
|
||
case "rollup":
|
||
throw new Error(
|
||
`Google Health data type ${dataType.path} reads via :dailyRollUp, not dataPoints.list`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Redact + bound an upstream Google API error message before it reaches an
|
||
* error object / wide event: strip bearer tokens, drop query strings from any
|
||
* embedded URL (they can carry filters echoing user data or tokens), cap at
|
||
* 200 chars.
|
||
*/
|
||
function redactApiErrorMessage(msg: string): string {
|
||
return msg
|
||
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
|
||
.replace(/(https?:\/\/[^\s"'?]+)\?[^\s"']*/gi, "$1?[REDACTED]")
|
||
.slice(0, 200);
|
||
}
|
||
|
||
/**
|
||
* Collect the `google.rpc.BadRequest` field violations out of an AIP-193
|
||
* `error.details[]` array as `field: description` fragments. Only the field
|
||
* PATH and the description sentence survive — both run through the message
|
||
* redactor and a per-fragment cap, and at most three violations are kept, so
|
||
* no request value can ride an error string into the logs.
|
||
*/
|
||
function extractFieldViolations(details: unknown): string[] {
|
||
if (!Array.isArray(details)) return [];
|
||
const out: string[] = [];
|
||
for (const d of details) {
|
||
if (!d || typeof d !== "object") continue;
|
||
const fv = (d as { fieldViolations?: unknown }).fieldViolations;
|
||
if (!Array.isArray(fv)) continue;
|
||
for (const v of fv) {
|
||
if (out.length >= 3) return out;
|
||
if (!v || typeof v !== "object") continue;
|
||
const o = v as { field?: unknown; description?: unknown };
|
||
const field =
|
||
typeof o.field === "string"
|
||
? redactApiErrorMessage(o.field).slice(0, 80)
|
||
: undefined;
|
||
const description =
|
||
typeof o.description === "string"
|
||
? redactApiErrorMessage(o.description).slice(0, 120)
|
||
: undefined;
|
||
if (!field && !description) continue;
|
||
out.push([field, description].filter(Boolean).join(": "));
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Extract the AIP-193 error envelope (`{"error":{code,message,status,details}}`)
|
||
* from a non-2xx Google Health body into a short redacted
|
||
* `STATUS: message [field: description]` detail string, or undefined when the
|
||
* body carries no such envelope. This is what makes a field-grammar 400
|
||
* (`INVALID_ARGUMENT: Invalid filter …`) diagnosable from operator logs: the
|
||
* `details[]` BadRequest field violations name the exact offending request
|
||
* field. (The OAuth token endpoint uses the flat `{"error":"invalid_grant"}`
|
||
* shape instead — handled in `postToken`.)
|
||
*/
|
||
export function extractGoogleApiErrorDetail(json: unknown): string | undefined {
|
||
if (!json || typeof json !== "object") return undefined;
|
||
const e = (json as { error?: unknown }).error;
|
||
if (!e || typeof e !== "object") return undefined;
|
||
const o = e as { status?: unknown; message?: unknown; details?: unknown };
|
||
const status = typeof o.status === "string" ? o.status : undefined;
|
||
const message =
|
||
typeof o.message === "string"
|
||
? redactApiErrorMessage(o.message)
|
||
: undefined;
|
||
const violations = extractFieldViolations(o.details);
|
||
if (!status && !message && violations.length === 0) return undefined;
|
||
const head = [status, message].filter(Boolean).join(": ");
|
||
const tail = violations.length > 0 ? `[${violations.join("; ")}]` : "";
|
||
return [head, tail].filter(Boolean).join(" ").slice(0, 500);
|
||
}
|
||
|
||
// ─── Token source + one data request ───────────────────────────
|
||
|
||
/**
|
||
* Where a walk gets its access token. A plain string is used as given. A
|
||
* function is asked before every request, so a walk that runs for longer than
|
||
* the token lives (an access token lasts an hour; a full-history heart-rate
|
||
* walk can take several) picks up the refreshed one instead of carrying an
|
||
* expired token into a 401. `forceRefresh` asks for a new token even though
|
||
* the current one has not reached its expiry, after Google refused it.
|
||
*/
|
||
export type GoogleHealthTokenSource = (opts: {
|
||
forceRefresh: boolean;
|
||
}) => Promise<string>;
|
||
|
||
export type GoogleHealthAccessToken = string | GoogleHealthTokenSource;
|
||
|
||
/**
|
||
* How a data request sits out Google's per-user, per-minute quota (429
|
||
* `RESOURCE_EXHAUSTED`). The quota refills within the minute, so a short wait
|
||
* turns a throttled request into a completed one, where failing it used to
|
||
* cost the whole collection. The waits follow `Retry-After` (or the
|
||
* `google.rpc.RetryInfo` delay in the body) when Google sends one, and
|
||
* otherwise double from `baseDelayMs`: 2, 4, 8, 16, 32 s, a minute and a
|
||
* bit in all, which spans a full quota window. A requested delay above
|
||
* `maxDelayMs` is not slept out; the request fails as the transient it is
|
||
* and the next run picks the window up.
|
||
*/
|
||
export const GOOGLE_HEALTH_RATE_LIMIT = {
|
||
maxRetries: 5,
|
||
baseDelayMs: 2_000,
|
||
maxDelayMs: 60_000,
|
||
} as const;
|
||
|
||
/**
|
||
* The delay Google asked for on a 429, in ms: the `Retry-After` header in
|
||
* either of its forms, else a `google.rpc.RetryInfo` detail (`"30s"`) in the
|
||
* error body. Undefined when it asked for nothing readable.
|
||
*/
|
||
export function googleHealthRetryDelayMs(
|
||
res: Response,
|
||
json: unknown,
|
||
now: number = Date.now(),
|
||
): number | undefined {
|
||
const header = res.headers.get("retry-after")?.trim();
|
||
if (header) {
|
||
if (/^\d+$/.test(header)) return Number.parseInt(header, 10) * 1000;
|
||
const at = Date.parse(header);
|
||
if (Number.isFinite(at)) return Math.max(0, at - now);
|
||
}
|
||
const details = (json as { error?: { details?: unknown } } | null)?.error
|
||
?.details;
|
||
if (Array.isArray(details)) {
|
||
for (const d of details) {
|
||
const delay = (d as { retryDelay?: unknown } | null)?.retryDelay;
|
||
if (typeof delay === "string") {
|
||
const m = /^(\d+(?:\.\d+)?)s$/.exec(delay.trim());
|
||
if (m) return Math.round(Number.parseFloat(m[1]!) * 1000);
|
||
}
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* How a data request rides out a server that did not answer properly: a
|
||
* 500, 502, 503 or 504, a timeout, or a broken connection. A short history
|
||
* reads a handful of pages and rarely meets one; a full-history heart-rate
|
||
* walk reads thousands, and a single one used to end the walk and throw away
|
||
* everything it had read. Three tries more, 2, 4 and 8 s apart (or the
|
||
* `Retry-After` a 503 names, up to a minute), before the request fails.
|
||
*/
|
||
export const GOOGLE_HEALTH_TRANSIENT_RETRY = {
|
||
maxRetries: 3,
|
||
baseDelayMs: 2_000,
|
||
};
|
||
|
||
const GOOGLE_HEALTH_TRANSIENT_STATUSES = new Set([500, 502, 503, 504]);
|
||
|
||
function waitMs(ms: number): Promise<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
interface GoogleHealthDataResponse<T> {
|
||
status: number;
|
||
json: T | null;
|
||
verdict: ReturnType<typeof classifyGoogleHealthResponse>;
|
||
apiErrorDetail: string | undefined;
|
||
}
|
||
|
||
/**
|
||
* Send one data request and answer with the response it finally got.
|
||
*
|
||
* Three answers are handled here rather than handed back:
|
||
* - 429: the request waits (see `GOOGLE_HEALTH_RATE_LIMIT`) and is sent
|
||
* again, up to `maxRetries` times, unless the caller's stop condition
|
||
* says to end the walk.
|
||
* - a server error or no answer at all (see
|
||
* `GOOGLE_HEALTH_TRANSIENT_RETRY`): the same, with its own count.
|
||
* - 401 with a token source: the token expired or was revoked between
|
||
* the source's last check and this request. The source is asked once for
|
||
* a fresh token and the request is repeated. A second 401, or a 401 on a
|
||
* fixed string token, is returned like any other answer.
|
||
*
|
||
* Every attempt is recorded as its own external call, so the wide event
|
||
* shows the throttling and the waits rather than only their outcome.
|
||
*/
|
||
async function googleHealthDataRequest<T>(
|
||
token: GoogleHealthAccessToken,
|
||
label: string,
|
||
send: (accessToken: string) => Promise<Response>,
|
||
expectedFallbackNote: (
|
||
status: number,
|
||
apiErrorDetail: string | undefined,
|
||
) => string | undefined = () => undefined,
|
||
): Promise<GoogleHealthDataResponse<T>> {
|
||
let rateLimited = 0;
|
||
let transient = 0;
|
||
let refreshed = false;
|
||
let forceRefresh = false;
|
||
for (;;) {
|
||
const accessToken =
|
||
typeof token === "string" ? token : await token({ forceRefresh });
|
||
forceRefresh = false;
|
||
const started = performance.now();
|
||
let res: Response;
|
||
try {
|
||
res = await send(accessToken);
|
||
} catch (err) {
|
||
// No answer at all: the request timed out or the connection broke.
|
||
// On a walk of a thousand pages one of those is a matter of time, and
|
||
// it cost the whole walk.
|
||
const retry =
|
||
err instanceof SafeFetchError &&
|
||
(err.kind === "timeout" || err.kind === "network") &&
|
||
transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries &&
|
||
!stopRequested();
|
||
const delayMs =
|
||
GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient;
|
||
getEvent()?.addExternalCall({
|
||
service: "google-health",
|
||
method: label,
|
||
duration_ms: Math.round(performance.now() - started),
|
||
...(retry
|
||
? { note: `no_response_retrying_in_ms=${delayMs}` }
|
||
: { error: err instanceof Error ? err.message : String(err) }),
|
||
});
|
||
if (!retry) throw err;
|
||
transient += 1;
|
||
await waitMs(delayMs);
|
||
continue;
|
||
}
|
||
const json = (await res.json().catch(() => null)) as T | null;
|
||
const verdict = classifyGoogleHealthResponse(res.status);
|
||
const apiErrorDetail =
|
||
verdict.classification === "success"
|
||
? undefined
|
||
: extractGoogleApiErrorDetail(json);
|
||
|
||
const retryAfterMs =
|
||
res.status === 429 ? googleHealthRetryDelayMs(res, json) : undefined;
|
||
const backoffMs =
|
||
res.status === 429
|
||
? (retryAfterMs ??
|
||
GOOGLE_HEALTH_RATE_LIMIT.baseDelayMs * 2 ** rateLimited)
|
||
: 0;
|
||
const willWait =
|
||
res.status === 429 &&
|
||
rateLimited < GOOGLE_HEALTH_RATE_LIMIT.maxRetries &&
|
||
backoffMs <= GOOGLE_HEALTH_RATE_LIMIT.maxDelayMs &&
|
||
!stopRequested();
|
||
const willRefresh =
|
||
res.status === 401 && typeof token !== "string" && !refreshed;
|
||
const transientDelayMs = GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status)
|
||
? Math.min(
|
||
googleHealthRetryDelayMs(res, json) ??
|
||
GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient,
|
||
GOOGLE_HEALTH_RATE_LIMIT.maxDelayMs,
|
||
)
|
||
: 0;
|
||
const willRetryTransient =
|
||
GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status) &&
|
||
transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries &&
|
||
!stopRequested();
|
||
|
||
getEvent()?.addExternalCall({
|
||
service: "google-health",
|
||
method: label,
|
||
duration_ms: Math.round(performance.now() - started),
|
||
status: res.status,
|
||
...(expectedFallbackNote(res.status, apiErrorDetail)
|
||
? { note: expectedFallbackNote(res.status, apiErrorDetail) }
|
||
: willWait
|
||
? { note: `rate_limited_retrying_in_ms=${backoffMs}` }
|
||
: willRefresh
|
||
? { note: "token_rejected_retrying_with_fresh_token" }
|
||
: willRetryTransient
|
||
? { note: `server_error_retrying_in_ms=${transientDelayMs}` }
|
||
: {
|
||
error:
|
||
verdict.classification === "success"
|
||
? undefined
|
||
: apiErrorDetail
|
||
? `${verdict.reason} ${apiErrorDetail}`
|
||
: verdict.reason,
|
||
}),
|
||
});
|
||
|
||
if (willWait) {
|
||
rateLimited += 1;
|
||
await waitMs(backoffMs);
|
||
continue;
|
||
}
|
||
if (willRetryTransient) {
|
||
transient += 1;
|
||
await waitMs(transientDelayMs);
|
||
continue;
|
||
}
|
||
if (willRefresh) {
|
||
refreshed = true;
|
||
forceRefresh = true;
|
||
continue;
|
||
}
|
||
return { status: res.status, json, verdict, apiErrorDetail };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Walk every `DataPoint` for one data type since the incremental cursor via
|
||
* `dataPoints.list` with `nextPageToken` pagination. The data-type id is
|
||
* kebab-cased in the path; the `filter` predicate is built per-shape against
|
||
* the type's time anchor. `rollup` types refuse — they read via
|
||
* `fetchDailyRollUp`.
|
||
*
|
||
* DAILY-SUMMARY FILTER FALLBACK: the docs contradict themselves on the `.date`
|
||
* filter's type-name prefix (the index's "filter parameter" column says
|
||
* snake_case; the list reference's only worked example says camelCase — see
|
||
* `GoogleHealthDateFilterStyle`). The walk sends the camelCase worked-example
|
||
* form first; if the VERY FIRST request of the walk is rejected with a 400,
|
||
* the whole walk retries once with the snake_case form. The style that
|
||
* succeeded is annotated as `googleHealth.dateFilter.style` so live behaviour
|
||
* reports which grammar Google actually accepts. Any other failure, or a 400
|
||
* past the first request, propagates.
|
||
*/
|
||
export async function fetchDataPoints(
|
||
dataType: GoogleHealthDataType,
|
||
accessToken: GoogleHealthAccessToken,
|
||
verb: string,
|
||
query: DataPointQuery = {},
|
||
): Promise<GoogleHealthDataPoint[]> {
|
||
const points: GoogleHealthDataPoint[] = [];
|
||
await forEachDataPointPage(dataType, accessToken, verb, query, (page) => {
|
||
for (const p of page) points.push(p);
|
||
});
|
||
return points;
|
||
}
|
||
|
||
/**
|
||
* The same walk as `fetchDataPoints`, handing each page to `onPage` as it
|
||
* arrives instead of collecting the whole collection first.
|
||
*
|
||
* A dense collection has to be read this way. Intraday heart rate is one
|
||
* point a minute, so a full-history backfill of two years is over a million
|
||
* points; held as parsed JSON and then again as mapped readings, that is more
|
||
* than a 1 GB heap, and the process that runs the backfill also serves the
|
||
* app. Page by page, only one page is ever resident.
|
||
*
|
||
* `onPage` is awaited before the next page is requested, so a slow write
|
||
* applies back-pressure to the fetch. The date-filter fallback still works:
|
||
* it only fires when the first request is rejected, which is before any page
|
||
* reaches `onPage`.
|
||
*/
|
||
export async function forEachDataPointPage(
|
||
dataType: GoogleHealthDataType,
|
||
accessToken: GoogleHealthAccessToken,
|
||
verb: string,
|
||
query: DataPointQuery,
|
||
onPage: (points: GoogleHealthDataPoint[]) => void | Promise<void>,
|
||
): Promise<void> {
|
||
const maxPages = query.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES;
|
||
const pageSize = query.pageSize ?? GOOGLE_HEALTH_PAGE_SIZE;
|
||
const resumable = query.resumable === true;
|
||
const edge = query.edge;
|
||
|
||
let requestCount = 0;
|
||
|
||
const walk = async (
|
||
dateStyle: GoogleHealthDateFilterStyle,
|
||
edgeBy: "filter" | "skip",
|
||
): Promise<void> => {
|
||
let fetched = 0;
|
||
let pageToken: string | null | undefined;
|
||
let pageCount = 0;
|
||
let stopped = false;
|
||
// Reading without the edge bound: the pages before the edge are passed
|
||
// over, not written, until the first sample at or beyond it.
|
||
const skipTo = edge && edgeBy === "skip" ? edge : undefined;
|
||
let crossed = skipTo === undefined;
|
||
let skipped = 0;
|
||
const skipStartedAt = skipTo ? sliceNow() : 0;
|
||
const maxSkip = skipTo?.maxSkipPages ?? GOOGLE_HEALTH_DENSE_MAX_PAGES;
|
||
const beyondEdge = (point: GoogleHealthDataPoint): boolean | null => {
|
||
const t = googleHealthSampleInstant(point, dataType, skipTo?.tz);
|
||
if (t === null || !skipTo) return null;
|
||
return skipTo.order === "desc"
|
||
? t.getTime() > skipTo.at.getTime()
|
||
: t.getTime() < skipTo.at.getTime();
|
||
};
|
||
const refundSkip = () => {
|
||
if (skipped > 0) refundSliceSkip(skipped, sliceNow() - skipStartedAt);
|
||
skipped = 0;
|
||
};
|
||
|
||
try {
|
||
do {
|
||
// A walk still passing over the samples beyond its edge does not stop
|
||
// on the slice's budget, only on the job's: the skip writes nothing,
|
||
// so a slice that stopped inside it would have nothing to show, and
|
||
// the next slice would start the same skip over (see
|
||
// `GoogleHealthSliceBudget.skippedPages`).
|
||
if (crossed ? stopRequested(resumable) : stopRequested(false)) {
|
||
stopped = true;
|
||
break;
|
||
}
|
||
if (!crossed && skipped >= maxSkip) {
|
||
getEvent()?.addWarning(
|
||
`google-health ${verb}: passed over ${skipped} pages without reaching the resume edge`,
|
||
);
|
||
stopped = true;
|
||
break;
|
||
}
|
||
const params = new URLSearchParams({ pageSize: String(pageSize) });
|
||
const filters: string[] = [];
|
||
if (query.start) {
|
||
const { field, bound } = incrementalFilter(
|
||
dataType,
|
||
query.start,
|
||
query.tz,
|
||
dateStyle,
|
||
);
|
||
filters.push(`${field} >= "${bound}"`);
|
||
}
|
||
const edgeFiltered = edge !== undefined && edgeBy === "filter";
|
||
if (edgeFiltered) {
|
||
const { field, bound } = incrementalFilter(
|
||
dataType,
|
||
edge.at,
|
||
query.tz,
|
||
dateStyle,
|
||
);
|
||
filters.push(
|
||
`${field} ${edge.order === "desc" ? "<=" : ">="} "${bound}"`,
|
||
);
|
||
}
|
||
if (filters.length > 0) params.set("filter", filters.join(" AND "));
|
||
if (pageToken) params.set("pageToken", pageToken);
|
||
|
||
requestCount += 1;
|
||
// v1.42 (#1023) — the first filtered daily-summary read answering 400
|
||
// is the expected trigger for the snake_case retry below, not a
|
||
// failure: some accounts reject the documented camelCase prefix on
|
||
// some types, and the very next request answers 200. It was logged as
|
||
// an error next to the 200 that followed it. It is recorded as a note
|
||
// now; any other 400 still carries its error. The same holds for the
|
||
// first resumed request refusing the edge bound.
|
||
const firstRequest = requestCount === 1;
|
||
const firstOfWalk = pageCount === 0;
|
||
const edgeRefused = (status: number, detail: string | undefined) =>
|
||
edgeFiltered &&
|
||
firstOfWalk &&
|
||
status === 400 &&
|
||
refusesEdgeFilter(detail);
|
||
const { status, json, verdict, apiErrorDetail } =
|
||
await googleHealthDataRequest<GoogleHealthDataPointPage>(
|
||
accessToken,
|
||
`${verb}(page=${pageCount})`,
|
||
(token) =>
|
||
safeFetch(
|
||
`${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints?${params}`,
|
||
{
|
||
method: "GET",
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
},
|
||
),
|
||
(status, detail) =>
|
||
canFallBack &&
|
||
dateStyle === "camel" &&
|
||
firstRequest &&
|
||
status === 400
|
||
? "date_filter_rejected_retrying_snake_case"
|
||
: edgeRefused(status, detail)
|
||
? "edge_filter_rejected_reading_without_it"
|
||
: undefined,
|
||
);
|
||
if (edgeRefused(status, apiErrorDetail)) {
|
||
throw new EdgeFilterRefused();
|
||
}
|
||
// A rate limit the request could not wait out ends a resumable walk
|
||
// inside a backfill slice where it is: the pages before it are written
|
||
// and their edge recorded, so this is a pause, not a failure. The slice
|
||
// ends with it, since every other walk would meet the same quota.
|
||
if (status === 429 && resumable && inSlice()) {
|
||
endSlice("rate_limited");
|
||
stopped = true;
|
||
break;
|
||
}
|
||
if (verdict.classification !== "success") {
|
||
throw new GoogleHealthApiError({
|
||
verb,
|
||
classification: verdict.classification,
|
||
httpStatus: verdict.httpStatus,
|
||
reason: verdict.reason,
|
||
upstreamError: apiErrorDetail,
|
||
});
|
||
}
|
||
|
||
let page = json?.dataPoints ?? [];
|
||
pageToken = json?.nextPageToken ?? null;
|
||
pageCount += 1;
|
||
fetched += page.length;
|
||
noteSlicePage();
|
||
if (!crossed) {
|
||
// Placed samples beyond the edge were written by an earlier slice;
|
||
// one without a sample time cannot be placed and is passed over
|
||
// with them until the walk is past the edge.
|
||
const kept = page.filter((p) => beyondEdge(p) === false);
|
||
if (kept.length === 0) {
|
||
skipped += 1;
|
||
continue;
|
||
}
|
||
crossed = true;
|
||
refundSkip();
|
||
page = page.filter((p) => beyondEdge(p) !== true);
|
||
}
|
||
await onPage(page);
|
||
} while (pageToken && pageCount - skipped < maxPages);
|
||
} finally {
|
||
refundSkip();
|
||
}
|
||
|
||
notePagination(pageCount, fetched, stopped || Boolean(pageToken));
|
||
};
|
||
|
||
// The fallback only exists for filtered daily-summary reads — every other
|
||
// shape has one documented, doc-consistent filter field.
|
||
const canFallBack =
|
||
dataType.timeField === "date" && query.start !== undefined;
|
||
|
||
let style: GoogleHealthDateFilterStyle = "camel";
|
||
let edgeBy: "filter" | "skip" = edge?.filter === false ? "skip" : "filter";
|
||
const run = async (dateStyle: GoogleHealthDateFilterStyle) => {
|
||
try {
|
||
await walk(dateStyle, edgeBy);
|
||
} catch (err) {
|
||
if (!(err instanceof EdgeFilterRefused)) throw err;
|
||
// Once: the walk without the bound sends no edge filter to refuse.
|
||
edgeBy = "skip";
|
||
await edge?.onFilterUnsupported?.();
|
||
await walk(dateStyle, edgeBy);
|
||
}
|
||
};
|
||
try {
|
||
await run("camel");
|
||
} catch (err) {
|
||
const firstRequestRejected =
|
||
canFallBack &&
|
||
requestCount === 1 &&
|
||
err instanceof GoogleHealthApiError &&
|
||
err.httpStatus === 400;
|
||
if (!firstRequestRejected) throw err;
|
||
style = "snake";
|
||
await run("snake");
|
||
}
|
||
if (canFallBack) {
|
||
annotate({ meta: { "googleHealth.dateFilter.style": style } });
|
||
query.onDateFilterStyle?.(style);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Thrown inside a walk whose first request Google refused because of the
|
||
* resume edge's bound; `forEachDataPointPage` reads the collection again
|
||
* without it.
|
||
*/
|
||
class EdgeFilterRefused extends Error {
|
||
constructor() {
|
||
super("google-health: edge filter refused");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Whether a 400 on the first resumed request is Google refusing the edge
|
||
* bound, rather than something else about the request. The request differs
|
||
* from one Google answers every hour only by that bound, so a bare 400 or an
|
||
* INVALID_ARGUMENT counts, and so does any message naming the filter; one
|
||
* naming the page token, the page size or the access token does not.
|
||
*/
|
||
export function refusesEdgeFilter(detail: string | undefined): boolean {
|
||
if (detail === undefined) return true;
|
||
if (/filter/i.test(detail)) return true;
|
||
if (/token|page.?size/i.test(detail)) return false;
|
||
return /INVALID_ARGUMENT/.test(detail);
|
||
}
|
||
|
||
// ─── Daily roll-up reads (`POST …/dataPoints:dailyRollUp`) ─────────────────
|
||
|
||
/**
|
||
* Max civil days one dailyRollUp request range may span. Per the v4 reference
|
||
* (`users.dataTypes.dataPoints/dailyRollUp`, `range`): "The maximum range for
|
||
* `calories-in-heart-rate-zone`, `heart-rate`, `active-minutes` and
|
||
* `total-calories` is 14 days. The maximum range for all other data types is
|
||
* 90 days." The four types read here (steps / distance / active-energy-burned /
|
||
* floors) all sit in the 90-day class; the 14-day cap would only bind if
|
||
* `heart-rate` or `total-calories` ever moved onto the rollup path.
|
||
*/
|
||
export const GOOGLE_HEALTH_ROLLUP_RANGE_DAYS = 90;
|
||
|
||
/**
|
||
* Conservative chunk span for the one-shot fallback walk: the tightest range
|
||
* cap the dailyRollUp docs state for ANY data type (the 14-day class above).
|
||
* Used only after the standard 90-day first request is rejected with a 400 —
|
||
* see `fetchDailyRollUp`.
|
||
*/
|
||
export const GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS = 14;
|
||
|
||
/**
|
||
* Full-sync horizon for the rollup types, in civil days. The rollup read needs
|
||
* an explicit range (unlike the unbounded list walk), so the backfill is
|
||
* pinned: 5 years ≈ 21 chunks per type — bounded, and deeper than any
|
||
* wearable-history horizon the dashboard reads.
|
||
*/
|
||
export const GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS = 5 * 365;
|
||
|
||
/** A civil calendar date (1-based month), the dailyRollUp range unit. */
|
||
export interface GoogleHealthCivilDate {
|
||
year: number;
|
||
month: number;
|
||
day: number;
|
||
}
|
||
|
||
/** The civil date an observer in `tz` reads at `instant` (UTC without `tz`). */
|
||
export function civilDateInTz(
|
||
instant: Date,
|
||
tz?: string,
|
||
): GoogleHealthCivilDate {
|
||
if (!tz) {
|
||
return {
|
||
year: instant.getUTCFullYear(),
|
||
month: instant.getUTCMonth() + 1,
|
||
day: instant.getUTCDate(),
|
||
};
|
||
}
|
||
const p = wallClockInTz(instant, tz);
|
||
return { year: p.year, month: p.month, day: p.day };
|
||
}
|
||
|
||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||
|
||
function civilToUtcMs(d: GoogleHealthCivilDate): number {
|
||
return Date.UTC(d.year, d.month - 1, d.day);
|
||
}
|
||
|
||
function utcMsToCivil(ms: number): GoogleHealthCivilDate {
|
||
const d = new Date(ms);
|
||
return {
|
||
year: d.getUTCFullYear(),
|
||
month: d.getUTCMonth() + 1,
|
||
day: d.getUTCDate(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Split a closed-open civil range into ≤`maxDays` closed-open chunks — the
|
||
* dailyRollUp request range caps at 90 days, so a multi-year backfill walks the
|
||
* range in slices. Returns an empty list when `start >= end`.
|
||
*/
|
||
export function chunkCivilRange(
|
||
start: GoogleHealthCivilDate,
|
||
endExclusive: GoogleHealthCivilDate,
|
||
maxDays: number = GOOGLE_HEALTH_ROLLUP_RANGE_DAYS,
|
||
): Array<{ start: GoogleHealthCivilDate; end: GoogleHealthCivilDate }> {
|
||
const s = civilToUtcMs(start);
|
||
const e = civilToUtcMs(endExclusive);
|
||
const out: Array<{
|
||
start: GoogleHealthCivilDate;
|
||
end: GoogleHealthCivilDate;
|
||
}> = [];
|
||
for (let cur = s; cur < e; cur += maxDays * DAY_MS) {
|
||
const chunkEnd = Math.min(cur + maxDays * DAY_MS, e);
|
||
out.push({ start: utcMsToCivil(cur), end: utcMsToCivil(chunkEnd) });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** `dataPoints:dailyRollUp` envelope. */
|
||
interface GoogleHealthRollupPage {
|
||
rollupDataPoints?: GoogleHealthRollupPoint[];
|
||
nextPageToken?: string | null;
|
||
}
|
||
|
||
interface RollupQuery {
|
||
/** Lower-bound incremental cursor; a full backfill walks the pinned horizon. */
|
||
start?: Date;
|
||
/** The user's IANA zone — the civil range is user-local. */
|
||
tz?: string;
|
||
/**
|
||
* Observes which request shape the walk settled on (`days90` standard vs
|
||
* `days14` fallback) — the structure probe surfaces it so a live account
|
||
* reports which shape Google actually accepted.
|
||
*/
|
||
onShape?: (shape: GoogleHealthRollupShape) => void;
|
||
/**
|
||
* Observes the raw top-level KEY NAMES of the first response page (never
|
||
* values). A rollup read that parses zero points is ambiguous — the service
|
||
* either returned nothing (account-side materialisation) or returned points
|
||
* under an envelope key this reader does not know (a per-cohort naming
|
||
* drift, the class of mismatch this integration has hit three times). The
|
||
* structure probe surfaces these keys so a live account settles it in one
|
||
* report: `["rollupDataPoints"]` = genuinely empty, anything else = drift.
|
||
*/
|
||
onEnvelopeKeys?: (keys: string[]) => void;
|
||
}
|
||
|
||
/** The dailyRollUp request shape a walk settled on. */
|
||
export type GoogleHealthRollupShape = "days90" | "days14";
|
||
|
||
/**
|
||
* Build the documented dailyRollUp request body for one closed-open civil-day
|
||
* chunk (`end` exclusive). Doc-example parity (developers.google.com/health/
|
||
* endpoints, dailyRollUp sample): both range bounds carry explicit
|
||
* `{date, time}` CivilDateTime objects, and the `end` bound is the LAST civil
|
||
* day INSIDE the chunk at 23:59:59 — NOT the next day's midnight. The range
|
||
* validator counts the civil days the range touches against the documented
|
||
* cap, so an exclusive next-day-midnight end makes a maximal chunk read one
|
||
* day too wide. `windowSizeDays: 1` is the documented daily-total window;
|
||
* `pageSize` is omitted (the documented default of 1440 already covers the
|
||
* ≤90 daily windows a chunk can produce); `pageToken` rides along on
|
||
* follow-up pages ("All other request fields need to be the same as in the
|
||
* initial request when the page token is specified").
|
||
*/
|
||
export function buildDailyRollUpBody(
|
||
chunk: { start: GoogleHealthCivilDate; end: GoogleHealthCivilDate },
|
||
pageToken?: string,
|
||
): Record<string, unknown> {
|
||
const lastDay = utcMsToCivil(civilToUtcMs(chunk.end) - DAY_MS);
|
||
const body: Record<string, unknown> = {
|
||
range: {
|
||
start: {
|
||
date: chunk.start,
|
||
time: { hours: 0, minutes: 0, seconds: 0, nanos: 0 },
|
||
},
|
||
end: {
|
||
date: lastDay,
|
||
time: { hours: 23, minutes: 59, seconds: 59, nanos: 0 },
|
||
},
|
||
},
|
||
windowSizeDays: 1,
|
||
};
|
||
if (pageToken) body.pageToken = pageToken;
|
||
return body;
|
||
}
|
||
|
||
/**
|
||
* Read one cumulative data type's daily totals via `POST …/dataPoints:dailyRollUp`
|
||
* with `windowSizeDays: 1`. The civil range is user-local, chunked at ≤90 days
|
||
* per request (the documented cap for these types); without an incremental
|
||
* `start` the walk covers the pinned backfill horizon. A `nextPageToken` is
|
||
* honoured defensively (the dailyRollUp response is documented without one,
|
||
* but the request accepts page tokens and the sibling `:rollUp` documents the
|
||
* response field).
|
||
*
|
||
* FALLBACK: if the very first request of a walk is rejected with a 400
|
||
* (INVALID_ARGUMENT — a range/shape constraint, since the body already
|
||
* mirrors the documented example), the whole range is re-walked once with the
|
||
* most conservative documented span (14 days, the tightest cap the docs state
|
||
* for any type). The shape that succeeded is annotated as
|
||
* `googleHealth.rollup.shape` so live behaviour reports which constraint
|
||
* actually binds. Any other failure, or a 400 past the first request,
|
||
* propagates.
|
||
*/
|
||
export async function fetchDailyRollUp(
|
||
dataType: GoogleHealthDataType,
|
||
accessToken: GoogleHealthAccessToken,
|
||
verb: string,
|
||
query: RollupQuery = {},
|
||
): Promise<GoogleHealthRollupPoint[]> {
|
||
const now = new Date();
|
||
const from =
|
||
query.start ??
|
||
new Date(now.getTime() - GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS * DAY_MS);
|
||
// End exclusive at tomorrow (user-local) so today's running total is covered.
|
||
const startCivil = civilDateInTz(from, query.tz);
|
||
const endCivil = civilDateInTz(new Date(now.getTime() + DAY_MS), query.tz);
|
||
|
||
let requestCount = 0;
|
||
|
||
const walk = async (maxDays: number): Promise<GoogleHealthRollupPoint[]> => {
|
||
const points: GoogleHealthRollupPoint[] = [];
|
||
let chunkIndex = 0;
|
||
let totalPages = 0;
|
||
let truncated = false;
|
||
for (const chunk of chunkCivilRange(startCivil, endCivil, maxDays)) {
|
||
if (stopRequested(false)) {
|
||
truncated = true;
|
||
break;
|
||
}
|
||
let pageToken: string | null | undefined;
|
||
let pageCount = 0;
|
||
do {
|
||
const body = buildDailyRollUpBody(chunk, pageToken ?? undefined);
|
||
|
||
requestCount += 1;
|
||
const { json, verdict, apiErrorDetail } =
|
||
await googleHealthDataRequest<GoogleHealthRollupPage>(
|
||
accessToken,
|
||
`${verb}(chunk=${chunkIndex},page=${pageCount})`,
|
||
(token) =>
|
||
safeFetch(
|
||
`${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints:dailyRollUp`,
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(body),
|
||
},
|
||
),
|
||
);
|
||
if (verdict.classification !== "success") {
|
||
throw new GoogleHealthApiError({
|
||
verb,
|
||
classification: verdict.classification,
|
||
httpStatus: verdict.httpStatus,
|
||
reason: verdict.reason,
|
||
upstreamError: apiErrorDetail,
|
||
});
|
||
}
|
||
|
||
// Surface the raw envelope key names of the very first page (names
|
||
// only, never values) — see `RollupQuery.onEnvelopeKeys`.
|
||
if (
|
||
chunkIndex === 0 &&
|
||
pageCount === 0 &&
|
||
json &&
|
||
typeof json === "object"
|
||
) {
|
||
query.onEnvelopeKeys?.(Object.keys(json));
|
||
}
|
||
for (const p of json?.rollupDataPoints ?? []) points.push(p);
|
||
pageToken = json?.nextPageToken ?? null;
|
||
pageCount += 1;
|
||
noteSlicePage();
|
||
} while (pageToken && pageCount < 100);
|
||
totalPages += pageCount;
|
||
truncated ||= Boolean(pageToken);
|
||
chunkIndex += 1;
|
||
}
|
||
notePagination(totalPages, points.length, truncated);
|
||
return points;
|
||
};
|
||
|
||
let shape: GoogleHealthRollupShape = "days90";
|
||
let points: GoogleHealthRollupPoint[];
|
||
try {
|
||
points = await walk(GOOGLE_HEALTH_ROLLUP_RANGE_DAYS);
|
||
} catch (err) {
|
||
const firstRequestRejected =
|
||
requestCount === 1 &&
|
||
err instanceof GoogleHealthApiError &&
|
||
err.httpStatus === 400;
|
||
if (!firstRequestRejected) throw err;
|
||
shape = "days14";
|
||
points = await walk(GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS);
|
||
}
|
||
annotate({ meta: { "googleHealth.rollup.shape": shape } });
|
||
query.onShape?.(shape);
|
||
return points;
|
||
}
|