diff --git a/src/lib/withings/__tests__/sync-plan.test.ts b/src/lib/withings/__tests__/sync-plan.test.ts index b94b29b54..862fd06cf 100644 --- a/src/lib/withings/__tests__/sync-plan.test.ts +++ b/src/lib/withings/__tests__/sync-plan.test.ts @@ -105,6 +105,7 @@ import { import { releasePlanGatedWithingsParks, requestWithinPlan, + shouldPersistPlanLeg, WITHINGS_ADVANCED_MEASTYPES, WITHINGS_PLAN_MAX_REQUESTS, } from "../api-plan"; @@ -394,19 +395,128 @@ describe("syncUserMeasurements inside the Withings API plan", () => { expect(requestedStartdates.every((s) => s === expected)).toBe(true); }); - it("holds the watermark and records no failure when the plan refuses everything", async () => { + it("treats a run where every request was refused as transient: nothing remembered, watermark held", async () => { refused = new Set([ 1, 5, 6, 8, 9, 10, 11, 12, 35, 54, 71, 76, 77, 88, 91, 123, 155, 170, 73, ]); const result = await syncUserMeasurements("user-1"); - expect(result).toEqual({ imported: 0, failed: false }); + expect(result).toEqual({ imported: 0, failed: true }); expect(prisma.withingsConnection.update).not.toHaveBeenCalled(); + // A 277 stays off the ledger, and a run that read nothing is no success. expect(recordSyncFailure).not.toHaveBeenCalled(); + expect(recordSyncSuccess).not.toHaveBeenCalled(); expect(requestedTypes.length).toBeLessThanOrEqual( WITHINGS_PLAN_MAX_REQUESTS, ); + // Weight and blood pressure are not remembered as refused. + expect(persistedLegs()).toEqual([]); + + // The fault clears; the next run asks for everything again and reads the + // whole gap since the last good sync. + refused = new Set(WITHINGS_ADVANCED_MEASTYPES); + requestedTypes.length = 0; + requestedStartdates.length = 0; + const next = await syncUserMeasurements("user-1"); + expect(next.failed).toBe(false); + expect(requestedTypes[0]).toEqual(expect.arrayContaining([1, 9, 10, 11])); + expect(createdTypes()).toEqual( + expect.arrayContaining(["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"]), + ); + const fromLastGood = String( + Math.floor( + (LAST_GOOD_SYNC.getTime() - WITHINGS_INCREMENTAL_OVERLAP_MS) / 1000, + ), + ); + expect(requestedStartdates.every((s) => s === fromLastGood)).toBe(true); + }); + + it("holds the watermark when plan memory leaves nothing to request", async () => { + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue( + connection({ + version: 1, + measures: { + unavailable: [...WITHINGS_MEASTYPES], + checkedAt: new Date().toISOString(), + }, + }) as never, + ); + + const result = await syncUserMeasurements("user-1"); + + expect(requestedTypes).toHaveLength(0); + expect(result).toEqual({ imported: 0, failed: true }); + expect(prisma.withingsConnection.update).not.toHaveBeenCalled(); + expect(recordSyncSuccess).not.toHaveBeenCalled(); + }); + + it("holds the watermark when a core type was skipped from plan memory", async () => { + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue( + connection({ + version: 1, + measures: { + unavailable: [1, ...WITHINGS_ADVANCED_MEASTYPES], + checkedAt: new Date().toISOString(), + }, + }) as never, + ); + + const result = await syncUserMeasurements("user-1"); + + expect(requestedTypes).toHaveLength(1); + expect(requestedTypes[0]).not.toContain(1); + expect(result.failed).toBe(false); + expect(createdTypes()).toContain("BLOOD_PRESSURE_SYS"); + // Weight was not read in this window, so the window stays open for it. + expect(prisma.withingsConnection.update).not.toHaveBeenCalled(); + }); + + it("still moves the watermark when only advanced types are remembered", async () => { + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue( + connection({ + version: 1, + measures: { + unavailable: [...WITHINGS_ADVANCED_MEASTYPES], + checkedAt: new Date().toISOString(), + }, + }) as never, + ); + + await syncUserMeasurements("user-1"); + + expect(prisma.withingsConnection.update).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + data: { lastSyncedAt: expect.any(Date) }, + }); + }); +}); + +describe("shouldPersistPlanLeg", () => { + it("remembers a refusal only from a run where something else was accepted", () => { + expect( + shouldPersistPlanLeg({ gated: true, reprobed: false, acceptedAny: true }), + ).toBe(true); + expect( + shouldPersistPlanLeg({ + gated: true, + reprobed: false, + acceptedAny: false, + }), + ).toBe(false); + expect( + shouldPersistPlanLeg({ gated: true, reprobed: true, acceptedAny: false }), + ).toBe(false); + expect( + shouldPersistPlanLeg({ gated: false, reprobed: true, acceptedAny: true }), + ).toBe(true); + expect( + shouldPersistPlanLeg({ + gated: false, + reprobed: false, + acceptedAny: true, + }), + ).toBe(false); }); }); diff --git a/src/lib/withings/api-plan.ts b/src/lib/withings/api-plan.ts index bbb3cd7ac..c056eb072 100644 --- a/src/lib/withings/api-plan.ts +++ b/src/lib/withings/api-plan.ts @@ -58,6 +58,18 @@ export const WITHINGS_ADVANCED_MEASTYPES: readonly number[] = [ 91, 155, 170, 123, 73, ]; +/** + * The meastypes Withings lists as free in every plan and that carry the core + * of the measure leg: weight (1), blood pressure (9, 10), pulse (11) and the + * body-composition readings the table lists as free (fat-free mass 5, fat + * mass 8, muscle mass 76, hydration 77, bone mass 88). A run that leaves one + * of them out on the strength of plan memory has not read the window for it, + * so the measure watermark must not move past it. + */ +export const WITHINGS_CORE_MEASTYPES: readonly number[] = [ + 1, 5, 8, 9, 10, 11, 76, 77, 88, +]; + /** * Sleep-summary fields HealthLog requests that the table marks advanced: the * sleep score, the overnight HRV and the respiration rate. The nightly average @@ -323,15 +335,23 @@ export async function persistWithingsPlanLeg( } /** - * Decide whether a run's outcome is worth writing down. A run that met a 277 - * always is; so is a re-probe (the full set was asked for), whatever it found; - * a run that skipped remembered keys and was accepted changes nothing. + * Decide whether a run's outcome is worth writing down. + * + * A run that met a 277 is, but only when some other request in the same run + * was accepted: a plan never refuses everything, so a run where every request + * was refused (a transient fault upstream, a misconfigured developer app) + * proves nothing about any single key, and remembering it would skip weight + * and blood pressure for a week. A re-probe that met no 277 is written down + * too, so a cleared refusal is forgotten. A run that skipped remembered keys + * and was accepted changes nothing. */ export function shouldPersistPlanLeg(opts: { gated: boolean; reprobed: boolean; + acceptedAny: boolean; }): boolean { - return opts.gated || opts.reprobed; + if (opts.gated) return opts.acceptedAny; + return opts.reprobed; } // ── lifting parks an earlier build left ────────────────────────────────────── diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 0f7609221..a7817cd86 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -566,7 +566,13 @@ export async function syncUserSleep( ), }); summaries = plan.results; - if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) { + if ( + shouldPersistPlanLeg({ + gated: plan.gated, + reprobed, + acceptedAny: plan.acceptedAny, + }) + ) { await persistWithingsPlanLeg(userId, "sleepSummary", { unavailable: [...skip, ...plan.unavailable], checkedAt: now.toISOString(), diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index cf9eadff4..e15d8d83e 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -22,6 +22,7 @@ import { requestWithinPlan, shouldPersistPlanLeg, WITHINGS_ADVANCED_MEASTYPES, + WITHINGS_CORE_MEASTYPES, WITHINGS_PLAN_GATED_STATUS, type WithinPlanResult, } from "./api-plan"; @@ -273,7 +274,13 @@ export async function syncUserMeasurements( await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_MEASURES); throw err; } - if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) { + if ( + shouldPersistPlanLeg({ + gated: plan.gated, + reprobed, + acceptedAny: plan.acceptedAny, + }) + ) { try { await persistWithingsPlanLeg(userId, "measures", { unavailable: [...skip, ...plan.unavailable].sort((a, b) => a - b), @@ -297,15 +304,26 @@ export async function syncUserMeasurements( }, }); } - if (plan.gated && !plan.acceptedAny) { - // The plan refused every type asked for. Nothing was read, so the - // watermark stays where it is (a later plan change re-reads the gap), and - // nothing failed: the connection is healthy, the plan is the limit. The - // settings card names what the plan withholds. - await recordSyncSuccess(userId, "withings", { - leg: WITHINGS_LEG_MEASURES, + if (!plan.acceptedAny) { + // Nothing was read: either plan memory left nothing to ask for, or every + // request was refused. Every plan carries weight and blood pressure, so a + // run where all of it was refused is a fault upstream, not a plan answer. + // Nothing is remembered (above), the watermark stays where it is so the + // next run reads the whole gap, and the run reports that it did not land. + // It stays off the failure ledger like every other 277: a plan answer must + // never climb the strike ladder or park the connection. + getEvent()?.addWarning( + requested.length === 0 + ? "withings: plan memory left no measure type to request" + : "withings: every measure request was refused with 277", + ); + annotate({ + action: { + name: "withings.sync.plan_nothing_read", + details: { requested: requested.length, requests: plan.requests }, + }, }); - return { imported: 0, failed: false }; + return { imported: 0, failed: true }; } const fetched = plan.results; @@ -491,12 +509,15 @@ export async function syncUserMeasurements( // the watermark past them would drop their readings in this window for // good, so it stays put until a run reads (or proves refused) every type. // Re-reading the accepted types is idempotent. - const holdWatermark = plan.unverified.length > 0; + // The same holds for a core type left out on the strength of plan memory: + // that memory may be wrong, and the re-probe must find the window intact. + const skippedCore = skip.filter((t) => WITHINGS_CORE_MEASTYPES.includes(t)); + const holdWatermark = plan.unverified.length > 0 || skippedCore.length > 0; if (holdWatermark) { annotate({ action: { name: "withings.sync.plan_watermark_held", - details: { unverified: plan.unverified }, + details: { unverified: plan.unverified, skippedCore }, }, }); } else {