Merge pull request #421 from MBombeck/release/v1.27.12

Release v1.27.12 — Google Health daily totals
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-06 20:38:42 +02:00
commit ca1e8c2415
11 files changed
+755 -100

No files matched your search

+8
View File
@@ -2,6 +2,14 @@
## [Unreleased]
## [1.27.12] — 2026-07-06 — Google Health daily totals
### Fixed
- Steps, distance, floors, and active energy now import from Google Health. The daily-totals request counted one calendar day too many per window (an exclusive end bound where the API expects the last covered day), which the API rejected outright; the request now matches the documented example exactly, with a self-reporting fallback to the tighter window size should a stricter limit apply.
- One data type's failure no longer blocks the remaining ones — each cumulative type fetches independently, failures are reported per type, and the sync watermark is withheld so the next run refetches what failed.
- Google API errors now carry the offending field names in the logs (redacted to field paths), so a future rejection explains itself; the connection test's structure probe also exercises a minimal daily-totals request.
## [1.27.11] — 2026-07-05 — Consistency, colour, and locale fixes
### Fixed
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.27.11
version: 1.27.12
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.27.11",
"version": "1.27.12",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.27.11";
/* @sw-version-fallback */ "v1.27.12";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -76,7 +76,17 @@ function describeStructure(v: unknown, depth = 0): unknown {
}
type ProbeResult =
| { ok: true; count: number; structure: unknown }
| {
ok: true;
count: number;
structure: unknown;
/**
* Rollup types only: which documented dailyRollUp request shape Google
* accepted (`days90` standard chunking vs `days14` conservative
* fallback) — the live verdict on the range constraint.
*/
requestShape?: string;
}
| {
ok: false;
httpStatus: number | null;
@@ -102,7 +112,12 @@ async function runStructureProbe(
for (const [name, dataType] of Object.entries(GOOGLE_HEALTH_DATA_TYPES)) {
try {
let points: Record<string, unknown>[];
let requestShape: string | undefined;
if (dataType.timeField === "rollup") {
// One minimal dailyRollUp exercise per rollup type (7-day range) — the
// probe reports the response skeleton AND which documented request
// shape Google accepted, so a live account settles the range-constraint
// question from the UI.
points = await fetchDailyRollUp(
dataType,
accessToken,
@@ -110,6 +125,9 @@ async function runStructureProbe(
{
start: rollupStart,
tz,
onShape: (shape) => {
requestShape = shape;
},
},
);
} else {
@@ -129,6 +147,7 @@ async function runStructureProbe(
ok: true,
count: points.length,
structure: points.length > 0 ? describeStructure(points[0]) : null,
...(requestShape ? { requestShape } : {}),
};
} catch (e) {
results[name] =
@@ -0,0 +1,290 @@
/**
* Pins the dailyRollUp request contract against the documented v4 shape.
*
* The request body mirrors the official example (developers.google.com/health/
* endpoints): both range bounds are explicit CivilDateTime objects
* (`{date:{year,month,day}, time:{hours,minutes,seconds,nanos}}`) and the
* `end` bound is the LAST covered civil day at 23:59:59 — NOT the next day's
* midnight. The range validator counts the civil days a range touches against
* the documented cap ("The maximum range for all other data types is 90
* days"), so an exclusive next-day-midnight end makes a maximal 90-day chunk
* read as 91 days and 400s — the shape that broke the first live backfill
* chunk.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { safeFetchMock } = vi.hoisted(() => ({ safeFetchMock: vi.fn() }));
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
return { ...actual, safeFetch: safeFetchMock };
});
import {
GOOGLE_HEALTH_DATA_TYPES,
GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS,
GOOGLE_HEALTH_ROLLUP_RANGE_DAYS,
buildDailyRollUpBody,
extractGoogleApiErrorDetail,
fetchDailyRollUp,
type GoogleHealthRollupShape,
} from "../client";
import { GoogleHealthApiError } from "../response-classifier";
function jsonResponse(status: number, body: unknown): Response {
return {
status,
ok: status >= 200 && status < 300,
json: async () => body,
} as unknown as Response;
}
/** Inclusive civil-day count a captured request-body range touches. */
function touchedDays(body: {
range: {
start: { date: { year: number; month: number; day: number } };
end: { date: { year: number; month: number; day: number } };
};
}): number {
const s = body.range.start.date;
const e = body.range.end.date;
return (
(Date.UTC(e.year, e.month - 1, e.day) -
Date.UTC(s.year, s.month - 1, s.day)) /
(24 * 60 * 60 * 1000) +
1
);
}
function capturedBodies(): Array<{
range: {
start: { date: { year: number; month: number; day: number } };
end: { date: { year: number; month: number; day: number } };
};
}> {
return safeFetchMock.mock.calls.map((c) =>
JSON.parse((c[1] as { body: string }).body),
);
}
beforeEach(() => {
safeFetchMock.mockReset();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-30T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("buildDailyRollUpBody — documented request shape", () => {
it("matches the documented single-day example byte-for-byte", () => {
// Doc example: one civil day rolls up as [day 00:00:00, same day 23:59:59].
expect(
buildDailyRollUpBody({
start: { year: 2026, month: 2, day: 26 },
end: { year: 2026, month: 2, day: 27 },
}),
).toEqual({
range: {
start: {
date: { year: 2026, month: 2, day: 26 },
time: { hours: 0, minutes: 0, seconds: 0, nanos: 0 },
},
end: {
date: { year: 2026, month: 2, day: 26 },
time: { hours: 23, minutes: 59, seconds: 59, nanos: 0 },
},
},
windowSizeDays: 1,
});
});
it("keeps a maximal 90-day chunk inside the documented 90-day cap", () => {
const body = buildDailyRollUpBody({
start: { year: 2026, month: 1, day: 1 },
end: { year: 2026, month: 4, day: 1 }, // exclusive: 90 civil days
}) as Parameters<typeof touchedDays>[0];
// End bound lands on the last covered day (Mar 31), not Apr 1 midnight.
expect(body.range.end.date).toEqual({ year: 2026, month: 3, day: 31 });
expect(touchedDays(body)).toBe(GOOGLE_HEALTH_ROLLUP_RANGE_DAYS);
});
it("omits pageSize and carries pageToken only when paginating", () => {
const first = buildDailyRollUpBody({
start: { year: 2026, month: 6, day: 1 },
end: { year: 2026, month: 6, day: 8 },
});
expect(first).not.toHaveProperty("pageSize");
expect(first).not.toHaveProperty("pageToken");
const next = buildDailyRollUpBody(
{
start: { year: 2026, month: 6, day: 1 },
end: { year: 2026, month: 6, day: 8 },
},
"tok",
);
expect(next.pageToken).toBe("tok");
});
});
describe("fetchDailyRollUp — chunk walk + conservative fallback", () => {
it("walks 90-day chunks and reports the standard shape", async () => {
safeFetchMock.mockResolvedValue(
jsonResponse(200, {
rollupDataPoints: [{ steps: { countSum: "100" } }],
}),
);
let shape: GoogleHealthRollupShape | undefined;
const points = await fetchDailyRollUp(
GOOGLE_HEALTH_DATA_TYPES.steps,
"token",
"fetchSteps",
{
start: new Date("2026-01-01T00:00:00Z"),
onShape: (s) => {
shape = s;
},
},
);
expect(shape).toBe("days90");
// 2026-01-01 → 2026-07-01 (exclusive tomorrow) = 181 days → 3 chunks.
expect(safeFetchMock).toHaveBeenCalledTimes(3);
expect(points).toHaveLength(3);
for (const body of capturedBodies()) {
expect(touchedDays(body)).toBeLessThanOrEqual(
GOOGLE_HEALTH_ROLLUP_RANGE_DAYS,
);
}
});
it("re-walks at 14 days when the FIRST request is rejected with a 400", async () => {
safeFetchMock.mockResolvedValueOnce(
jsonResponse(400, {
error: { code: 400, status: "INVALID_ARGUMENT", message: "bad range" },
}),
);
safeFetchMock.mockResolvedValue(
jsonResponse(200, { rollupDataPoints: [] }),
);
let shape: GoogleHealthRollupShape | undefined;
await fetchDailyRollUp(
GOOGLE_HEALTH_DATA_TYPES.steps,
"token",
"fetchSteps",
{
start: new Date("2026-06-01T00:00:00Z"),
onShape: (s) => {
shape = s;
},
},
);
expect(shape).toBe("days14");
// First body was a single 30-day chunk; the retry re-chunks at ≤14 days.
const bodies = capturedBodies();
expect(bodies.length).toBeGreaterThan(1);
for (const body of bodies.slice(1)) {
expect(touchedDays(body)).toBeLessThanOrEqual(
GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS,
);
}
});
it("propagates a 400 past the first request instead of re-walking", async () => {
safeFetchMock.mockResolvedValueOnce(
jsonResponse(200, { rollupDataPoints: [] }),
);
safeFetchMock.mockResolvedValueOnce(
jsonResponse(400, {
error: { code: 400, status: "INVALID_ARGUMENT", message: "nope" },
}),
);
await expect(
fetchDailyRollUp(GOOGLE_HEALTH_DATA_TYPES.steps, "token", "fetchSteps", {
start: new Date("2026-01-01T00:00:00Z"),
}),
).rejects.toBeInstanceOf(GoogleHealthApiError);
expect(safeFetchMock).toHaveBeenCalledTimes(2);
});
it("propagates a non-400 first-request failure without a fallback walk", async () => {
safeFetchMock.mockResolvedValueOnce(jsonResponse(500, {}));
await expect(
fetchDailyRollUp(GOOGLE_HEALTH_DATA_TYPES.steps, "token", "fetchSteps", {
start: new Date("2026-06-01T00:00:00Z"),
}),
).rejects.toBeInstanceOf(GoogleHealthApiError);
expect(safeFetchMock).toHaveBeenCalledTimes(1);
});
});
describe("extractGoogleApiErrorDetail — AIP-193 field violations", () => {
it("keeps the plain STATUS: message shape without details", () => {
expect(
extractGoogleApiErrorDetail({
error: { code: 400, status: "INVALID_ARGUMENT", message: "Invalid" },
}),
).toBe("INVALID_ARGUMENT: Invalid");
});
it("appends fieldViolations as field: description fragments", () => {
expect(
extractGoogleApiErrorDetail({
error: {
code: 400,
status: "INVALID_ARGUMENT",
message: "Invalid argument in request.",
details: [
{
"@type": "type.googleapis.com/google.rpc.BadRequest",
fieldViolations: [
{ field: "range", description: "Range too wide." },
],
},
],
},
}),
).toBe(
"INVALID_ARGUMENT: Invalid argument in request. [range: Range too wide.]",
);
});
it("caps at three violations and tolerates a details-only envelope", () => {
const detail = extractGoogleApiErrorDetail({
error: {
details: [
{
fieldViolations: [
{ field: "a", description: "1" },
{ field: "b", description: "2" },
{ field: "c", description: "3" },
{ field: "d", description: "4" },
],
},
],
},
});
expect(detail).toBe("[a: 1; b: 2; c: 3]");
});
it("redacts tokens and URL queries inside violation descriptions", () => {
const detail = extractGoogleApiErrorDetail({
error: {
status: "INVALID_ARGUMENT",
details: [
{
fieldViolations: [
{
field: "range.start",
description:
"Bearer abc123 rejected at https://health.googleapis.com/v4/x?filter=secret",
},
],
},
],
},
});
expect(detail).not.toContain("abc123");
expect(detail).not.toContain("filter=secret");
});
});
@@ -0,0 +1,84 @@
/**
* Pins the cumulative-group independence contract: each of the four rollup
* types (steps / distance / active-energy / floors) and the VO2-max summary
* fetches independently inside `syncUserActivity`. A hard failure on ONE type
* routes through `handleCollectionFetchError` and the loop continues — the
* siblings still fetch (live regression: steps' 400 killed the whole group).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fetchDailyRollUpMock, fetchDataPointsMock, handleErrorMock } =
vi.hoisted(() => ({
fetchDailyRollUpMock: vi.fn(),
fetchDataPointsMock: vi.fn(async () => []),
handleErrorMock: vi.fn(async () => 0),
}));
vi.mock("../client", async (importOriginal) => {
const actual = await importOriginal<typeof import("../client")>();
return {
...actual,
fetchDailyRollUp: fetchDailyRollUpMock,
fetchDataPoints: fetchDataPointsMock,
};
});
vi.mock("../sync", () => ({
getValidToken: vi.fn(async () => ({
accessToken: "token",
connection: { id: "c1", googleUserId: "g1" },
})),
handleCollectionFetchError: handleErrorMock,
upsertGoogleHealthMeasurements: vi.fn(async () => ({
imported: 0,
touched: [],
})),
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(async () => "UTC"),
}));
import { syncUserActivity } from "../sync-activity";
import { GoogleHealthApiError } from "../response-classifier";
beforeEach(() => {
fetchDailyRollUpMock.mockReset();
fetchDataPointsMock.mockClear();
handleErrorMock.mockClear();
});
describe("syncUserActivity — one rollup failure never suppresses siblings", () => {
it("keeps fetching distance / active-energy / floors / vo2max after steps 400s", async () => {
fetchDailyRollUpMock.mockImplementation(
async (_dt: unknown, _token: string, verb: string) => {
if (verb === "fetchSteps") {
throw new GoogleHealthApiError({
verb,
classification: "persistent",
httpStatus: 400,
reason: "HTTP 400",
});
}
return [];
},
);
await expect(syncUserActivity("user-1")).resolves.toBe(0);
const verbs = fetchDailyRollUpMock.mock.calls.map((c) => c[2]);
expect(verbs).toEqual([
"fetchSteps",
"fetchDistance",
"fetchActiveEnergy",
"fetchFloors",
]);
// VO2 max (list read) still ran too.
expect(fetchDataPointsMock).toHaveBeenCalledTimes(1);
// The steps failure was routed through the shared handler exactly once.
expect(handleErrorMock).toHaveBeenCalledTimes(1);
expect(handleErrorMock).toHaveBeenCalledWith(
"fetchSteps",
"user-1",
expect.any(GoogleHealthApiError),
);
});
});
@@ -0,0 +1,91 @@
/**
* Pins the fail-soft contract of `handleCollectionFetchError`: a hard failure
* (anything but the per-class 403 soft-skip) records the classified sync
* failure and RETURNS — it must not rethrow, because every call site sits in a
* per-collection catch and a rethrow would abort the sibling collections
* (live: steps 400ing suppressed distance / floors / active-energy entirely).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { recordSyncFailureMock } = vi.hoisted(() => ({
recordSyncFailureMock: vi.fn(async () => {}),
}));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/crypto", () => ({ encrypt: vi.fn(), decrypt: vi.fn() }));
vi.mock("@/lib/integrations/status", () => ({
isReauthRequired: vi.fn(async () => false),
recordSyncFailure: recordSyncFailureMock,
recordSyncSuccess: vi.fn(async () => {}),
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
collapseToTypeDayKeys: vi.fn(() => []),
recomputeBucketsForMeasurement: vi.fn(async () => {}),
recomputeUserRollups: vi.fn(async () => {}),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
}));
vi.mock("../credentials", () => ({
getUserGoogleHealthCredentials: vi.fn(async () => null),
}));
vi.mock("../client", () => ({ refreshAccessToken: vi.fn() }));
import { handleCollectionFetchError } from "../sync";
import { GoogleHealthApiError } from "../response-classifier";
beforeEach(() => {
recordSyncFailureMock.mockClear();
});
describe("handleCollectionFetchError — fail-soft grouping", () => {
it("soft-skips a per-class 403 without recording a failure", async () => {
const err = new GoogleHealthApiError({
verb: "fetchSteps",
classification: "reauth_required",
httpStatus: 403,
reason: "HTTP 403",
});
await expect(
handleCollectionFetchError("fetchSteps", "user-1", err),
).resolves.toBe(0);
expect(recordSyncFailureMock).not.toHaveBeenCalled();
});
it("records a 400 as a persistent failure and returns instead of rethrowing", async () => {
const err = new GoogleHealthApiError({
verb: "fetchSteps",
classification: "persistent",
httpStatus: 400,
reason: "HTTP 400",
upstreamError: "INVALID_ARGUMENT: Invalid argument in request.",
});
await expect(
handleCollectionFetchError("fetchSteps", "user-1", err),
).resolves.toBe(0);
expect(recordSyncFailureMock).toHaveBeenCalledTimes(1);
expect(recordSyncFailureMock).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
integration: "google-health",
kind: "persistent",
errorCode: "400",
}),
);
});
it("records a transient failure (5xx) and returns as well", async () => {
const err = new GoogleHealthApiError({
verb: "fetchDistance",
classification: "transient",
httpStatus: 503,
reason: "HTTP 503",
});
await expect(
handleCollectionFetchError("fetchDistance", "user-1", err),
).resolves.toBe(0);
expect(recordSyncFailureMock).toHaveBeenCalledWith(
expect.objectContaining({ kind: "transient" }),
);
});
});
+196 -74
View File
@@ -27,7 +27,7 @@
* a refresh token (and force one on every re-consent).
*/
import { createHash, randomBytes } from "node:crypto";
import { getEvent } from "@/lib/logging/context";
import { annotate, getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import {
@@ -677,25 +677,63 @@ function redactApiErrorMessage(msg: string): string {
}
/**
* Extract the AIP-193 error envelope (`{"error":{code,message,status}}`) from a
* non-2xx Google Health body into a short redacted `STATUS: message` 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 OAuth token endpoint uses the flat
* `{"error":"invalid_grant"}` shape instead — handled in `postToken`.)
* 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 };
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;
if (!status && !message) return undefined;
return [status, message].filter(Boolean).join(": ");
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);
}
/**
@@ -778,9 +816,25 @@ export async function fetchDataPoints(
// ─── Daily roll-up reads (`POST …/dataPoints:dailyRollUp`) ─────────────────
/** Max civil days one dailyRollUp request range may span (per the v4 docs). */
/**
* 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
@@ -872,15 +926,70 @@ interface RollupQuery {
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;
}
/** 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 closed-open and user-local,
* chunked at ≤90 days per request; without an incremental `start` the walk
* covers the pinned backfill horizon. A `nextPageToken` is honoured defensively
* (the response is documented without one, but the request accepts page
* tokens).
* 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,
@@ -896,71 +1005,84 @@ export async function fetchDailyRollUp(
const startCivil = civilDateInTz(from, query.tz);
const endCivil = civilDateInTz(new Date(now.getTime() + DAY_MS), query.tz);
const points: GoogleHealthRollupPoint[] = [];
let chunkIndex = 0;
for (const chunk of chunkCivilRange(startCivil, endCivil)) {
let pageToken: string | null | undefined;
let pageCount = 0;
do {
const body: Record<string, unknown> = {
range: {
start: { date: chunk.start },
end: { date: chunk.end },
},
windowSizeDays: 1,
pageSize: 100,
};
if (pageToken) body.pageToken = pageToken;
let requestCount = 0;
const reqStart = performance.now();
const res = await safeFetch(
`${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints:dailyRollUp`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
const walk = async (maxDays: number): Promise<GoogleHealthRollupPoint[]> => {
const points: GoogleHealthRollupPoint[] = [];
let chunkIndex = 0;
for (const chunk of chunkCivilRange(startCivil, endCivil, maxDays)) {
let pageToken: string | null | undefined;
let pageCount = 0;
do {
const body = buildDailyRollUpBody(chunk, pageToken ?? undefined);
requestCount += 1;
const reqStart = performance.now();
const res = await safeFetch(
`${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints:dailyRollUp`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
body: JSON.stringify(body),
},
);
const json = (await res
.json()
.catch(() => null)) as GoogleHealthRollupPage | null;
const verdict = classifyGoogleHealthResponse(res.status);
const apiErrorDetail =
verdict.classification === "success"
? undefined
: extractGoogleApiErrorDetail(json);
getEvent()?.addExternalCall({
service: "google-health",
method: `${verb}(chunk=${chunkIndex},page=${pageCount})`,
duration_ms: Math.round(performance.now() - reqStart),
status: res.status,
error:
);
const json = (await res
.json()
.catch(() => null)) as GoogleHealthRollupPage | null;
const verdict = classifyGoogleHealthResponse(res.status);
const apiErrorDetail =
verdict.classification === "success"
? undefined
: apiErrorDetail
? `${verdict.reason} ${apiErrorDetail}`
: verdict.reason,
});
if (verdict.classification !== "success") {
throw new GoogleHealthApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError: apiErrorDetail,
: extractGoogleApiErrorDetail(json);
getEvent()?.addExternalCall({
service: "google-health",
method: `${verb}(chunk=${chunkIndex},page=${pageCount})`,
duration_ms: Math.round(performance.now() - reqStart),
status: res.status,
error:
verdict.classification === "success"
? undefined
: apiErrorDetail
? `${verdict.reason} ${apiErrorDetail}`
: verdict.reason,
});
}
if (verdict.classification !== "success") {
throw new GoogleHealthApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError: apiErrorDetail,
});
}
for (const p of json?.rollupDataPoints ?? []) points.push(p);
pageToken = json?.nextPageToken ?? null;
pageCount += 1;
} while (pageToken && pageCount < 100);
chunkIndex += 1;
for (const p of json?.rollupDataPoints ?? []) points.push(p);
pageToken = json?.nextPageToken ?? null;
pageCount += 1;
} while (pageToken && pageCount < 100);
chunkIndex += 1;
}
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;
}
+8 -3
View File
@@ -44,9 +44,14 @@ Two read methods are in use — NOT every type supports `dataPoints.list`:
- **`dataPoints:dailyRollUp`** (POST, `windowSizeDays: 1`) — the cumulative
activity totals (steps, distance, active-energy-burned, floors). Their list
surface returns minute-grain observation buckets (≈1440/day), NOT daily
totals — and **floors has no list method at all**. The request range is
CivilDateTime, closed-open, user-local, max **90 days** per request; the
client chunks longer ranges and pins the full-sync horizon
totals — and **floors has no list method at all**. The request range is a
pair of CivilDateTime bounds (`{date:{year,month,day}, time:{hours,minutes,
seconds,nanos}}`), user-local; per the documented example, the `end` bound
is the LAST covered civil day at 23:59:59 — not the next day's midnight.
Max **90 days** per request for these types (14 days only for
heart-rate / total-calories / active-minutes / calories-in-heart-rate-zone);
the client chunks longer ranges (`buildDailyRollUpBody`), falls back once to
14-day chunks if the first request 400s, and pins the full-sync horizon
(`GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS`).
Legal incremental `filter` fields are per-shape — anything else is an HTTP 400
+55 -19
View File
@@ -190,6 +190,20 @@ interface SoftSkipTracker {
}
const softSkipStorage = new AsyncLocalStorage<SoftSkipTracker>();
/**
* Per-cycle ledger of collection fetches that HARD-failed (non-403). A hard
* failure on one collection must not abort its siblings — steps 400ing must
* not suppress distance / floors / active-energy — so
* `handleCollectionFetchError` records the failure here and returns instead of
* rethrowing. The orchestrator reads the ledger to keep the cycle's verdict
* honest: any entry ⇒ the run counts as failed (partial error), the watermark
* is NOT stamped, and the next tick refetches the window.
*/
interface HardFailTracker {
failures: string[];
}
const hardFailStorage = new AsyncLocalStorage<HardFailTracker>();
/**
* Per-cycle accumulator of the `(type, day)` keys every resource's writes
* touched. Only populated on a `fullSync` backfill (when the inline per-day
@@ -204,10 +218,17 @@ const rollupDeferStorage = new AsyncLocalStorage<RollupDeferTracker>();
/**
* Single-source the per-resource collection-fetch error handling. A 403 on one
* data class soft-skips it (warn + return 0) so sibling resources still sync;
* anything else records a classified sync failure and rethrows. A soft-skip
* increments the ambient tracker so `syncUserGoogleHealth` can refuse to stamp
* success on an all-403 grant-revoke cycle that imported nothing.
* data class soft-skips it (warn + return 0) so sibling resources still sync; a
* soft-skip increments the ambient tracker so `syncUserGoogleHealth` can refuse
* to stamp success on an all-403 grant-revoke cycle that imported nothing.
*
* Anything else records a classified sync failure, warns with the per-type
* detail, notes the collection on the ambient hard-fail ledger, and RETURNS —
* it does not rethrow. Every call site sits in a per-collection catch, so
* returning lets the sibling collections in the same resource keep fetching
* (one 400 on steps must not kill distance / floors / active-energy), while
* the ledger still fails the cycle so the watermark is not stamped past the
* broken collection.
*/
export async function handleCollectionFetchError(
resource: string,
@@ -223,7 +244,12 @@ export async function handleCollectionFetchError(
return 0;
}
await recordGoogleHealthSyncFailure(userId, err);
throw err;
getEvent()?.addWarning(
`google-health ${resource} failed for ${userId}: ${err}`,
);
const hardTracker = hardFailStorage.getStore();
if (hardTracker) hardTracker.failures.push(resource);
return 0;
}
/**
@@ -562,31 +588,41 @@ export async function syncUserGoogleHealth(
import("./sync-workout"),
]);
// Explicit labels — the production bundle minifies `fn.name` into a useless
// single letter ("google-health c failed"), so the warning names the resource
// itself.
const resources = [
syncUserMetrics,
syncUserActivity,
syncUserSleep,
syncUserWorkout,
{ name: "metrics", fn: syncUserMetrics },
{ name: "activity", fn: syncUserActivity },
{ name: "sleep", fn: syncUserSleep },
{ name: "workout", fn: syncUserWorkout },
];
const tracker: SoftSkipTracker = { count: 0 };
const hardFailTracker: HardFailTracker = { failures: [] };
const deferTracker: RollupDeferTracker = { keys: [] };
let total = 0;
let anyFailed = false;
await softSkipStorage.run(tracker, async () => {
await rollupDeferStorage.run(deferTracker, async () => {
for (const fn of resources) {
try {
total += await fn(userId, resourceOpts);
} catch (err) {
anyFailed = true;
getEvent()?.addWarning(
`google-health ${fn.name} failed for ${userId}: ${err}`,
);
await hardFailStorage.run(hardFailTracker, async () => {
await rollupDeferStorage.run(deferTracker, async () => {
for (const { name, fn } of resources) {
try {
total += await fn(userId, resourceOpts);
} catch (err) {
anyFailed = true;
getEvent()?.addWarning(
`google-health ${name} sync failed for ${userId}: ${err}`,
);
}
}
}
});
});
});
// A collection that hard-failed inside a resource (recorded on the ledger by
// `handleCollectionFetchError` without aborting its siblings) still fails the
// cycle: partial error, no watermark stamp.
if (hardFailTracker.failures.length > 0) anyFailed = true;
// On a `fullSync` backfill the per-write inline rollup hook was deferred; the
// accumulated touched type-days collapse into ONE range-recompute spanning the