mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user