mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge pull request #421 from MBombeck/release/v1.27.12
Release v1.27.12 — Google Health daily totals
This commit is contained in:
11 files changed
+755
-100
No files matched your search
@@ -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,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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user