From 9bbbeb805cc6762aa6bcec516f62a994dd83733f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 04:42:52 +0200 Subject: [PATCH] fix(oura): derive the sync window from the newest imported reading The lookback was pinned at seven days, so a sync that stayed broken for longer than a week came back with a window too narrow to reach the gap. The quieter case did the same damage with every signal green: a ring left unsynced holds its nights in Oura's cloud until the app is opened, every hourly tick succeeds with empty arrays, and when the days finally arrive they sit outside the window. Anchor the window on the newest live Oura measurement instead, clamped between the existing seven-day floor and a new thirty-day ceiling, plus a day of overlap. Both failure modes leave the same fingerprint, so one anchor closes both, and the watermark cannot drift out of step with the data because it is the data. Tombstones are excluded: the sleep sweep soft-deletes rows a revised hypnogram orphaned, and one of those must not pass for a live reading. The ceiling is thirty rather than the ninety days Oura's guidance would allow, because the whole batch still rides one transaction. With data flowing normally the clamp floors at seven and the tick is unchanged. A connection with no rows at all takes the full month. --- src/lib/oura/__tests__/sync.test.ts | 172 +++++++++++++++++++++++++++- src/lib/oura/sync.ts | 71 +++++++++++- 2 files changed, 237 insertions(+), 6 deletions(-) diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 798947ed9..2e13277b9 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -17,6 +17,7 @@ const { createManyAndReturnMock, updateMock, findManyMock, + findFirstMock, updateManyMock, emitArrivalMock, recordSuccessMock, @@ -42,6 +43,7 @@ const { createManyAndReturnMock: vi.fn(), updateMock: vi.fn(), findManyMock: vi.fn(), + findFirstMock: vi.fn(), updateManyMock: vi.fn(), emitArrivalMock: vi.fn(), recordSuccessMock: vi.fn(), @@ -64,6 +66,7 @@ vi.mock("@/lib/db", () => ({ createManyAndReturn: createManyAndReturnMock, update: updateMock, findMany: findManyMock, + findFirst: findFirstMock, updateMany: updateManyMock, }, $transaction: transactionMock, @@ -112,10 +115,18 @@ vi.mock("../client", async (importOriginal) => { }; }); -import { syncUserOura, upsertOuraMeasurements } from "../sync"; +import { + OURA_SYNC_LOOKBACK_DAYS, + OURA_SYNC_MAX_LOOKBACK_DAYS, + resolveOuraLookbackDays, + syncUserOura, + upsertOuraMeasurements, +} from "../sync"; import { OuraApiError } from "../response-classifier"; import { isSyncFailureRecorded } from "@/lib/integrations/status"; +const DAY_MS = 24 * 60 * 60 * 1000; + const CONN = { accessToken: "acc", refreshToken: "ref", @@ -178,6 +189,11 @@ beforeEach(() => { ); updateMock.mockReset().mockResolvedValue({}); findManyMock.mockReset(); + // Watermark read: default to a fresh row (yesterday) so every pre-existing + // test keeps the shipped 7-day floor and asserts against an unchanged window. + findFirstMock + .mockReset() + .mockResolvedValue({ measuredAt: new Date(Date.now() - DAY_MS) }); updateManyMock.mockReset().mockResolvedValue({ count: 0 }); emitArrivalMock.mockReset().mockResolvedValue(undefined); recordSuccessMock.mockReset().mockResolvedValue(undefined); @@ -706,3 +722,157 @@ describe("upsertOuraMeasurements — exact insertion results", () => { expect(emitArrivalMock).toHaveBeenCalledWith("u1", [inserted], "oura"); }); }); + +/** + * v1.32.28 — the lookback window is derived from the newest Oura row we hold + * rather than pinned at seven days. + * + * Two ways days used to be lost. A sync that stayed broken for longer than a + * week came back with a window too narrow to reach the gap. And a ring left + * unsynced produced a run of EMPTY successes, every signal green, after which + * the late-arriving days fell outside the window. Both leave the same + * fingerprint: the newest row stops moving. So one anchor closes both. + */ +describe("syncUserOura — window resolution", () => { + /** Days between the fetch window's start and today, as the query sees it. */ + function windowDays(): number { + const query = fetchReadinessMock.mock.calls[0]![1] as { + startDate: string; + endDate: string; + }; + const start = Date.parse(`${query.startDate}T00:00:00Z`); + const end = Date.parse(`${query.endDate}T00:00:00Z`); + return Math.round((end - start) / DAY_MS); + } + + it("widens the window to cover a ten-day gap", async () => { + getConnMock.mockResolvedValue(CONN); + findFirstMock.mockResolvedValue({ + measuredAt: new Date(Date.now() - 10 * DAY_MS), + }); + + await syncUserOura("u1"); + + // Ten days stale plus one day of overlap over the last seen day. + expect(windowDays()).toBe(11); + // Every collection shares the one widened window, not just readiness. + for (const fetchMock of [ + fetchSleepMock, + fetchActivityMock, + fetchDailySleepMock, + fetchSpo2Mock, + fetchVo2MaxMock, + fetchCardioAgeMock, + fetchResilienceMock, + ]) { + expect(fetchMock.mock.calls[0]![1]).toEqual( + fetchReadinessMock.mock.calls[0]![1], + ); + } + }); + + it("keeps the seven-day floor while data is flowing", async () => { + getConnMock.mockResolvedValue(CONN); + findFirstMock.mockResolvedValue({ + measuredAt: new Date(Date.now() - DAY_MS), + }); + + await syncUserOura("u1"); + + expect(windowDays()).toBe(7); + // One indexed read per tick, and tombstones are excluded from it — a + // soft-deleted row must not pass for a live one. + expect(findFirstMock).toHaveBeenCalledTimes(1); + expect(findFirstMock).toHaveBeenCalledWith({ + where: { userId: "u1", source: "OURA", deletedAt: null }, + orderBy: { measuredAt: "desc" }, + select: { measuredAt: true }, + }); + }); + + it("ignores a swept row: a tombstone cannot hold the watermark fresh", async () => { + getConnMock.mockResolvedValue(CONN); + // The sleep sweep soft-deletes rows a revised hypnogram orphaned. The + // resolver's `deletedAt: null` filter means such a row is simply not + // returned, so the newest LIVE row is what answers — here, twelve days old. + findFirstMock.mockImplementation( + async (args: { where: { deletedAt: null } }) => { + expect(args.where.deletedAt).toBeNull(); + return { measuredAt: new Date(Date.now() - 12 * DAY_MS) }; + }, + ); + + await syncUserOura("u1"); + + expect(windowDays()).toBe(13); + }); + + it("takes the full month on a fresh connection with no rows at all", async () => { + getConnMock.mockResolvedValue(CONN); + findFirstMock.mockResolvedValue(null); + + await syncUserOura("u1"); + + expect(windowDays()).toBe(OURA_SYNC_MAX_LOOKBACK_DAYS); + }); + + it("clamps a ninety-day gap to the month ceiling", async () => { + getConnMock.mockResolvedValue(CONN); + findFirstMock.mockResolvedValue({ + measuredAt: new Date(Date.now() - 90 * DAY_MS), + }); + + await syncUserOura("u1"); + + expect(windowDays()).toBe(OURA_SYNC_MAX_LOOKBACK_DAYS); + }); + + it("lets an explicit lookback win without consulting the watermark", async () => { + getConnMock.mockResolvedValue(CONN); + findFirstMock.mockResolvedValue({ + measuredAt: new Date(Date.now() - 10 * DAY_MS), + }); + + await syncUserOura("u1", { lookbackDays: 3 }); + + expect(windowDays()).toBe(3); + expect(findFirstMock).not.toHaveBeenCalled(); + }); + + it("passes the resolved window to the cycle-phases leg too", async () => { + getConnMock.mockResolvedValue(CONN); + findFirstMock.mockResolvedValue({ + measuredAt: new Date(Date.now() - 10 * DAY_MS), + }); + + await syncUserOura("u1"); + + expect(fetchCyclePhasesMock).toHaveBeenCalled(); + const query = fetchCyclePhasesMock.mock.calls[0]![1] as { + startDate: string; + endDate: string; + }; + expect(query).toEqual(fetchReadinessMock.mock.calls[0]![1]); + }); +}); + +describe("resolveOuraLookbackDays", () => { + it("floors at the shipped week and ceilings at the month", async () => { + findFirstMock.mockResolvedValue({ measuredAt: new Date() }); + expect(await resolveOuraLookbackDays("u1")).toBe(OURA_SYNC_LOOKBACK_DAYS); + + findFirstMock.mockResolvedValue({ + measuredAt: new Date(Date.now() - 400 * DAY_MS), + }); + expect(await resolveOuraLookbackDays("u1")).toBe( + OURA_SYNC_MAX_LOOKBACK_DAYS, + ); + }); + + it("returns the gap plus one day of overlap in between", async () => { + findFirstMock.mockResolvedValue({ + measuredAt: new Date(Date.now() - 20 * DAY_MS), + }); + expect(await resolveOuraLookbackDays("u1")).toBe(21); + }); +}); diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 9ed4bc39a..fd6c1e2ff 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -89,11 +89,67 @@ import { import { OuraApiError, classifyOuraError } from "./response-classifier"; import { syncUserOuraCyclePhases } from "./cycle-sync"; -/** Default lookback window (days) for an incremental sync. Oura finalises a - * night's scores hours after wake; 7 days re-fetches a handful of records (the - * upserts are idempotent) and closes any catch-up gap. */ +/** Floor of the lookback window (days) for an incremental sync. Oura finalises + * a night's scores hours after wake; 7 days re-fetches a handful of records + * (the upserts are idempotent) and covers the re-score tail. */ export const OURA_SYNC_LOOKBACK_DAYS = 7; +/** + * Ceiling of the lookback window (days). A catch-up tick may widen the window + * up to this many days; beyond it the missing span is history, not an outage, + * and belongs to a backfill rather than an hourly poll. + * + * 30 rather than the ~90 days Oura's own guidance would allow: the whole batch + * still rides ONE `$transaction` with a 60 s timeout (see + * `upsertOuraMeasurements` below), and a 30-day catch-up is roughly four times + * the row count of a normal tick — comfortably inside what that transaction + * handles. Raise it only after the batch write is chunked. + */ +export const OURA_SYNC_MAX_LOOKBACK_DAYS = 30; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Resolve how far back this tick should reach, anchored on the newest Oura + * measurement we actually hold. + * + * The fixed 7-day window lost days in two ways. A sync that failed for longer + * than a week resumed with a window too narrow to reach the gap, so the missing + * days stayed missing. And a ring left unsynced (Oura's cloud has nothing until + * the app is opened) produced a run of EMPTY successes — every signal green — + * after which the late-arriving days fell outside the window and were never + * imported. + * + * Anchoring on the newest imported row covers both: rows stop arriving whether + * the sync fails or succeeds empty, so the same watermark goes stale in either + * case. It is derived, never stored, so it cannot drift out of step with the + * data the way a persisted cursor can — the watermark IS the data. + * + * `deletedAt: null` matters: the sleep sweep soft-deletes rows a revised + * hypnogram orphaned, and a tombstone must not hold the watermark artificially + * fresh. The `+ 1` day is the overlap buffer over the last seen day. With data + * flowing normally the watermark is under two days old and the clamp floors at + * `OURA_SYNC_LOOKBACK_DAYS`, so the steady-state window and request count are + * exactly what they were before. No watermark at all is a fresh connection: + * take the full ceiling. + */ +export async function resolveOuraLookbackDays(userId: string): Promise { + const newest = await prisma.measurement.findFirst({ + where: { userId, source: "OURA", deletedAt: null }, + orderBy: { measuredAt: "desc" }, + select: { measuredAt: true }, + }); + if (!newest) return OURA_SYNC_MAX_LOOKBACK_DAYS; + + const daysSince = Math.floor( + (Date.now() - newest.measuredAt.getTime()) / DAY_MS, + ); + return Math.min( + OURA_SYNC_MAX_LOOKBACK_DAYS, + Math.max(OURA_SYNC_LOOKBACK_DAYS, daysSince + 1), + ); +} + export function classifyOuraFailure(err: unknown): FailureKind { return toFailureKind(classifyOuraError(err)); } @@ -366,6 +422,11 @@ export async function syncUserOura( const conn = await getOuraConnection(userId); if (!conn) return 0; + // An explicit window from the caller wins; otherwise derive it from what we + // already hold so a gap widens the window instead of being skipped over. + const lookbackDays = + opts.lookbackDays ?? (await resolveOuraLookbackDays(userId)); + let result: OuraFetchResult; try { result = await fetchAll( @@ -373,7 +434,7 @@ export async function syncUserOura( conn.accessToken, conn.refreshToken, conn.refreshTokenCiphertext, - opts.lookbackDays ?? OURA_SYNC_LOOKBACK_DAYS, + lookbackDays, ); } catch (err) { // Only a whole-connection failure (a failed token refresh → reauth) reaches @@ -415,7 +476,7 @@ export async function syncUserOura( await syncUserOuraCyclePhases( userId, freshConn.accessToken, - opts.lookbackDays ?? OURA_SYNC_LOOKBACK_DAYS, + lookbackDays, ); } } catch (err) {