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) {