mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Every Oura collection, workouts and heart rate included, is now read back to the start of the account's history by a backfill that runs through the shared integration lane in five-minute slices. Each collection walks from the day the backfill began toward the past one window at a time (ninety days, thirty for sleep, seven for heart rate) and the progress note on the user row records after every window where it got to, so a slice cut short by its budget, the lane or a rate limit resumes there. A 429 ends the slice and the next one waits for the Retry-After Oura named. Activity and sleep are walked first to learn where history starts; the rest stop a week before it. Heart rate streams a page at a time and keeps only the days it touched, so memory stays flat however long the history is. A collection the grant does not cover is a scope note, not a failure; the first slice of the next chain asks again, and a connect clears the completion stamp so a newly granted scope is read without walking anything twice. The two columns arrive empty, so every existing connection is offered the backfill at the next boot without reconnecting. Before anything else the backfill retires the WALKING_RUNNING_DISTANCE rows earlier syncs wrote from the equivalent walking distance and refolds their days; an account that kept such rows after disconnecting is offered too.
503 lines
17 KiB
TypeScript
503 lines
17 KiB
TypeScript
/**
|
|
* The Oura history backfill against the real schema.
|
|
*
|
|
* Oura's API is replaced at the `safeFetch` seam by a generator that builds
|
|
* each page on request: a year and a half of daily activity, one workout, and
|
|
* a heart-rate reading every five minutes over the same span (some 160 000
|
|
* samples, in pages of 1 000). The backfill runs slice after slice, each one
|
|
* cut short by its budget, until it stamps its marker. What is pinned:
|
|
*
|
|
* - it reaches the end: every heart-rate sample is a PULSE row, every day a
|
|
* steps row, the workout a Workout row, the marker is stamped, and the
|
|
* legacy equivalent-distance rows are retired with their rollup day;
|
|
* - it resumes: no window is read twice across slices, apart from the one
|
|
* window a 429 interrupted, and that slice sends the next one after the
|
|
* Retry-After the 429 named;
|
|
* - it does not grow with the history: the collected heap after the first
|
|
* quarter of heart-rate pages stays inside a budget a collect-everything
|
|
* walk of the same pages passes several times over;
|
|
* - discovery offers an existing connection without a reconnect, and an
|
|
* account that kept only the legacy rows, and nothing already stamped.
|
|
*/
|
|
import v8 from "node:v8";
|
|
import vm from "node:vm";
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
const USER = "oura-backfill-owner";
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
/** The day the backfill begins, and the end of the generated history. */
|
|
const ANCHOR = "2026-10-01";
|
|
const NOW = Date.parse(`${ANCHOR}T12:00:00Z`);
|
|
/** The first day of the generated history. */
|
|
const HISTORY_FROM = "2025-04-01";
|
|
const HR_FROM = Date.parse(`${HISTORY_FROM}T00:00:00Z`);
|
|
const HR_STEP = 5 * 60_000;
|
|
const HR_PAGE = 1000;
|
|
const HR_TOTAL = Math.floor((NOW - HR_FROM) / HR_STEP);
|
|
|
|
const GROWTH_BUDGET_BYTES = 24 * 1024 * 1024;
|
|
|
|
const fake = vi.hoisted(() => ({
|
|
requests: [] as string[],
|
|
heartRatePages: 0,
|
|
/** Answer the next heart-rate page with a 429 once this many have been served. */
|
|
rateLimitAtPage: -1,
|
|
onPage: null as (() => void) | null,
|
|
/** Collections answered with a 401 naming their scope. */
|
|
denied: new Set<string>(),
|
|
sends: [] as Array<{ queue: string; payload: unknown; options: unknown }>,
|
|
}));
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("@/lib/jobs/boss-instance", () => ({
|
|
getGlobalBoss: () => ({
|
|
send: async (queue: string, payload: unknown, options: unknown) => {
|
|
fake.sends.push({ queue, payload, options });
|
|
return `job-${fake.sends.length}`;
|
|
},
|
|
}),
|
|
}));
|
|
|
|
function dayKey(ms: number): string {
|
|
return new Date(ms).toISOString().slice(0, 10);
|
|
}
|
|
|
|
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
|
|
const json = (body: unknown, status = 200, headers = {}) =>
|
|
new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json", ...headers },
|
|
});
|
|
return {
|
|
...actual,
|
|
// A plain function, not `vi.fn`: a mock records every result, and the
|
|
// results here are whole pages.
|
|
safeFetch: async (url: string) => {
|
|
const u = new URL(url);
|
|
const p = u.searchParams;
|
|
const collection = u.pathname.split("/").pop()!;
|
|
const token = p.get("next_token");
|
|
fake.requests.push(
|
|
`${collection}|${p.get("start_date") ?? p.get("start_datetime")}|${p.get("end_date") ?? p.get("end_datetime")}|${token ?? ""}`,
|
|
);
|
|
if (fake.denied.has(collection)) {
|
|
return json(
|
|
{ detail: `Token is not authorized access ${collection} scope.` },
|
|
401,
|
|
);
|
|
}
|
|
if (collection === "heartrate") {
|
|
if (fake.rateLimitAtPage === fake.heartRatePages) {
|
|
fake.rateLimitAtPage = -1;
|
|
return json({ detail: "Too many requests" }, 429, {
|
|
"retry-after": "600",
|
|
});
|
|
}
|
|
const start = Date.parse(p.get("start_datetime")!);
|
|
const end = Date.parse(p.get("end_datetime")!);
|
|
const first = Math.max(0, Math.ceil((start - HR_FROM) / HR_STEP));
|
|
const last = Math.min(HR_TOTAL, Math.ceil((end - HR_FROM) / HR_STEP));
|
|
const from = first + Number(token ?? 0) * HR_PAGE;
|
|
const to = Math.min(last, from + HR_PAGE);
|
|
const data = [];
|
|
for (let i = from; i < to; i++) {
|
|
data.push({
|
|
bpm: 52 + (i % 40),
|
|
source: "awake",
|
|
timestamp: new Date(HR_FROM + i * HR_STEP).toISOString(),
|
|
});
|
|
}
|
|
fake.heartRatePages++;
|
|
fake.onPage?.();
|
|
return json({
|
|
data,
|
|
next_token: to < last ? String(Number(token ?? 0) + 1) : null,
|
|
});
|
|
}
|
|
if (collection === "daily_activity") {
|
|
const from =
|
|
p.get("start_date")! < HISTORY_FROM
|
|
? HISTORY_FROM
|
|
: p.get("start_date")!;
|
|
const to = p.get("end_date")! > ANCHOR ? ANCHOR : p.get("end_date")!;
|
|
const data = [];
|
|
for (
|
|
let t = Date.parse(`${from}T00:00:00Z`);
|
|
dayKey(t) <= to;
|
|
t += DAY_MS
|
|
) {
|
|
data.push({
|
|
id: `act-${dayKey(t)}`,
|
|
day: dayKey(t),
|
|
steps: 5000,
|
|
active_calories: 300,
|
|
equivalent_walking_distance: 4000,
|
|
});
|
|
}
|
|
return json({ data, next_token: null });
|
|
}
|
|
if (collection === "workout") {
|
|
const day = "2026-02-14";
|
|
const inRange =
|
|
p.get("start_date")! <= day && day <= p.get("end_date")!;
|
|
return json({
|
|
data: inRange
|
|
? [
|
|
{
|
|
id: "oura-workout-1",
|
|
activity: "cycling",
|
|
calories: 410,
|
|
distance: 15000,
|
|
intensity: "moderate",
|
|
source: "manual",
|
|
day,
|
|
start_datetime: `${day}T08:00:00+00:00`,
|
|
end_datetime: `${day}T09:00:00+00:00`,
|
|
},
|
|
]
|
|
: [],
|
|
next_token: null,
|
|
});
|
|
}
|
|
return json({ data: [], next_token: null });
|
|
},
|
|
};
|
|
});
|
|
|
|
const forceGc = ((): (() => void) => {
|
|
v8.setFlagsFromString("--expose-gc");
|
|
const gc = vm.runInNewContext("gc") as () => void;
|
|
v8.setFlagsFromString("--no-expose-gc");
|
|
return gc;
|
|
})();
|
|
|
|
function liveHeapBytes(): number {
|
|
forceGc();
|
|
forceGc();
|
|
return process.memoryUsage().heapUsed;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
const prisma = getPrismaClient();
|
|
await truncateAllTables(prisma);
|
|
fake.requests = [];
|
|
fake.heartRatePages = 0;
|
|
fake.rateLimitAtPage = -1;
|
|
fake.sends = [];
|
|
fake.denied = new Set();
|
|
const { encrypt } = await import("@/lib/crypto");
|
|
await prisma.user.create({
|
|
data: {
|
|
id: USER,
|
|
username: USER,
|
|
timezone: "UTC",
|
|
ouraAccessTokenEncrypted: encrypt("access"),
|
|
ouraRefreshTokenEncrypted: encrypt("refresh"),
|
|
},
|
|
});
|
|
}, 120_000);
|
|
|
|
/** Seed the rows an earlier sync wrote from the equivalent distance. */
|
|
async function seedLegacyDistance(userId: string): Promise<void> {
|
|
const prisma = getPrismaClient();
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
{
|
|
userId,
|
|
type: "WALKING_RUNNING_DISTANCE",
|
|
source: "OURA",
|
|
value: 4000,
|
|
unit: "m",
|
|
measuredAt: new Date("2026-09-01T12:00:00Z"),
|
|
externalId: "activity:2026-09-01:distance",
|
|
},
|
|
{
|
|
userId,
|
|
type: "WALKING_RUNNING_DISTANCE",
|
|
source: "OURA",
|
|
value: 5200,
|
|
unit: "m",
|
|
measuredAt: new Date("2026-09-02T12:00:00Z"),
|
|
externalId: "activity:2026-09-02:distance",
|
|
},
|
|
// Another source's distance on the same day stays.
|
|
{
|
|
userId,
|
|
type: "WALKING_RUNNING_DISTANCE",
|
|
source: "APPLE_HEALTH",
|
|
value: 3100,
|
|
unit: "m",
|
|
measuredAt: new Date("2026-09-01T18:00:00Z"),
|
|
externalId: "hk-dist-1",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
describe("retireOuraEquivalentDistance", () => {
|
|
it("soft-deletes only the OURA equivalent-distance rows and refolds the day", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedLegacyDistance(USER);
|
|
const { recomputeBucketsForMeasurement } =
|
|
await import("@/lib/rollups/measurement-rollups");
|
|
await recomputeBucketsForMeasurement(
|
|
USER,
|
|
"WALKING_RUNNING_DISTANCE",
|
|
new Date("2026-09-01T12:00:00Z"),
|
|
);
|
|
const ouraDay = () =>
|
|
prisma.measurementRollup.findFirst({
|
|
where: {
|
|
userId: USER,
|
|
type: "WALKING_RUNNING_DISTANCE",
|
|
granularity: "DAY",
|
|
source: "OURA",
|
|
bucketStart: new Date("2026-09-01T00:00:00Z"),
|
|
},
|
|
});
|
|
expect((await ouraDay())?.count).toBe(1);
|
|
|
|
const { retireOuraEquivalentDistance } =
|
|
await import("@/lib/oura/retire-distance");
|
|
expect(await retireOuraEquivalentDistance(USER)).toBe(2);
|
|
expect(await retireOuraEquivalentDistance(USER)).toBe(0);
|
|
|
|
const live = await prisma.measurement.findMany({
|
|
where: { userId: USER, deletedAt: null },
|
|
select: { source: true, externalId: true },
|
|
});
|
|
expect(live).toEqual([{ source: "APPLE_HEALTH", externalId: "hk-dist-1" }]);
|
|
const after = await ouraDay();
|
|
expect(after?.count ?? 0).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("Oura history backfill", () => {
|
|
it("walks the whole history in resumable slices with bounded memory", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedLegacyDistance(USER);
|
|
const { runOuraBackfillForUser } = await import("@/lib/jobs/oura-backfill");
|
|
|
|
// A rate limit in the middle of the heart-rate walk.
|
|
fake.rateLimitAtPage = 40;
|
|
|
|
let clock = NOW;
|
|
const now = () => (clock += 1000);
|
|
const quarter = Math.ceil(HR_TOTAL / HR_PAGE / 4);
|
|
let anchor: number | null = null;
|
|
let peak = 0;
|
|
let samples = 0;
|
|
fake.onPage = () => {
|
|
if (fake.heartRatePages % 5 !== 0) return;
|
|
const live = liveHeapBytes();
|
|
if (anchor === null) {
|
|
if (fake.heartRatePages >= quarter) anchor = peak = live;
|
|
return;
|
|
}
|
|
samples++;
|
|
peak = Math.max(peak, live);
|
|
};
|
|
|
|
let slice = 0;
|
|
let continued = true;
|
|
let slices = 0;
|
|
try {
|
|
while (continued) {
|
|
const run = await runOuraBackfillForUser(USER, () => false, slice, {
|
|
sliceMs: 40_000,
|
|
now,
|
|
});
|
|
continued = run.continued;
|
|
slice += 1;
|
|
slices += 1;
|
|
expect(slices).toBeLessThan(200);
|
|
}
|
|
} finally {
|
|
fake.onPage = null;
|
|
}
|
|
process.stderr.write(
|
|
`[oura-backfill] slices=${slices} hrPages=${fake.heartRatePages} growth=${((peak - (anchor ?? 0)) / 1024 / 1024).toFixed(1)} MB over ${samples} samples\n`,
|
|
);
|
|
|
|
// It ran in many slices, and finished.
|
|
expect(slices).toBeGreaterThan(5);
|
|
const user = await prisma.user.findUniqueOrThrow({
|
|
where: { id: USER },
|
|
select: { ouraBackfillCompletedAt: true, ouraBackfillProgress: true },
|
|
});
|
|
expect(user.ouraBackfillCompletedAt).not.toBeNull();
|
|
const progress = user.ouraBackfillProgress as {
|
|
historyStart: string;
|
|
distanceRetired: boolean;
|
|
};
|
|
expect(progress.historyStart).toBe(HISTORY_FROM);
|
|
expect(progress.distanceRetired).toBe(true);
|
|
|
|
// Every heart-rate sample, every day, the workout.
|
|
expect(
|
|
await prisma.measurement.count({
|
|
where: { userId: USER, type: "PULSE", source: "OURA" },
|
|
}),
|
|
).toBe(HR_TOTAL);
|
|
const days =
|
|
(Date.parse(`${ANCHOR}T00:00:00Z`) -
|
|
Date.parse(`${HISTORY_FROM}T00:00:00Z`)) /
|
|
DAY_MS +
|
|
1;
|
|
expect(
|
|
await prisma.measurement.count({
|
|
where: { userId: USER, type: "ACTIVITY_STEPS", source: "OURA" },
|
|
}),
|
|
).toBe(days);
|
|
const workouts = await prisma.workout.findMany({
|
|
where: { userId: USER, source: "OURA" },
|
|
select: { externalId: true, sportType: true, totalDistanceM: true },
|
|
});
|
|
expect(workouts).toEqual([
|
|
{
|
|
externalId: "oura-workout-1",
|
|
sportType: "cycling",
|
|
totalDistanceM: 15000,
|
|
},
|
|
]);
|
|
// No equivalent distance written, and the legacy rows retired.
|
|
expect(
|
|
await prisma.measurement.count({
|
|
where: {
|
|
userId: USER,
|
|
source: "OURA",
|
|
type: "WALKING_RUNNING_DISTANCE",
|
|
deletedAt: null,
|
|
},
|
|
}),
|
|
).toBe(0);
|
|
|
|
// Resumed, never restarted: every first page of a window was asked for
|
|
// once, apart from the heart-rate window the 429 interrupted.
|
|
const firstPages = fake.requests.filter((r) => r.endsWith("|"));
|
|
const repeated = firstPages.filter((r, i) => firstPages.indexOf(r) !== i);
|
|
expect(repeated).toHaveLength(1);
|
|
expect(repeated[0]).toMatch(/^heartrate\|/);
|
|
// The slice the 429 ended sent the next one after the wait it named.
|
|
const delays = fake.sends.map(
|
|
(s) => (s.options as { startAfter?: number }).startAfter ?? 0,
|
|
);
|
|
expect(delays).toContain(600);
|
|
|
|
// Bounded: the heap after the first quarter of the stream stays flat.
|
|
expect(samples).toBeGreaterThan(5);
|
|
expect(peak - anchor!).toBeLessThan(GROWTH_BUDGET_BYTES);
|
|
}, 900_000);
|
|
|
|
it("leaves an existing connection's history to discovery, no reconnect needed", async () => {
|
|
const prisma = getPrismaClient();
|
|
// An account whose Oura connection is gone but whose legacy rows stay.
|
|
await prisma.user.create({
|
|
data: { id: "oura-legacy-only", username: "oura-legacy-only" },
|
|
});
|
|
await seedLegacyDistance("oura-legacy-only");
|
|
// An account already backfilled.
|
|
await prisma.user.create({
|
|
data: {
|
|
id: "oura-done",
|
|
username: "oura-done",
|
|
ouraAccessTokenEncrypted: "x",
|
|
ouraRefreshTokenEncrypted: "y",
|
|
ouraBackfillCompletedAt: new Date(),
|
|
},
|
|
});
|
|
const { enqueueBootTimeOuraBackfill, runOuraBackfillForUser } =
|
|
await import("@/lib/jobs/oura-backfill");
|
|
|
|
const result = await enqueueBootTimeOuraBackfill(30);
|
|
expect(result).toEqual({ enqueued: 2, skipped: 0, error: null });
|
|
expect(
|
|
fake.sends.map((s) => (s.payload as { userId: string }).userId).sort(),
|
|
).toEqual(["oura-backfill-owner", "oura-legacy-only"]);
|
|
|
|
// The legacy-only account retires its rows and is stamped at once.
|
|
await runOuraBackfillForUser("oura-legacy-only");
|
|
const legacy = await prisma.user.findUniqueOrThrow({
|
|
where: { id: "oura-legacy-only" },
|
|
select: { ouraBackfillCompletedAt: true },
|
|
});
|
|
expect(legacy.ouraBackfillCompletedAt).not.toBeNull();
|
|
expect(
|
|
await prisma.measurement.count({
|
|
where: { userId: "oura-legacy-only", source: "OURA", deletedAt: null },
|
|
}),
|
|
).toBe(0);
|
|
});
|
|
|
|
it("finishes past a collection the grant refuses, and asks again after a reconnect", async () => {
|
|
const prisma = getPrismaClient();
|
|
const { OURA_BACKFILL_ORDER, runOuraBackfillForUser } =
|
|
await import("@/lib/jobs/oura-backfill");
|
|
// Everything but workouts already walked.
|
|
await prisma.user.update({
|
|
where: { id: USER },
|
|
data: {
|
|
ouraBackfillProgress: {
|
|
anchor: ANCHOR,
|
|
historyStart: "2026-01-01",
|
|
distanceRetired: true,
|
|
collections: Object.fromEntries(
|
|
OURA_BACKFILL_ORDER.filter((n) => n !== "workout").map((n) => [
|
|
n,
|
|
{ before: "2015-01-01", done: true },
|
|
]),
|
|
),
|
|
},
|
|
},
|
|
});
|
|
fake.denied.add("workout");
|
|
|
|
expect(await runOuraBackfillForUser(USER)).toEqual({
|
|
imported: 0,
|
|
continued: false,
|
|
});
|
|
let user = await prisma.user.findUniqueOrThrow({
|
|
where: { id: USER },
|
|
select: { ouraBackfillCompletedAt: true, ouraBackfillProgress: true },
|
|
});
|
|
expect(user.ouraBackfillCompletedAt).not.toBeNull();
|
|
expect(
|
|
(user.ouraBackfillProgress as { collections: Record<string, unknown> })
|
|
.collections.workout,
|
|
).toEqual({ before: ANCHOR, denied: true });
|
|
// One request found it out; nothing was recorded as a failure.
|
|
expect(fake.requests.filter((r) => r.startsWith("workout|"))).toHaveLength(
|
|
1,
|
|
);
|
|
expect(await prisma.integrationStatus.count()).toBe(0);
|
|
|
|
// A reconnect that grants the scope clears the stamp; the next chain
|
|
// reads workouts and nothing else.
|
|
fake.denied.clear();
|
|
fake.requests = [];
|
|
await prisma.user.update({
|
|
where: { id: USER },
|
|
data: { ouraBackfillCompletedAt: null },
|
|
});
|
|
await runOuraBackfillForUser(USER);
|
|
user = await prisma.user.findUniqueOrThrow({
|
|
where: { id: USER },
|
|
select: { ouraBackfillCompletedAt: true, ouraBackfillProgress: true },
|
|
});
|
|
expect(user.ouraBackfillCompletedAt).not.toBeNull();
|
|
expect(new Set(fake.requests.map((r) => r.split("|")[0]))).toEqual(
|
|
new Set(["workout"]),
|
|
);
|
|
expect(
|
|
await prisma.workout.count({ where: { userId: USER, source: "OURA" } }),
|
|
).toBe(1);
|
|
});
|
|
});
|