mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Add per-resource cursors, fetch-by-id, and full backfill to WHOOP sync
Replace the single shared last-synced cursor with a per-resource cursor map on the WHOOP connection. Recovery, sleep, workout, and cycle each advance only their own cursor on a successful fetch+upsert, so a slow or 403-ing collection never drags the incremental window forward for its siblings and its own late-arriving records stay inside the overlap. Legacy connections fall back to the shared last-synced instant, so no historical state is lost. Add single-record fetch-by-id client routes and per-resource by-id syncs. The update webhook now carries the resource id through the queue payload, so the worker resolves and upserts that one record immediately rather than re-walking the whole collection on the next overlap tick. Anchor a full sync at a fixed deep-history point instead of returning no lower bound, so a backfill covers the multi-year span an incremental tick would skip and a stale cursor cannot silently narrow it.
This commit is contained in:
1 parent
a2260d5bb8
commit
2cb84c17e9
16 files changed
+938
-126
No files matched your search
@@ -0,0 +1,18 @@
|
||||
-- v1.16.16 (iOS #17) — per-resource WHOOP sync cursors.
|
||||
--
|
||||
-- WHOOP's collection endpoints filter on a record's OWN time range, not on
|
||||
-- when it reached the cloud, and each resource (recovery / sleep / workout /
|
||||
-- cycle) re-scores and arrives on its own latency. A single shared
|
||||
-- `last_synced_at` cursor advanced by the fastest resource then drags the
|
||||
-- incremental `start` forward for a slow or failing resource too, so its
|
||||
-- late-arriving records fall permanently before the window and are never
|
||||
-- ingested. A 403/timeout on ONE collection likewise must not poison the
|
||||
-- cursor every sibling reads.
|
||||
--
|
||||
-- `resource_cursors` is a small JSON map `{ "recovery": "<iso>", ... }` of the
|
||||
-- per-resource last-synced instant. Each resource advances only its own key on
|
||||
-- a successful fetch+upsert; a stalled resource leaves its key untouched and
|
||||
-- keeps re-fetching from where it left off without holding back the others.
|
||||
-- Null = legacy connection — the readers fall back to the shared
|
||||
-- `last_synced_at` so no historical state is lost.
|
||||
ALTER TABLE "whoop_connections" ADD COLUMN "resource_cursors" JSONB;
|
||||
@@ -2168,6 +2168,15 @@ model WhoopConnection {
|
||||
/// a refresh token. Null = legacy connection that predates scope tracking.
|
||||
scope String? @map("scope")
|
||||
lastSyncedAt DateTime? @map("last_synced_at")
|
||||
/// v1.16.16 (iOS #17) — per-resource sync cursors. A JSON map
|
||||
/// `{ "recovery": "<iso>", "sleep": "<iso>", "workout": "<iso>",
|
||||
/// "cycle": "<iso>" }` of each resource's own last-synced instant. The
|
||||
/// incremental `start` for a resource is `cursor − overlap`; a resource
|
||||
/// advances ONLY its own key on a successful fetch+upsert, so a slow or
|
||||
/// 403-ing collection never drags the cursor forward for its siblings and
|
||||
/// its own late-arriving records stay inside the window. Null / a missing
|
||||
/// key falls back to the shared `lastSyncedAt` (legacy connection).
|
||||
resourceCursors Json? @map("resource_cursors")
|
||||
/// Set once the self-converging history backfill walks every collection to
|
||||
/// completion. Null = backfill still in flight (or never started).
|
||||
backfillCompletedAt DateTime? @map("backfill_completed_at")
|
||||
|
||||
@@ -162,8 +162,11 @@ describe("POST /api/whoop/webhook/[token]", () => {
|
||||
where: { whoopUserId: "42" },
|
||||
select: { userId: true },
|
||||
});
|
||||
// v1.16.16 (iOS #17) — the resource id rides through so the worker can do
|
||||
// a targeted fetch-by-id refresh instead of re-walking the collection.
|
||||
expect(send).toHaveBeenCalledWith("whoop-recovery-sync", {
|
||||
userId: "user-1",
|
||||
resourceId: "abc",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* v1.16.16 (iOS #17) — `runWhoopResourceSync` dispatch tests (mocked syncs).
|
||||
*
|
||||
* The webhook now carries the resource id; the worker must do a targeted
|
||||
* fetch-by-id refresh when it is present, fall back to the per-user collection
|
||||
* walk when a webhook job omits the id, and walk every connection only on the
|
||||
* cron tick (no userId on any job). One user's failure never starves the
|
||||
* cohort.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Job } from "pg-boss";
|
||||
|
||||
const {
|
||||
syncUserWorkout,
|
||||
syncWhoopWorkoutById,
|
||||
findManyMock,
|
||||
} = vi.hoisted(() => ({
|
||||
syncUserWorkout: vi.fn<(...a: unknown[]) => Promise<number>>(async () => 0),
|
||||
syncWhoopWorkoutById: vi.fn<(...a: unknown[]) => Promise<number>>(
|
||||
async () => 0,
|
||||
),
|
||||
findManyMock: vi.fn<(...a: unknown[]) => Promise<Array<{ userId: string }>>>(
|
||||
async () => [],
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/whoop/sync-recovery", () => ({
|
||||
syncUserRecovery: vi.fn(async () => 0),
|
||||
syncWhoopRecoveryById: vi.fn(async () => 0),
|
||||
}));
|
||||
vi.mock("@/lib/whoop/sync-sleep", () => ({
|
||||
syncUserSleep: vi.fn(async () => 0),
|
||||
syncWhoopSleepById: vi.fn(async () => 0),
|
||||
}));
|
||||
vi.mock("@/lib/whoop/sync-cycle", () => ({
|
||||
syncUserCycle: vi.fn(async () => 0),
|
||||
}));
|
||||
vi.mock("@/lib/whoop/sync-workout", () => ({
|
||||
syncUserWorkout: (...a: unknown[]) => syncUserWorkout(...a),
|
||||
syncWhoopWorkoutById: (...a: unknown[]) => syncWhoopWorkoutById(...a),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({ recordError: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/logging/background", () => ({
|
||||
withBackgroundEvent: async (
|
||||
_name: string,
|
||||
fn: (evt: {
|
||||
setBackground: () => void;
|
||||
setError: () => void;
|
||||
addWarning: () => void;
|
||||
}) => Promise<void>,
|
||||
) =>
|
||||
fn({
|
||||
setBackground: vi.fn(),
|
||||
setError: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../reminder/shared", () => ({
|
||||
getWorkerPrisma: () => ({
|
||||
whoopConnection: { findMany: (...a: unknown[]) => findManyMock(...a) },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { handleWhoopWorkoutSync } from "../reminder/whoop-sync";
|
||||
|
||||
function job(data: { userId?: string; resourceId?: string }): Job<{
|
||||
userId?: string;
|
||||
resourceId?: string;
|
||||
}> {
|
||||
return { data } as Job<{ userId?: string; resourceId?: string }>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("runWhoopResourceSync dispatch", () => {
|
||||
it("a webhook job with a resourceId does a targeted fetch-by-id refresh", async () => {
|
||||
await handleWhoopWorkoutSync([job({ userId: "u1", resourceId: "w-9" })]);
|
||||
|
||||
expect(syncWhoopWorkoutById).toHaveBeenCalledWith("u1", "w-9");
|
||||
expect(syncUserWorkout).not.toHaveBeenCalled();
|
||||
expect(findManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a webhook job without a resourceId falls back to the per-user collection", async () => {
|
||||
await handleWhoopWorkoutSync([job({ userId: "u1" })]);
|
||||
|
||||
expect(syncUserWorkout).toHaveBeenCalledWith("u1");
|
||||
expect(syncWhoopWorkoutById).not.toHaveBeenCalled();
|
||||
expect(findManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a cron tick (no userId) walks every connection's collection", async () => {
|
||||
findManyMock.mockResolvedValue([{ userId: "a" }, { userId: "b" }]);
|
||||
|
||||
await handleWhoopWorkoutSync([job({})]);
|
||||
|
||||
expect(findManyMock).toHaveBeenCalled();
|
||||
expect(syncUserWorkout).toHaveBeenCalledTimes(2);
|
||||
expect(syncWhoopWorkoutById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("one user's failure never starves the rest of the cohort", async () => {
|
||||
findManyMock.mockResolvedValue([{ userId: "a" }, { userId: "b" }]);
|
||||
syncUserWorkout.mockRejectedValueOnce(new Error("boom for a"));
|
||||
|
||||
await expect(handleWhoopWorkoutSync([job({})])).resolves.toBeUndefined();
|
||||
// Both users were attempted despite the first throwing.
|
||||
expect(syncUserWorkout).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,19 @@
|
||||
import { type Job } from "pg-boss";
|
||||
import { recordError } from "@/lib/jobs/worker-status";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { syncUserRecovery } from "@/lib/whoop/sync-recovery";
|
||||
import { syncUserSleep as syncWhoopSleep } from "@/lib/whoop/sync-sleep";
|
||||
import {
|
||||
syncUserRecovery,
|
||||
syncWhoopRecoveryById,
|
||||
} from "@/lib/whoop/sync-recovery";
|
||||
import {
|
||||
syncUserSleep as syncWhoopSleep,
|
||||
syncWhoopSleepById,
|
||||
} from "@/lib/whoop/sync-sleep";
|
||||
import { syncUserCycle } from "@/lib/whoop/sync-cycle";
|
||||
import { syncUserWorkout } from "@/lib/whoop/sync-workout";
|
||||
import {
|
||||
syncUserWorkout,
|
||||
syncWhoopWorkoutById,
|
||||
} from "@/lib/whoop/sync-workout";
|
||||
import { getWorkerPrisma } from "./shared";
|
||||
|
||||
/**
|
||||
@@ -18,34 +27,59 @@ import { getWorkerPrisma } from "./shared";
|
||||
* WHOOP sync queue:
|
||||
*
|
||||
* 1. Webhook (`recovery.updated` / `sleep.updated` / `workout.updated`) —
|
||||
* payload carries `userId`, the handler syncs that one user.
|
||||
* payload carries `userId` and (v1.16.16) the `resourceId`; the handler
|
||||
* does a targeted fetch-by-id refresh of that single record.
|
||||
* 2. Cron — payload has no `userId`; the handler iterates every WHOOP
|
||||
* connection and re-syncs each, catching dropped webhook deliveries.
|
||||
* Cycle has no webhook, so its cron is the sole driver.
|
||||
* connection and re-syncs each collection, catching dropped webhook
|
||||
* deliveries. Cycle has no webhook, so its cron is the sole driver.
|
||||
*
|
||||
* A webhook job that carries a `userId` but no `resourceId` (a legacy in-flight
|
||||
* job, or a delivery that omitted the id) falls back to the per-user collection
|
||||
* walk rather than touching every connection.
|
||||
*/
|
||||
export interface WhoopSyncPayload {
|
||||
userId?: string;
|
||||
/** v1.16.16 — webhook resource id for a targeted fetch-by-id refresh. */
|
||||
resourceId?: string;
|
||||
}
|
||||
|
||||
/** A per-user collection walk (cron + the no-id webhook fallback). */
|
||||
type CollectionSync = (userId: string) => Promise<number>;
|
||||
/** A webhook-driven single-record refresh by resource id. */
|
||||
type ByIdSync = (userId: string, resourceId: string) => Promise<number>;
|
||||
|
||||
/**
|
||||
* Shared driver for the per-resource WHOOP sync handlers. Resolves the target
|
||||
* set (per-user from the webhook payload, or every connection on the cron
|
||||
* tick) and runs `syncFn` per user. One user's parked-at-reauth state never
|
||||
* starves the rest of the cohort on the cron path.
|
||||
* Shared driver for the per-resource WHOOP sync handlers.
|
||||
*
|
||||
* - A webhook job with a `resourceId` runs the targeted `byIdFn` (when the
|
||||
* resource supports fetch-by-id) — landing the exact record immediately.
|
||||
* - A webhook job with a `userId` but no `resourceId` runs the per-user
|
||||
* collection `syncFn`.
|
||||
* - A cron tick (no `userId` on any job) walks every connection's collection.
|
||||
*
|
||||
* One user's parked-at-reauth state never starves the rest of the cohort.
|
||||
*/
|
||||
export async function runWhoopResourceSync(
|
||||
taskName: string,
|
||||
jobs: Job<WhoopSyncPayload>[],
|
||||
syncFn: (userId: string) => Promise<number>,
|
||||
syncFn: CollectionSync,
|
||||
byIdFn?: ByIdSync,
|
||||
): Promise<void> {
|
||||
await withBackgroundEvent(taskName, async (evt) => {
|
||||
const prisma = getWorkerPrisma();
|
||||
try {
|
||||
const targets: Array<{ userId: string }> = [];
|
||||
// (userId, optional resourceId) work items from the webhook payloads.
|
||||
const targets: Array<{ userId: string; resourceId?: string }> = [];
|
||||
for (const job of jobs) {
|
||||
if (job.data?.userId) targets.push({ userId: job.data.userId });
|
||||
if (job.data?.userId) {
|
||||
targets.push({
|
||||
userId: job.data.userId,
|
||||
resourceId: job.data.resourceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (targets.length === 0) {
|
||||
// Cron tick — re-walk every connection's collection.
|
||||
const connections = await prisma.whoopConnection.findMany({
|
||||
select: { userId: true },
|
||||
});
|
||||
@@ -55,9 +89,12 @@ export async function runWhoopResourceSync(
|
||||
|
||||
let usersSynced = 0;
|
||||
let measurementsImported = 0;
|
||||
for (const { userId } of targets) {
|
||||
for (const { userId, resourceId } of targets) {
|
||||
try {
|
||||
measurementsImported += await syncFn(userId);
|
||||
measurementsImported +=
|
||||
resourceId && byIdFn
|
||||
? await byIdFn(userId, resourceId)
|
||||
: await syncFn(userId);
|
||||
usersSynced++;
|
||||
} catch (err) {
|
||||
evt.addWarning(`${taskName} failed for user ${userId}: ${err}`);
|
||||
@@ -85,17 +122,29 @@ export function handleWhoopRecoverySync(jobs: Job<WhoopSyncPayload>[]) {
|
||||
"job.whoop_recovery_sync",
|
||||
jobs,
|
||||
syncUserRecovery,
|
||||
syncWhoopRecoveryById,
|
||||
);
|
||||
}
|
||||
|
||||
export function handleWhoopSleepSync(jobs: Job<WhoopSyncPayload>[]) {
|
||||
return runWhoopResourceSync("job.whoop_sleep_sync", jobs, syncWhoopSleep);
|
||||
return runWhoopResourceSync(
|
||||
"job.whoop_sleep_sync",
|
||||
jobs,
|
||||
syncWhoopSleep,
|
||||
syncWhoopSleepById,
|
||||
);
|
||||
}
|
||||
|
||||
export function handleWhoopWorkoutSync(jobs: Job<WhoopSyncPayload>[]) {
|
||||
return runWhoopResourceSync("job.whoop_workout_sync", jobs, syncUserWorkout);
|
||||
return runWhoopResourceSync(
|
||||
"job.whoop_workout_sync",
|
||||
jobs,
|
||||
syncUserWorkout,
|
||||
syncWhoopWorkoutById,
|
||||
);
|
||||
}
|
||||
|
||||
export function handleWhoopCycleSync(jobs: Job<WhoopSyncPayload>[]) {
|
||||
// Cycle has no webhook (poll-only), so no fetch-by-id path.
|
||||
return runWhoopResourceSync("job.whoop_cycle_sync", jobs, syncUserCycle);
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
WHOOP_PAGE_LIMIT,
|
||||
exchangeCode,
|
||||
fetchBodyMeasurement,
|
||||
fetchRecoveryByCycleId,
|
||||
fetchSleepById,
|
||||
fetchWorkoutById,
|
||||
fetchCycleById,
|
||||
fetchCycles,
|
||||
fetchProfile,
|
||||
fetchRecoveries,
|
||||
@@ -198,6 +202,39 @@ describe("single-object endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch-by-id (webhook-driven single-record refresh)", () => {
|
||||
it("resolves one record at the documented v2 single-record path", async () => {
|
||||
const cases: Array<[() => Promise<unknown>, string]> = [
|
||||
[() => fetchSleepById("at", "s1"), "/v2/activity/sleep/s1"],
|
||||
[() => fetchWorkoutById("at", "w1"), "/v2/activity/workout/w1"],
|
||||
[() => fetchCycleById("at", "123"), "/v2/cycle/123"],
|
||||
[() => fetchRecoveryByCycleId("at", "123"), "/v2/cycle/123/recovery"],
|
||||
];
|
||||
for (const [call, path] of cases) {
|
||||
const fetchMock = installFetchMock([{ status: 200, body: { id: "x" } }]);
|
||||
await call();
|
||||
const [url] = fetchMock.mock.calls[0] as unknown as [string];
|
||||
expect(url).toContain(path);
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the single record body (one workout by id)", async () => {
|
||||
installFetchMock([
|
||||
{ status: 200, body: { id: "w1", start: "s", end: "e" } },
|
||||
]);
|
||||
const w = await fetchWorkoutById("at", "w1");
|
||||
expect(w.id).toBe("w1");
|
||||
});
|
||||
|
||||
it("url-encodes a resource id with reserved characters", async () => {
|
||||
const fetchMock = installFetchMock([{ status: 200, body: { id: "x" } }]);
|
||||
await fetchWorkoutById("at", "a/b?c");
|
||||
const [url] = fetchMock.mock.calls[0] as unknown as [string];
|
||||
expect(url).toContain("/v2/activity/workout/a%2Fb%3Fc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRecovery", () => {
|
||||
const base: WhoopRecovery = {
|
||||
cycle_id: 1,
|
||||
|
||||
@@ -9,30 +9,33 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchWorkouts = vi.fn();
|
||||
const fetchWorkoutById = vi.fn();
|
||||
|
||||
vi.mock("../client", () => ({
|
||||
fetchWorkouts: (...a: unknown[]) => fetchWorkouts(...a),
|
||||
fetchWorkoutById: (...a: unknown[]) => fetchWorkoutById(...a),
|
||||
KJ_TO_KCAL: 0.239006,
|
||||
}));
|
||||
|
||||
const getValidToken = vi.fn();
|
||||
const markSynced = vi.fn();
|
||||
const markResourceSynced = vi.fn();
|
||||
vi.mock("../sync", async () => {
|
||||
const actual = await vi.importActual<typeof import("../sync")>("../sync");
|
||||
return {
|
||||
...actual,
|
||||
getValidToken: (...a: unknown[]) => getValidToken(...a),
|
||||
markSynced: (...a: unknown[]) => markSynced(...a),
|
||||
markResourceSynced: (...a: unknown[]) => markResourceSynced(...a),
|
||||
};
|
||||
});
|
||||
|
||||
const whoopConnectionFindUnique = vi.fn();
|
||||
const workoutUpsert = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
whoopConnection: {
|
||||
findUnique: (...a: unknown[]) => whoopConnectionFindUnique(...a),
|
||||
},
|
||||
workout: { upsert: vi.fn() },
|
||||
workout: { upsert: (...a: unknown[]) => workoutUpsert(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -40,8 +43,11 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ addWarning: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { syncUserWorkout } from "../sync-workout";
|
||||
import { WHOOP_RECOVERY_SLEEP_OVERLAP_MS } from "../sync";
|
||||
import { syncUserWorkout, syncWhoopWorkoutById } from "../sync-workout";
|
||||
import {
|
||||
WHOOP_FULL_SYNC_ANCHOR,
|
||||
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
} from "../sync";
|
||||
|
||||
const LAST_SYNCED = new Date("2026-06-10T12:00:00.000Z");
|
||||
|
||||
@@ -51,8 +57,12 @@ beforeEach(() => {
|
||||
accessToken: "tok",
|
||||
connection: { id: "c1", whoopUserId: "w1" },
|
||||
});
|
||||
whoopConnectionFindUnique.mockResolvedValue({ lastSyncedAt: LAST_SYNCED });
|
||||
markSynced.mockResolvedValue(undefined);
|
||||
// No per-resource cursor yet → falls back to the shared lastSyncedAt.
|
||||
whoopConnectionFindUnique.mockResolvedValue({
|
||||
lastSyncedAt: LAST_SYNCED,
|
||||
resourceCursors: null,
|
||||
});
|
||||
markResourceSynced.mockResolvedValue(undefined);
|
||||
fetchWorkouts.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
@@ -70,9 +80,81 @@ describe("syncUserWorkout — late-synced workout overlap window", () => {
|
||||
expect(arg.start!.getTime()).toBeLessThanOrEqual(sixHoursBefore);
|
||||
});
|
||||
|
||||
it("fullSync passes no start (deep backfill anchor handles history)", async () => {
|
||||
it("fullSync anchors at the deep-history anchor, not the cursor", async () => {
|
||||
await syncUserWorkout("user-1", { fullSync: true });
|
||||
const arg = fetchWorkouts.mock.calls[0]![1] as { start?: Date };
|
||||
expect(arg.start).toBeUndefined();
|
||||
expect(arg.start).toEqual(WHOOP_FULL_SYNC_ANCHOR);
|
||||
});
|
||||
|
||||
it("uses the per-resource workout cursor when present, ignoring siblings", async () => {
|
||||
const workoutCursor = new Date("2026-06-04T12:00:00.000Z");
|
||||
whoopConnectionFindUnique.mockResolvedValue({
|
||||
// recovery synced far more recently — must NOT pull the workout window
|
||||
// forward.
|
||||
lastSyncedAt: new Date("2026-06-12T00:00:00.000Z"),
|
||||
resourceCursors: {
|
||||
recovery: "2026-06-12T00:00:00.000Z",
|
||||
workout: workoutCursor.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
await syncUserWorkout("user-1");
|
||||
|
||||
const arg = fetchWorkouts.mock.calls[0]![1] as { start?: Date };
|
||||
expect(arg.start!.getTime()).toBe(
|
||||
workoutCursor.getTime() - WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncWhoopWorkoutById — webhook fetch-by-id", () => {
|
||||
const SCORED_WORKOUT = {
|
||||
id: "w-123",
|
||||
start: "2026-06-14T07:00:00.000Z",
|
||||
end: "2026-06-14T08:00:00.000Z",
|
||||
sport_id: 1,
|
||||
score: {
|
||||
strain: 12.3,
|
||||
average_heart_rate: 130,
|
||||
max_heart_rate: 165,
|
||||
kilojoule: 1500,
|
||||
percent_recorded: 99,
|
||||
},
|
||||
};
|
||||
|
||||
it("resolves the ONE record by id and upserts it (no collection walk)", async () => {
|
||||
fetchWorkoutById.mockResolvedValue(SCORED_WORKOUT);
|
||||
workoutUpsert.mockResolvedValue({});
|
||||
|
||||
const imported = await syncWhoopWorkoutById("user-1", "w-123");
|
||||
|
||||
expect(imported).toBe(1);
|
||||
// Targeted fetch-by-id, never the collection.
|
||||
expect(fetchWorkoutById).toHaveBeenCalledWith("tok", "w-123");
|
||||
expect(fetchWorkouts).not.toHaveBeenCalled();
|
||||
const upsertArg = workoutUpsert.mock.calls[0]![0];
|
||||
expect(upsertArg.where.userId_source_externalId).toEqual({
|
||||
userId: "user-1",
|
||||
source: "WHOOP",
|
||||
externalId: "w-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("a single-id refresh does NOT advance the resource cursor", async () => {
|
||||
fetchWorkoutById.mockResolvedValue(SCORED_WORKOUT);
|
||||
workoutUpsert.mockResolvedValue({});
|
||||
|
||||
await syncWhoopWorkoutById("user-1", "w-123");
|
||||
|
||||
expect(markResourceSynced).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("an unscored record stores nothing", async () => {
|
||||
fetchWorkoutById.mockResolvedValue({ ...SCORED_WORKOUT, score: null });
|
||||
|
||||
const imported = await syncWhoopWorkoutById("user-1", "w-123");
|
||||
|
||||
expect(imported).toBe(0);
|
||||
expect(workoutUpsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -88,8 +88,11 @@ import {
|
||||
getValidToken,
|
||||
incrementalStart,
|
||||
isCollectionForbidden,
|
||||
markResourceSynced,
|
||||
resolveResourceCursor,
|
||||
upsertWhoopMeasurements,
|
||||
WHOOP_DEFAULT_OVERLAP_MS,
|
||||
WHOOP_FULL_SYNC_ANCHOR,
|
||||
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
} from "../sync";
|
||||
import { WhoopApiError } from "../response-classifier";
|
||||
@@ -172,8 +175,11 @@ describe("getValidToken — rotating refresh", () => {
|
||||
});
|
||||
|
||||
describe("incrementalStart", () => {
|
||||
it("returns undefined for a full sync", () => {
|
||||
expect(incrementalStart(new Date(), { fullSync: true })).toBeUndefined();
|
||||
it("anchors a full sync at the deep-history anchor, ignoring the cursor", () => {
|
||||
const got = incrementalStart(new Date("2026-06-01T12:00:00Z"), {
|
||||
fullSync: true,
|
||||
});
|
||||
expect(got).toEqual(WHOOP_FULL_SYNC_ANCHOR);
|
||||
});
|
||||
|
||||
it("subtracts the overlap from lastSyncedAt", () => {
|
||||
@@ -191,6 +197,108 @@ describe("incrementalStart", () => {
|
||||
const got = incrementalStart(last);
|
||||
expect(got!.getTime()).toBe(last.getTime() - WHOOP_DEFAULT_OVERLAP_MS);
|
||||
});
|
||||
|
||||
it("a fullSync reaches a span an incremental tick would skip", () => {
|
||||
// Incremental from a recent cursor only looks back the overlap (days); a
|
||||
// fullSync anchors years back, so it covers history the incremental misses.
|
||||
const recentCursor = new Date("2026-06-01T12:00:00Z");
|
||||
const incremental = incrementalStart(recentCursor, {
|
||||
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
});
|
||||
const full = incrementalStart(recentCursor, { fullSync: true });
|
||||
const yearOld = new Date("2025-01-01T00:00:00Z");
|
||||
// A year-old record is BEFORE the incremental start (skipped) but AFTER
|
||||
// the full-sync anchor (covered).
|
||||
expect(yearOld.getTime()).toBeLessThan(incremental!.getTime());
|
||||
expect(yearOld.getTime()).toBeGreaterThan(full!.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveResourceCursor — per-resource cursor", () => {
|
||||
it("prefers the per-resource cursor over the shared lastSyncedAt", () => {
|
||||
const got = resolveResourceCursor(
|
||||
{
|
||||
resourceCursors: { workout: "2026-06-10T00:00:00.000Z" },
|
||||
lastSyncedAt: new Date("2026-06-12T00:00:00.000Z"),
|
||||
},
|
||||
"workout",
|
||||
);
|
||||
expect(got?.toISOString()).toBe("2026-06-10T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("falls back to lastSyncedAt for a resource with no cursor key (legacy)", () => {
|
||||
const shared = new Date("2026-06-12T00:00:00.000Z");
|
||||
const got = resolveResourceCursor(
|
||||
{ resourceCursors: { recovery: "2026-06-10T00:00:00.000Z" }, lastSyncedAt: shared },
|
||||
"workout",
|
||||
);
|
||||
expect(got).toEqual(shared);
|
||||
});
|
||||
|
||||
it("falls back to lastSyncedAt when the column is null", () => {
|
||||
const shared = new Date("2026-06-12T00:00:00.000Z");
|
||||
expect(
|
||||
resolveResourceCursor({ resourceCursors: null, lastSyncedAt: shared }, "sleep"),
|
||||
).toEqual(shared);
|
||||
});
|
||||
|
||||
it("a stalled resource cursor does not advance with a sibling's", () => {
|
||||
// workout last synced a week ago; recovery synced an hour ago. Resolving
|
||||
// workout must still return the OLD workout cursor — a sibling's progress
|
||||
// never drags the stalled resource's window forward.
|
||||
const connection = {
|
||||
resourceCursors: {
|
||||
recovery: "2026-06-12T11:00:00.000Z",
|
||||
workout: "2026-06-05T12:00:00.000Z",
|
||||
},
|
||||
lastSyncedAt: new Date("2026-06-12T11:00:00.000Z"),
|
||||
};
|
||||
const workoutStart = incrementalStart(
|
||||
resolveResourceCursor(connection, "workout"),
|
||||
{ overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS },
|
||||
);
|
||||
// Workout still re-fetches from a week-old cursor minus the overlap, NOT
|
||||
// from recovery's recent one — the late-synced workout stays in range.
|
||||
expect(workoutStart!.getTime()).toBe(
|
||||
new Date("2026-06-05T12:00:00.000Z").getTime() -
|
||||
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markResourceSynced — independent cursor advance", () => {
|
||||
it("advances ONLY the named resource's key, preserving siblings", async () => {
|
||||
prismaMock.whoopConnection.findUnique.mockResolvedValue({
|
||||
resourceCursors: { recovery: "2026-06-01T00:00:00.000Z" },
|
||||
});
|
||||
prismaMock.whoopConnection.update.mockResolvedValue({});
|
||||
|
||||
const at = new Date("2026-06-14T08:00:00.000Z");
|
||||
await markResourceSynced("user1", "workout", at);
|
||||
|
||||
const data = prismaMock.whoopConnection.update.mock.calls[0]![0].data;
|
||||
// recovery's older cursor is preserved; workout is set to `at`.
|
||||
expect(data.resourceCursors).toEqual({
|
||||
recovery: "2026-06-01T00:00:00.000Z",
|
||||
workout: at.toISOString(),
|
||||
});
|
||||
// The shared cursor keeps moving for any legacy reader.
|
||||
expect(data.lastSyncedAt).toEqual(at);
|
||||
});
|
||||
|
||||
it("seeds the map from empty when the column is null", async () => {
|
||||
prismaMock.whoopConnection.findUnique.mockResolvedValue({
|
||||
resourceCursors: null,
|
||||
});
|
||||
prismaMock.whoopConnection.update.mockResolvedValue({});
|
||||
|
||||
const at = new Date("2026-06-14T08:00:00.000Z");
|
||||
await markResourceSynced("user1", "sleep", at);
|
||||
|
||||
expect(
|
||||
prismaMock.whoopConnection.update.mock.calls[0]![0].data.resourceCursors,
|
||||
).toEqual({ sleep: at.toISOString() });
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertWhoopMeasurements — idempotent upsert", () => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* v1.16.16 (iOS #17) — `processWhoopNotification` enqueue tests (mocked).
|
||||
*
|
||||
* A `*.updated` webhook must enqueue the per-resource sync job carrying the
|
||||
* resource id so the worker can do a targeted fetch-by-id refresh (landing the
|
||||
* exact record immediately rather than waiting for the next overlap window).
|
||||
* The existing 200-always + unknown-user + delete-soft-delete contract is
|
||||
* preserved.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { findFirst, bossSend, updateMany, deleteMany } = vi.hoisted(() => ({
|
||||
findFirst: vi.fn(),
|
||||
bossSend: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
whoopConnection: { findFirst: (...a: unknown[]) => findFirst(...a) },
|
||||
measurement: { updateMany: (...a: unknown[]) => updateMany(...a) },
|
||||
workout: { deleteMany: (...a: unknown[]) => deleteMany(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: () => ({ send: (...a: unknown[]) => bossSend(...a) }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({ getClientIp: () => "1.2.3.4" }));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: () => {},
|
||||
getEvent: () => ({ setAuth: vi.fn(), addWarning: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { processWhoopNotification } from "../webhook-handler";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findFirst.mockResolvedValue({ userId: "user-1" });
|
||||
bossSend.mockResolvedValue("job-id");
|
||||
});
|
||||
|
||||
describe("processWhoopNotification — fetch-by-id enqueue", () => {
|
||||
it("enqueues the workout queue with the resource id", async () => {
|
||||
const res = await processWhoopNotification({
|
||||
user_id: 42,
|
||||
id: "w-123",
|
||||
type: "workout.updated",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(bossSend).toHaveBeenCalledWith("whoop-workout-sync", {
|
||||
userId: "user-1",
|
||||
resourceId: "w-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("carries a numeric resource id (cycle-based recovery) as a string", async () => {
|
||||
await processWhoopNotification({
|
||||
user_id: 42,
|
||||
id: 9981,
|
||||
type: "recovery.updated",
|
||||
});
|
||||
|
||||
expect(bossSend).toHaveBeenCalledWith("whoop-recovery-sync", {
|
||||
userId: "user-1",
|
||||
resourceId: "9981",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 200 for an unknown user without enqueuing", async () => {
|
||||
findFirst.mockResolvedValue(null);
|
||||
|
||||
const res = await processWhoopNotification({
|
||||
user_id: 7,
|
||||
id: "w-1",
|
||||
type: "workout.updated",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("soft-deletes on a *.deleted event (no enqueue)", async () => {
|
||||
updateMany.mockResolvedValue({ count: 3 });
|
||||
deleteMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
const res = await processWhoopNotification({
|
||||
user_id: 42,
|
||||
id: "w-123",
|
||||
type: "workout.deleted",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
expect(deleteMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -402,6 +402,67 @@ export function fetchWorkouts(
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Single-record reads (webhook-driven fetch-by-id) ──────────
|
||||
// A `*.updated` webhook carries only the resource id, not the data. Re-fetch
|
||||
// that ONE record by id for a targeted refresh — far cheaper than re-walking
|
||||
// the whole collection, and it lands the record immediately rather than on the
|
||||
// next overlap window. WHOOP v2 single-record paths (re-verify at build):
|
||||
// sleep GET /v2/activity/sleep/{sleepId}
|
||||
// workout GET /v2/activity/workout/{workoutId}
|
||||
// cycle GET /v2/cycle/{cycleId}
|
||||
// recovery GET /v2/cycle/{cycleId}/recovery (no recovery-by-recovery-id)
|
||||
// `fetchSingle` throws a `WhoopApiError` on a non-2xx (incl. a 404 for a since-
|
||||
// deleted id) so the caller's existing classify/soft-skip path handles it.
|
||||
|
||||
export function fetchSleepById(
|
||||
accessToken: string,
|
||||
sleepId: string,
|
||||
): Promise<WhoopSleep> {
|
||||
return fetchSingle<WhoopSleep>(
|
||||
`/v2/activity/sleep/${encodeURIComponent(sleepId)}`,
|
||||
accessToken,
|
||||
"fetchSleepById",
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchWorkoutById(
|
||||
accessToken: string,
|
||||
workoutId: string,
|
||||
): Promise<WhoopWorkout> {
|
||||
return fetchSingle<WhoopWorkout>(
|
||||
`/v2/activity/workout/${encodeURIComponent(workoutId)}`,
|
||||
accessToken,
|
||||
"fetchWorkoutById",
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchCycleById(
|
||||
accessToken: string,
|
||||
cycleId: string,
|
||||
): Promise<WhoopCycle> {
|
||||
return fetchSingle<WhoopCycle>(
|
||||
`/v2/cycle/${encodeURIComponent(cycleId)}`,
|
||||
accessToken,
|
||||
"fetchCycleById",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHOOP v2 has no recovery-by-recovery-id route — a recovery is read through
|
||||
* its cycle (`GET /v2/cycle/{cycleId}/recovery`). The recovery webhook carries
|
||||
* the cycle id, so the targeted refresh resolves the recovery from there.
|
||||
*/
|
||||
export function fetchRecoveryByCycleId(
|
||||
accessToken: string,
|
||||
cycleId: string,
|
||||
): Promise<WhoopRecovery> {
|
||||
return fetchSingle<WhoopRecovery>(
|
||||
`/v2/cycle/${encodeURIComponent(cycleId)}/recovery`,
|
||||
accessToken,
|
||||
"fetchRecoveryByCycleId",
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchBodyMeasurement(
|
||||
accessToken: string,
|
||||
): Promise<WhoopBodyMeasurement> {
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
getValidToken,
|
||||
incrementalStart,
|
||||
handleCollectionFetchError,
|
||||
markSynced,
|
||||
markResourceSynced,
|
||||
resolveResourceCursor,
|
||||
upsertWhoopMeasurements,
|
||||
type WhoopMeasurementUpsert,
|
||||
} from "./sync";
|
||||
@@ -25,11 +26,11 @@ export async function syncUserCycle(
|
||||
|
||||
const connection = await prisma.whoopConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { lastSyncedAt: true },
|
||||
select: { lastSyncedAt: true, resourceCursors: true },
|
||||
});
|
||||
if (!connection) return 0;
|
||||
|
||||
const start = incrementalStart(connection.lastSyncedAt, {
|
||||
const start = incrementalStart(resolveResourceCursor(connection, "cycle"), {
|
||||
fullSync: opts.fullSync,
|
||||
});
|
||||
|
||||
@@ -54,6 +55,6 @@ export async function syncUserCycle(
|
||||
}
|
||||
|
||||
const imported = await upsertWhoopMeasurements(userId, readings);
|
||||
await markSynced(userId);
|
||||
await markResourceSynced(userId, "cycle");
|
||||
return imported;
|
||||
}
|
||||
@@ -9,12 +9,17 @@
|
||||
* sleep UUID (v2 recovery carries `sleep_id`, not a stable recovery id) so the
|
||||
* externalId is stable across re-scores.
|
||||
*/
|
||||
import { fetchRecoveries, mapRecovery } from "./client";
|
||||
import {
|
||||
fetchRecoveries,
|
||||
fetchRecoveryByCycleId,
|
||||
mapRecovery,
|
||||
} from "./client";
|
||||
import {
|
||||
getValidToken,
|
||||
incrementalStart,
|
||||
handleCollectionFetchError,
|
||||
markSynced,
|
||||
markResourceSynced,
|
||||
resolveResourceCursor,
|
||||
upsertWhoopMeasurements,
|
||||
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
type WhoopMeasurementUpsert,
|
||||
@@ -30,14 +35,17 @@ export async function syncUserRecovery(
|
||||
|
||||
const connection = await prisma.whoopConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { lastSyncedAt: true },
|
||||
select: { lastSyncedAt: true, resourceCursors: true },
|
||||
});
|
||||
if (!connection) return 0;
|
||||
|
||||
const start = incrementalStart(connection.lastSyncedAt, {
|
||||
fullSync: opts.fullSync,
|
||||
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
});
|
||||
const start = incrementalStart(
|
||||
resolveResourceCursor(connection, "recovery"),
|
||||
{
|
||||
fullSync: opts.fullSync,
|
||||
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
},
|
||||
);
|
||||
|
||||
let records: Awaited<ReturnType<typeof fetchRecoveries>>;
|
||||
try {
|
||||
@@ -62,6 +70,43 @@ export async function syncUserRecovery(
|
||||
}
|
||||
|
||||
const imported = await upsertWhoopMeasurements(userId, readings);
|
||||
await markSynced(userId);
|
||||
await markResourceSynced(userId, "recovery");
|
||||
return imported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook-driven targeted refresh: resolve ONE recovery by its cycle id and
|
||||
* upsert its readings, instead of re-walking the whole collection. WHOOP v2
|
||||
* reads a recovery through its cycle (`/v2/cycle/{cycleId}/recovery`), and the
|
||||
* recovery webhook carries the cycle id. An unscored / since-deleted record
|
||||
* yields nothing. Does NOT advance the resource cursor — a single-id refresh
|
||||
* proves nothing about records between the cursor and now, so the cron/overlap
|
||||
* path stays responsible for moving the cursor.
|
||||
*/
|
||||
export async function syncWhoopRecoveryById(
|
||||
userId: string,
|
||||
cycleId: string,
|
||||
): Promise<number> {
|
||||
const tokenInfo = await getValidToken(userId);
|
||||
if (!tokenInfo) return 0;
|
||||
|
||||
let record: Awaited<ReturnType<typeof fetchRecoveryByCycleId>>;
|
||||
try {
|
||||
record = await fetchRecoveryByCycleId(tokenInfo.accessToken, cycleId);
|
||||
} catch (err) {
|
||||
return handleCollectionFetchError("recovery", userId, err);
|
||||
}
|
||||
|
||||
const readings: WhoopMeasurementUpsert[] = [];
|
||||
for (const m of mapRecovery(record)) {
|
||||
readings.push({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
externalId: `${record.sleep_id}:${m.fieldTag}`,
|
||||
});
|
||||
}
|
||||
|
||||
return upsertWhoopMeasurements(userId, readings);
|
||||
}
|
||||
@@ -7,12 +7,13 @@
|
||||
* Per-stage rows carry the `sleepStage` axis so the five stage rows for one
|
||||
* night stay distinct under the dedup key. externalId = `<sleep_id>:<fieldTag>`.
|
||||
*/
|
||||
import { fetchSleeps, mapSleep } from "./client";
|
||||
import { fetchSleeps, fetchSleepById, mapSleep } from "./client";
|
||||
import {
|
||||
getValidToken,
|
||||
incrementalStart,
|
||||
handleCollectionFetchError,
|
||||
markSynced,
|
||||
markResourceSynced,
|
||||
resolveResourceCursor,
|
||||
upsertWhoopMeasurements,
|
||||
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
type WhoopMeasurementUpsert,
|
||||
@@ -28,11 +29,11 @@ export async function syncUserSleep(
|
||||
|
||||
const connection = await prisma.whoopConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { lastSyncedAt: true },
|
||||
select: { lastSyncedAt: true, resourceCursors: true },
|
||||
});
|
||||
if (!connection) return 0;
|
||||
|
||||
const start = incrementalStart(connection.lastSyncedAt, {
|
||||
const start = incrementalStart(resolveResourceCursor(connection, "sleep"), {
|
||||
fullSync: opts.fullSync,
|
||||
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
});
|
||||
@@ -59,6 +60,42 @@ export async function syncUserSleep(
|
||||
}
|
||||
|
||||
const imported = await upsertWhoopMeasurements(userId, readings);
|
||||
await markSynced(userId);
|
||||
await markResourceSynced(userId, "sleep");
|
||||
return imported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook-driven targeted refresh: resolve ONE sleep activity by its uuid and
|
||||
* upsert its readings, instead of re-walking the whole collection. An unscored
|
||||
* / since-deleted record yields nothing. Does NOT advance the resource cursor
|
||||
* (a single-id refresh proves nothing about the records between the cursor and
|
||||
* now).
|
||||
*/
|
||||
export async function syncWhoopSleepById(
|
||||
userId: string,
|
||||
sleepId: string,
|
||||
): Promise<number> {
|
||||
const tokenInfo = await getValidToken(userId);
|
||||
if (!tokenInfo) return 0;
|
||||
|
||||
let record: Awaited<ReturnType<typeof fetchSleepById>>;
|
||||
try {
|
||||
record = await fetchSleepById(tokenInfo.accessToken, sleepId);
|
||||
} catch (err) {
|
||||
return handleCollectionFetchError("sleep", userId, err);
|
||||
}
|
||||
|
||||
const readings: WhoopMeasurementUpsert[] = [];
|
||||
for (const m of mapSleep(record)) {
|
||||
readings.push({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
externalId: `${record.id}:${m.fieldTag}`,
|
||||
sleepStage: m.sleepStage ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return upsertWhoopMeasurements(userId, readings);
|
||||
}
|
||||
+113
-71
@@ -13,12 +13,18 @@
|
||||
* rows (different `source`); the read-time `pickCanonicalWorkoutRows` picker
|
||||
* (E-slice, W6) collapses the cross-source twin at read time.
|
||||
*/
|
||||
import { fetchWorkouts, KJ_TO_KCAL } from "./client";
|
||||
import {
|
||||
fetchWorkouts,
|
||||
fetchWorkoutById,
|
||||
KJ_TO_KCAL,
|
||||
type WhoopWorkout,
|
||||
} from "./client";
|
||||
import {
|
||||
getValidToken,
|
||||
incrementalStart,
|
||||
handleCollectionFetchError,
|
||||
markSynced,
|
||||
markResourceSynced,
|
||||
resolveResourceCursor,
|
||||
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
} from "./sync";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -41,7 +47,7 @@ export async function syncUserWorkout(
|
||||
|
||||
const connection = await prisma.whoopConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { lastSyncedAt: true },
|
||||
select: { lastSyncedAt: true, resourceCursors: true },
|
||||
});
|
||||
if (!connection) return 0;
|
||||
|
||||
@@ -53,10 +59,13 @@ export async function syncUserWorkout(
|
||||
// the same window recovery/sleep already use for their late re-scores. The
|
||||
// `(userId, WHOOP, externalId)` upsert keeps the re-fetch idempotent and a
|
||||
// handful of workouts/day keeps the page count down.
|
||||
const start = incrementalStart(connection.lastSyncedAt, {
|
||||
fullSync: opts.fullSync,
|
||||
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
});
|
||||
const start = incrementalStart(
|
||||
resolveResourceCursor(connection, "workout"),
|
||||
{
|
||||
fullSync: opts.fullSync,
|
||||
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
},
|
||||
);
|
||||
|
||||
let records: Awaited<ReturnType<typeof fetchWorkouts>>;
|
||||
try {
|
||||
@@ -67,75 +76,108 @@ export async function syncUserWorkout(
|
||||
|
||||
let imported = 0;
|
||||
for (const w of records) {
|
||||
if (!w.score) continue; // unscored workout — nothing to store yet
|
||||
const startedAt = new Date(w.start);
|
||||
const endedAt = new Date(w.end);
|
||||
const durationSec = Math.max(
|
||||
0,
|
||||
Math.round((endedAt.getTime() - startedAt.getTime()) / 1000),
|
||||
);
|
||||
const energyKcal =
|
||||
typeof w.score.kilojoule === "number"
|
||||
? Math.round(w.score.kilojoule * KJ_TO_KCAL)
|
||||
: null;
|
||||
imported += await upsertWhoopWorkout(userId, w);
|
||||
}
|
||||
|
||||
const metadata: Prisma.InputJsonValue = {
|
||||
whoopWorkoutStrain: w.score.strain,
|
||||
percentRecorded: w.score.percent_recorded,
|
||||
...(w.score.altitude_gain_meter != null
|
||||
? { altitudeGainMeter: w.score.altitude_gain_meter }
|
||||
: {}),
|
||||
...(w.score.altitude_change_meter != null
|
||||
? { altitudeChangeMeter: w.score.altitude_change_meter }
|
||||
: {}),
|
||||
...(w.score.zone_durations
|
||||
? { zoneDurations: w.score.zone_durations }
|
||||
: {}),
|
||||
};
|
||||
await markResourceSynced(userId, "workout");
|
||||
return imported;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.workout.upsert({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId,
|
||||
source: "WHOOP",
|
||||
externalId: w.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
/**
|
||||
* Webhook-driven targeted refresh: resolve ONE workout by its uuid and upsert
|
||||
* it, instead of re-walking the whole collection. This is the direct fix for
|
||||
* iOS #17 — a `workout.updated` webhook lands the exact workout immediately,
|
||||
* with no dependence on the overlap window catching it. An unscored /
|
||||
* since-deleted record yields nothing. Does NOT advance the resource cursor.
|
||||
*/
|
||||
export async function syncWhoopWorkoutById(
|
||||
userId: string,
|
||||
workoutId: string,
|
||||
): Promise<number> {
|
||||
const tokenInfo = await getValidToken(userId);
|
||||
if (!tokenInfo) return 0;
|
||||
|
||||
let record: WhoopWorkout;
|
||||
try {
|
||||
record = await fetchWorkoutById(tokenInfo.accessToken, workoutId);
|
||||
} catch (err) {
|
||||
return handleCollectionFetchError("workout", userId, err);
|
||||
}
|
||||
|
||||
return upsertWhoopWorkout(userId, record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert one WHOOP workout into the `Workout` table keyed on
|
||||
* `(userId, source = WHOOP, externalId)`. Returns 1 when a row was written, 0
|
||||
* for an unscored record (nothing to store yet) or an upsert failure (logged,
|
||||
* never thrown — a single bad record must not fail the surrounding sync).
|
||||
* Shared by the collection walk and the webhook-driven fetch-by-id refresh so
|
||||
* both write an identical row shape.
|
||||
*/
|
||||
export async function upsertWhoopWorkout(
|
||||
userId: string,
|
||||
w: WhoopWorkout,
|
||||
): Promise<number> {
|
||||
if (!w.score) return 0; // unscored workout — nothing to store yet
|
||||
const startedAt = new Date(w.start);
|
||||
const endedAt = new Date(w.end);
|
||||
const durationSec = Math.max(
|
||||
0,
|
||||
Math.round((endedAt.getTime() - startedAt.getTime()) / 1000),
|
||||
);
|
||||
const energyKcal =
|
||||
typeof w.score.kilojoule === "number"
|
||||
? Math.round(w.score.kilojoule * KJ_TO_KCAL)
|
||||
: null;
|
||||
|
||||
const metadata: Prisma.InputJsonValue = {
|
||||
whoopWorkoutStrain: w.score.strain,
|
||||
percentRecorded: w.score.percent_recorded,
|
||||
...(w.score.altitude_gain_meter != null
|
||||
? { altitudeGainMeter: w.score.altitude_gain_meter }
|
||||
: {}),
|
||||
...(w.score.altitude_change_meter != null
|
||||
? { altitudeChangeMeter: w.score.altitude_change_meter }
|
||||
: {}),
|
||||
...(w.score.zone_durations
|
||||
? { zoneDurations: w.score.zone_durations }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const row = {
|
||||
sportType: sportLabel(w.sport_id, w.sport_name),
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationSec,
|
||||
totalEnergyKcal: energyKcal,
|
||||
totalDistanceM: w.score.distance_meter ?? null,
|
||||
avgHeartRate: w.score.average_heart_rate ?? null,
|
||||
maxHeartRate: w.score.max_heart_rate ?? null,
|
||||
elevationM: w.score.altitude_gain_meter ?? null,
|
||||
metadata,
|
||||
};
|
||||
|
||||
try {
|
||||
await prisma.workout.upsert({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId,
|
||||
source: "WHOOP",
|
||||
externalId: w.id,
|
||||
sportType: sportLabel(w.sport_id, w.sport_name),
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationSec,
|
||||
totalEnergyKcal: energyKcal,
|
||||
totalDistanceM: w.score.distance_meter ?? null,
|
||||
avgHeartRate: w.score.average_heart_rate ?? null,
|
||||
maxHeartRate: w.score.max_heart_rate ?? null,
|
||||
elevationM: w.score.altitude_gain_meter ?? null,
|
||||
metadata,
|
||||
},
|
||||
update: {
|
||||
sportType: sportLabel(w.sport_id, w.sport_name),
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationSec,
|
||||
totalEnergyKcal: energyKcal,
|
||||
totalDistanceM: w.score.distance_meter ?? null,
|
||||
avgHeartRate: w.score.average_heart_rate ?? null,
|
||||
maxHeartRate: w.score.max_heart_rate ?? null,
|
||||
elevationM: w.score.altitude_gain_meter ?? null,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
imported++;
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`WHOOP: failed to upsert workout: ${err}`);
|
||||
}
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
source: "WHOOP",
|
||||
externalId: w.id,
|
||||
...row,
|
||||
},
|
||||
update: row,
|
||||
});
|
||||
return 1;
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`WHOOP: failed to upsert workout: ${err}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
await markSynced(userId);
|
||||
return imported;
|
||||
}
|
||||
+104
-8
@@ -24,7 +24,7 @@
|
||||
*/
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -117,6 +117,61 @@ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
||||
export const WHOOP_RECOVERY_SLEEP_OVERLAP_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const WHOOP_DEFAULT_OVERLAP_MS = 60 * 60 * 1000; // 1 h
|
||||
|
||||
/**
|
||||
* The far-past anchor a `fullSync` walks from. Incremental ticks start at
|
||||
* `cursor − overlap` (days at most); a fullSync deliberately ignores the
|
||||
* cursor and re-walks the deep history WHOOP retains (multi-year), which an
|
||||
* incremental run would never reach. WHOOP's first public data predates this,
|
||||
* so a fixed anchor covers every realistic account; the client's `next_token`
|
||||
* pagination + the idempotent upsert keep the re-walk bounded and safe.
|
||||
*/
|
||||
export const WHOOP_FULL_SYNC_ANCHOR = new Date("2020-01-01T00:00:00.000Z");
|
||||
|
||||
/**
|
||||
* The four pollable WHOOP collections that carry an independent sync cursor.
|
||||
* Body-measurement is a single profile object (no time-range cursor) and is
|
||||
* excluded. Each resource advances ONLY its own cursor key so a slow/failing
|
||||
* collection never drags the incremental window forward for its siblings.
|
||||
*/
|
||||
export type WhoopResource = "recovery" | "sleep" | "workout" | "cycle";
|
||||
|
||||
/**
|
||||
* Shape of the `WhoopConnection.resourceCursors` JSON map: a partial
|
||||
* `resource → ISO-8601 last-synced instant`. A missing key (or a legacy null
|
||||
* column) means the resource has no per-resource cursor yet and falls back to
|
||||
* the shared `lastSyncedAt`.
|
||||
*/
|
||||
type ResourceCursorMap = Partial<Record<WhoopResource, string>>;
|
||||
|
||||
function parseResourceCursors(raw: unknown): ResourceCursorMap {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
return raw as ResourceCursorMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the incremental cursor for one resource. Prefers the per-resource
|
||||
* cursor; falls back to the connection's shared `lastSyncedAt` for a legacy
|
||||
* connection (or a resource that has never synced under the new column) so no
|
||||
* historical state is lost on the first tick after the migration. Returns null
|
||||
* when neither exists (the very first sync — `incrementalStart` then anchors 30
|
||||
* days back).
|
||||
*/
|
||||
export function resolveResourceCursor(
|
||||
connection: {
|
||||
resourceCursors?: unknown;
|
||||
lastSyncedAt?: Date | null;
|
||||
},
|
||||
resource: WhoopResource,
|
||||
): Date | null {
|
||||
const map = parseResourceCursors(connection.resourceCursors);
|
||||
const iso = map[resource];
|
||||
if (iso) {
|
||||
const parsed = new Date(iso);
|
||||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||||
}
|
||||
return connection.lastSyncedAt ?? null;
|
||||
}
|
||||
|
||||
export interface WhoopTokenInfo {
|
||||
accessToken: string;
|
||||
connection: { id: string; whoopUserId: string };
|
||||
@@ -200,18 +255,24 @@ export async function getValidToken(
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the incremental `start` for a resource sync. `fullSync` returns
|
||||
* undefined (the backfill anchor handles the deep history). Otherwise start
|
||||
* from `lastSyncedAt - overlap`, or 30 days back on the very first incremental
|
||||
* tick.
|
||||
* Compute the `start` bound for a resource fetch.
|
||||
*
|
||||
* A `fullSync` deliberately ignores the cursor and anchors at
|
||||
* `WHOOP_FULL_SYNC_ANCHOR` — the deep historical backfill an incremental run
|
||||
* would never reach. (It previously returned `undefined`; an explicit anchor
|
||||
* pins the lower bound so the walk is reproducible and the cursor a stalled
|
||||
* incremental left behind cannot silently narrow a backfill.)
|
||||
*
|
||||
* Otherwise it is an incremental tick: start from `cursor − overlap`, or 30
|
||||
* days back on the very first sync (no cursor yet).
|
||||
*/
|
||||
export function incrementalStart(
|
||||
lastSyncedAt: Date | null,
|
||||
cursor: Date | null,
|
||||
opts: { fullSync?: boolean; overlapMs?: number } = {},
|
||||
): Date | undefined {
|
||||
if (opts.fullSync) return undefined;
|
||||
if (opts.fullSync) return WHOOP_FULL_SYNC_ANCHOR;
|
||||
const overlap = opts.overlapMs ?? WHOOP_DEFAULT_OVERLAP_MS;
|
||||
if (lastSyncedAt) return new Date(lastSyncedAt.getTime() - overlap);
|
||||
if (cursor) return new Date(cursor.getTime() - overlap);
|
||||
return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
@@ -316,6 +377,41 @@ export async function markSynced(userId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance ONE resource's cursor after its successful fetch+upsert. Reads the
|
||||
* current `resourceCursors` map, sets only this resource's key to `now`, and
|
||||
* writes the merged map back — sibling cursors are preserved untouched, so a
|
||||
* resource that errored or soft-skipped this tick keeps its own (older) cursor
|
||||
* and re-fetches from there next time. Also keeps the shared `lastSyncedAt`
|
||||
* moving for any legacy reader still on it.
|
||||
*
|
||||
* Best-effort merge: the read+write is not transactional, but each resource
|
||||
* runs serially within a user's sync (the per-resource pg-boss queues are
|
||||
* `localConcurrency: 1`), so two cursors for the same user never race.
|
||||
*/
|
||||
export async function markResourceSynced(
|
||||
userId: string,
|
||||
resource: WhoopResource,
|
||||
at: Date = new Date(),
|
||||
): Promise<void> {
|
||||
const connection = await prisma.whoopConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { resourceCursors: true },
|
||||
});
|
||||
if (!connection) return;
|
||||
|
||||
const map = parseResourceCursors(connection.resourceCursors);
|
||||
const next: ResourceCursorMap = { ...map, [resource]: at.toISOString() };
|
||||
|
||||
await prisma.whoopConnection.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
resourceCursors: next as Prisma.InputJsonValue,
|
||||
lastSyncedAt: at,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Full per-user sync across every WHOOP resource. Webhook-driven syncs enqueue
|
||||
* a single per-resource job; this drives the hourly poll catch-all and the
|
||||
|
||||
@@ -203,13 +203,18 @@ export async function processWhoopNotification(
|
||||
}
|
||||
|
||||
// `*.updated` (creates arrive as updates too). Enqueue the per-user
|
||||
// resource sync; the worker re-fetches by id (payload carries no data).
|
||||
// resource sync. The payload carries the resource id so the worker can do a
|
||||
// targeted fetch-by-id refresh — landing the exact record immediately rather
|
||||
// than waiting for the next overlap window to re-walk the collection.
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
getEvent()?.addWarning("whoop-webhook: pg-boss not initialised");
|
||||
return NextResponse.json({ status: "ok" }, { status: 200 });
|
||||
}
|
||||
await boss.send(queue, { userId: connection.userId });
|
||||
await boss.send(queue, {
|
||||
userId: connection.userId,
|
||||
resourceId: resourceId ?? undefined,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "whoop.webhook.enqueued" },
|
||||
|
||||
Reference in new issue
Block a user