fix(google-health): page dailyRollUp chunks through walkPages

Each rollup chunk kept its own token loop with a hundred-page bound. It now
walks through walkPages: a token handed out twice ends the chunk at once
instead of re-reading the same page up to the bound, and a chunk cut off is
reported on the wide event as well as through the client outcome.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 06:49:45 +02:00
1 parent 93b3f36d96
commit 5c1b9889ec
2 files changed
+137 -47

No files matched your search

@@ -26,6 +26,7 @@ import {
buildDailyRollUpBody,
extractGoogleApiErrorDetail,
fetchDailyRollUp,
runWithGoogleHealthClientOutcome,
type GoogleHealthRollupShape,
} from "../client";
import { GoogleHealthApiError } from "../response-classifier";
@@ -219,6 +220,82 @@ describe("fetchDailyRollUp — chunk walk + conservative fallback", () => {
});
});
describe("fetchDailyRollUp — pages within a chunk (walkPages)", () => {
const oneChunk = { start: new Date("2026-06-20T00:00:00Z") };
it("follows nextPageToken to the end of a chunk", async () => {
safeFetchMock
.mockResolvedValueOnce(
jsonResponse(200, {
rollupDataPoints: [{ steps: { countSum: "1" } }],
nextPageToken: "p2",
}),
)
.mockResolvedValueOnce(
jsonResponse(200, {
rollupDataPoints: [{ steps: { countSum: "2" } }],
nextPageToken: "",
}),
);
const { result, outcome } = await runWithGoogleHealthClientOutcome(() =>
fetchDailyRollUp(
GOOGLE_HEALTH_DATA_TYPES.steps,
"token",
"fetchSteps",
oneChunk,
),
);
expect(result).toHaveLength(2);
expect(safeFetchMock).toHaveBeenCalledTimes(2);
expect(
JSON.parse((safeFetchMock.mock.calls[1]![1] as { body: string }).body)
.pageToken,
).toBe("p2");
expect(outcome).toMatchObject({ pages: 2, truncated: false });
});
it("stops a chunk whose page token comes back again and reports it truncated", async () => {
safeFetchMock.mockResolvedValue(
jsonResponse(200, {
rollupDataPoints: [{ steps: { countSum: "1" } }],
nextPageToken: "again",
}),
);
const { result, outcome } = await runWithGoogleHealthClientOutcome(() =>
fetchDailyRollUp(
GOOGLE_HEALTH_DATA_TYPES.steps,
"token",
"fetchSteps",
oneChunk,
),
);
// The first page hands out "again", the second hands it out again.
expect(safeFetchMock).toHaveBeenCalledTimes(2);
expect(result).toHaveLength(2);
expect(outcome).toMatchObject({
truncated: true,
reasonCode: "existing_page_limit",
});
});
it("stops a chunk at its page ceiling and reports it truncated", async () => {
let n = 0;
safeFetchMock.mockImplementation(async () =>
jsonResponse(200, { rollupDataPoints: [], nextPageToken: `p${++n}` }),
);
const { outcome } = await runWithGoogleHealthClientOutcome(() =>
fetchDailyRollUp(
GOOGLE_HEALTH_DATA_TYPES.steps,
"token",
"fetchSteps",
oneChunk,
),
);
expect(safeFetchMock).toHaveBeenCalledTimes(100);
expect(outcome).toMatchObject({ pages: 100, truncated: true });
});
});
describe("extractGoogleApiErrorDetail — AIP-193 field violations", () => {
it("keeps the plain STATUS: message shape without details", () => {
expect(
+60 -47
View File
@@ -55,6 +55,7 @@ import {
googleHealthWalkProgress,
noteGoogleHealthWalkPage,
} from "./backfill-bounds";
import { walkPages } from "@/lib/integrations/page-walk";
export * from "./mappers";
@@ -1605,6 +1606,13 @@ export const GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS = 14;
*/
export const GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS = 5 * 365;
/**
* Page ceiling for one dailyRollUp chunk. A chunk of at most 90 daily windows
* fits on one page of the documented default size, so a chunk that pages at
* all is unusual and one that reaches this is a runaway token.
*/
const GOOGLE_HEALTH_ROLLUP_MAX_PAGES = 100;
/** A civil calendar date (1-based month), the dailyRollUp range unit. */
export interface GoogleHealthCivilDate {
year: number;
@@ -1778,56 +1786,61 @@ export async function fetchDailyRollUp(
truncated = true;
break;
}
let pageToken: string | null | undefined;
let pageCount = 0;
do {
const body = buildDailyRollUpBody(chunk, pageToken ?? undefined);
const index = chunkIndex;
const chunkWalk = await walkPages<string, GoogleHealthRollupPoint>(
`google_health.rollup.${dataType.path}`,
GOOGLE_HEALTH_ROLLUP_MAX_PAGES,
async (pageToken, pageCount) => {
const body = buildDailyRollUpBody(chunk, pageToken);
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",
requestCount += 1;
const { json, verdict, apiErrorDetail } =
await googleHealthDataRequest<GoogleHealthRollupPage>(
accessToken,
`${verb}(chunk=${index},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),
},
body: JSON.stringify(body),
},
),
);
if (verdict.classification !== "success") {
throw new GoogleHealthApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError: apiErrorDetail,
});
}
),
);
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);
// Surface the raw envelope key names of the very first page (names
// only, never values) — see `RollupQuery.onEnvelopeKeys`.
if (
index === 0 &&
pageCount === 0 &&
json &&
typeof json === "object"
) {
query.onEnvelopeKeys?.(Object.keys(json));
}
noteSlicePage();
return {
items: json?.rollupDataPoints ?? [],
next: json?.nextPageToken || null,
};
},
);
for (const p of chunkWalk.items) points.push(p);
totalPages += chunkWalk.pages;
truncated ||= chunkWalk.truncated;
chunkIndex += 1;
}
notePagination(totalPages, points.length, truncated);