Merge branch 'v1423/review' into release/v1.42.3

This commit is contained in:
Marc-André Bombeck committed 2026-10-14 19:57:04 +02:00
commit 784ea4ca9d
10 files changed
+593 -70

No files matched your search

+24 -1
View File
@@ -192,6 +192,9 @@ export const PUT = apiHandler(
const data: Record<string, unknown> = {};
if (d.panel !== undefined) data.panel = d.panel;
if (d.analyte !== undefined) data.analyte = d.analyte;
// Set when a changed value retires a conversion whose printed range text is
// in the report's unit (see below).
let dropPrintedText = false;
if (d.value !== undefined) {
data.value = d.value;
// A number the person typed is no longer the conversion of what the
@@ -205,6 +208,18 @@ export const PUT = apiHandler(
) {
data.sourceValue = null;
data.sourceUnit = null;
// The printed range text belonged to the printed value and is written
// in the report's unit. With `sourceUnit` gone nothing would say so,
// and every reader would present it as the marker unit's window, so
// it goes with the record. The bounds stay: they were converted with
// the value and are already in the marker's unit.
if (
existing.sourceUnit !== null &&
existing.sourceReferenceText !== null
) {
data.sourceReferenceText = null;
dropPrintedText = true;
}
}
}
// v1.18.9 — qualitative edit. A row stays single-typed: editing `value` on a
@@ -220,7 +235,15 @@ export const PUT = apiHandler(
if (d.sourceReferenceHigh !== undefined) {
data.sourceReferenceHigh = d.sourceReferenceHigh;
}
if (d.sourceReferenceText !== undefined) {
// A whole-form echo of the old printed text does not bring it back once the
// value change retired it; a text that differs is a new statement and wins.
if (
d.sourceReferenceText !== undefined &&
!(
dropPrintedText &&
d.sourceReferenceText === existing.sourceReferenceText
)
) {
data.sourceReferenceText = d.sourceReferenceText;
}
if (d.takenAt !== undefined) data.takenAt = d.takenAt;
@@ -12,6 +12,18 @@ describe("valueEditSeed", () => {
expect(valueEditSeed(110)).toBe("110");
expect(valueEditSeed(4.567)).toBe("4.57");
});
it("never opens a small nonzero value as zero", () => {
expect(valueEditSeed(0.004)).toBe("0.004");
expect(valueEditSeed(0.0123456)).toBe("0.0123");
expect(valueEditSeed(0.87)).toBe("0.87");
expect(valueEditSeed(0.123456)).toBe("0.123");
expect(valueEditSeed(-0.004)).toBe("-0.004");
expect(valueEditSeed(0.0000004)).toBe("0.0000004");
for (const v of [0.004, 0.0004, 0.00004, 1e-9]) {
expect(Number(valueEditSeed(v))).not.toBe(0);
}
});
});
describe("resolveValueForUpdate", () => {
+21 -6
View File
@@ -1,18 +1,33 @@
import { formatLabValue } from "./format-value";
/**
* The numeric value of a lab reading in the edit sheet.
*
* A converted reading is stored at full precision (5.6 mmol/L of glucose is
* 100.90191999999999 mg/dL), and the sheet must not open with that tail. The
* field starts from the value as every lab surface shows it: whole numbers
* bare, at most two decimals. Written with a dot, which the sheet's parser
* reads, rather than in the reader's locale, which an input cannot round-trip.
* field starts from the value at the precision every lab surface shows it:
* whole numbers bare, two decimals. Two decimals carry too little of a small
* value, though (a TSH of 0.004 would open as "0"), so below 1 the field keeps
* three significant figures instead. Written with a dot, which the sheet's
* parser reads, rather than in the reader's locale, which an input cannot
* round-trip, and never in exponent notation.
*/
export function valueEditSeed(value: number): string {
return formatLabValue(value, String);
if (Number.isInteger(value)) return String(value);
const magnitude = Math.floor(Math.log10(Math.abs(value)));
const decimals = Math.min(
MAX_SEED_DECIMALS,
Math.max(DISPLAY_DECIMALS, SEED_SIGNIFICANT_FIGURES - 1 - magnitude),
);
return value
.toFixed(decimals)
.replace(/(\.\d*?)0+$/, "$1")
.replace(/\.$/, "");
}
const DISPLAY_DECIMALS = 2;
const SEED_SIGNIFICANT_FIGURES = 3;
/** `toFixed` accepts up to 100; no lab value needs more than this. */
const MAX_SEED_DECIMALS = 20;
/**
* What the edit PUT sends for the value. A field the person did not touch is
* omitted (`undefined`), so the stored full-precision number stays as it is and
+76 -1
View File
@@ -950,6 +950,50 @@ describe("syncUserOura — a grant without the spo2 / heart_health scopes", () =
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura");
});
it("keeps a 401 on a core collection a failure even when siblings succeed", async () => {
getConnMock.mockResolvedValue(CONN);
fetchReadinessMock.mockResolvedValue([
{ id: "x", day: "2026-06-10", score: 70 },
]);
fetchSleepMock.mockRejectedValue(err401());
refreshMock.mockResolvedValue({
access_token: "newAcc",
refresh_token: "newRef",
expires_in: 86400,
});
const result = await syncUserOura("u1");
expect(refreshMock).toHaveBeenCalledTimes(1);
expect(result.failed).toBe(true);
expect(scopeDeniedMock).not.toHaveBeenCalledWith(
"u1",
expect.arrayContaining(["sleep"]),
);
expect(recordFailureMock).toHaveBeenCalledWith(
expect.objectContaining({ integration: "oura" }),
);
expect(recordSuccessMock).not.toHaveBeenCalled();
});
it("does not let a stored core name skip the refresh", async () => {
getConnMock.mockResolvedValue({ ...CONN, scopeDenied: ["sleep"] });
fetchReadinessMock.mockResolvedValue([
{ id: "x", day: "2026-06-10", score: 70 },
]);
fetchSleepMock.mockRejectedValueOnce(err401()).mockResolvedValue([]);
refreshMock.mockResolvedValue({
access_token: "newAcc",
refresh_token: "newRef",
expires_in: 86400,
});
const result = await syncUserOura("u1");
expect(refreshMock).toHaveBeenCalledTimes(1);
expect(result.failed).toBe(false);
});
it("still treats a 401 on every collection as a dead token", async () => {
getConnMock.mockResolvedValue(CONN);
for (const m of [
@@ -973,9 +1017,40 @@ describe("syncUserOura — a grant without the spo2 / heart_health scopes", () =
const result = await syncUserOura("u1");
expect(result.failed).toBe(true);
expect(scopeDeniedMock).toHaveBeenCalledWith("u1", []);
// Nothing proved the token, so nothing was learned about the grant.
expect(scopeDeniedMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledWith(
expect.objectContaining({ integration: "oura", kind: "reauth_required" }),
);
});
it("keeps the scope note when the refresh is abandoned for want of credentials", async () => {
getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED });
// A new 401 the note does not explain sends the run to the refresh.
fetchSleepMock.mockRejectedValue(err401());
denyScopedCollections();
getCredsMock.mockResolvedValue(null);
await syncUserOura("u1");
expect(refreshMock).not.toHaveBeenCalled();
expect(scopeDeniedMock).not.toHaveBeenCalled();
});
it("keeps the scope note when the refresh loses its token to a peer", async () => {
getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED });
fetchSleepMock.mockRejectedValue(err401());
denyScopedCollections();
refreshMock.mockResolvedValue({
access_token: "newAcc",
refresh_token: "newRef",
expires_in: 86400,
});
storeTokensMock.mockResolvedValue(null);
await syncUserOura("u1");
expect(refreshMock).toHaveBeenCalledTimes(1);
expect(scopeDeniedMock).not.toHaveBeenCalled();
});
});
+45 -24
View File
@@ -91,6 +91,7 @@ import {
} from "./credentials";
import { OuraApiError, classifyOuraError } from "./response-classifier";
import { syncUserOuraCyclePhases } from "./cycle-sync";
import { OURA_SCOPE_GATED_COLLECTIONS } from "./scope";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
@@ -245,11 +246,13 @@ export interface OuraFetchResult {
*/
sleepSweeps: SleepSegmentSweep[];
/**
* Collections refused with 401/403 while a sibling succeeded with the same
* Collections refused with 401 while a sibling succeeded with the same
* token: the token is fine, the grant does not cover them. Not failures; the
* card asks for one reconnect instead.
* card asks for one reconnect instead. `null` when the run never proved the
* token (no collection succeeded, or the refresh was abandoned): it learned
* nothing about the grant, so the note from the last run that did stands.
*/
scopeDenied: string[];
scopeDenied: string[] | null;
}
/**
@@ -261,24 +264,35 @@ function isAuthRefusal(err: unknown): boolean {
return err instanceof OuraApiError && err.httpStatus === 401;
}
const SCOPE_GATED: ReadonlySet<string> = new Set(OURA_SCOPE_GATED_COLLECTIONS);
/**
* Split the auth refusals out of a run's failures once a sibling collection has
* proven the token. Oura's authorization server enforces scopes per collection,
* so a grant issued without `spo2` / `heart_health` answers 401 on exactly the
* collections behind them and 200 everywhere else. Counting those toward the
* reconnect ladder painted a working connection as broken on every sync, and
* refreshing the token over them changed nothing.
* A 401 that can be the grant's scope rather than the token: only the
* collections behind `spo2` / `heart_health` sit outside the `daily` scope
* every grant carries. A 401 on sleep, readiness or activity is a real auth
* failure whatever its siblings did.
*/
function isScopeRefusal(f: OuraCollectionFailure): boolean {
return SCOPE_GATED.has(f.name) && isAuthRefusal(f.err);
}
/**
* Split the scope refusals out of a run's failures once a sibling collection
* has proven the token. Oura's authorization server enforces scopes per
* collection, so a grant issued without `spo2` / `heart_health` answers 401 on
* exactly the collections behind them and 200 everywhere else. Counting those
* toward the reconnect ladder painted a working connection as broken on every
* sync, and refreshing the token over them changed nothing. Any other failure,
* a 401 on a core collection included, stays a failure.
*/
function splitScopeDenied(
failures: OuraCollectionFailure[],
tokenProven: boolean,
): { failures: OuraCollectionFailure[]; scopeDenied: string[] } {
if (!tokenProven) return { failures, scopeDenied: [] };
): { failures: OuraCollectionFailure[]; scopeDenied: string[] | null } {
if (!tokenProven) return { failures, scopeDenied: null };
return {
failures: failures.filter((f) => !isAuthRefusal(f.err)),
scopeDenied: failures
.filter((f) => isAuthRefusal(f.err))
.map((f) => f.name),
failures: failures.filter((f) => !isScopeRefusal(f)),
scopeDenied: failures.filter(isScopeRefusal).map((f) => f.name),
};
}
@@ -455,7 +469,9 @@ async function fetchAll(
// to collections that succeeded, is the grant's scope again: a refresh cannot
// widen it, so none is spent. Any other 401 still takes the one refresh, and
// whatever 401s after it beside a success is recorded as scope.
const knownDenied = new Set(knownScopeDenied);
const knownDenied = new Set(
knownScopeDenied.filter((name) => SCOPE_GATED.has(name)),
);
const onlyKnownScope401 = first.failures
.filter((f) => isAuthRefusal(f.err))
.every((f) => knownDenied.has(f.name));
@@ -476,7 +492,7 @@ async function fetchAll(
readings: first.readings,
failures: first.failures,
sleepSweeps,
scopeDenied: [],
scopeDenied: null,
};
const rotated = await refreshAccessToken(refreshToken, creds);
@@ -494,7 +510,7 @@ async function fetchAll(
readings: first.readings,
failures: first.failures,
sleepSweeps,
scopeDenied: [],
scopeDenied: null,
};
const retry = await attempt(usableToken, first.failed);
@@ -551,15 +567,20 @@ export async function syncUserOura(
// Remember which collections the grant does not cover, so the settings card
// can ask for one reconnect. Best-effort: the note is a convenience, the
// sync outcome does not depend on it.
if (result.scopeDenied.length > 0) {
// A run that never proved the token says nothing about the grant and leaves
// the note as it was.
const scopeDenied = result.scopeDenied;
if (scopeDenied && scopeDenied.length > 0) {
annotate({
meta: { oura_scope_denied: result.scopeDenied },
meta: { oura_scope_denied: scopeDenied },
});
}
try {
await syncOuraScopeDenied(userId, result.scopeDenied);
} catch (err) {
getEvent()?.addWarning(`oura: scope note write failed: ${err}`);
if (scopeDenied) {
try {
await syncOuraScopeDenied(userId, scopeDenied);
} catch (err) {
getEvent()?.addWarning(`oura: scope note write failed: ${err}`);
}
}
// Clear whatever an earlier scoring left under the re-fetched sleep records
+214 -4
View File
@@ -105,10 +105,12 @@ import {
import {
releasePlanGatedWithingsParks,
requestWithinPlan,
shouldPersistPlanLeg,
WITHINGS_ADVANCED_MEASTYPES,
WITHINGS_PLAN_MAX_REQUESTS,
} from "../api-plan";
import { withingsPlanUnavailableFeatures } from "../plan-features";
import { WITHINGS_MEASTYPES } from "../client";
const LAST_GOOD_SYNC = new Date("2026-10-12T21:12:00Z");
@@ -315,6 +317,102 @@ describe("syncUserMeasurements inside the Withings API plan", () => {
expect(recordSyncFailure).not.toHaveBeenCalled();
});
it("never remembers a type the budget could not resolve, and converges over runs", async () => {
// Two undocumented refusals on top of the documented set. Over the 19
// types the first run runs out of budget before it reaches 12, 54, 71,
// 76, 77 and 88, all free.
refused = new Set([...WITHINGS_ADVANCED_MEASTYPES, 6, 35]);
let stored: unknown = null;
let lastSyncedAt = LAST_GOOD_SYNC;
vi.mocked(prisma.withingsConnection.findUnique).mockImplementation(
(async () => connection(stored, lastSyncedAt)) as never,
);
vi.mocked(prisma.withingsConnection.update).mockImplementation(
(async (args: { data: { lastSyncedAt: Date } }) => {
lastSyncedAt = args.data.lastSyncedAt;
return {};
}) as never,
);
const runs: Array<{ unavailable: number[]; startdates: string[] }> = [];
const read = new Set<number>();
for (let run = 0; run < 4; run++) {
vi.mocked(prisma.$executeRaw).mockClear();
vi.mocked(prisma.measurement.create).mockClear();
requestedStartdates.length = 0;
await syncUserMeasurements("user-1");
const [persisted] = persistedLegs();
if (persisted) {
const value = persisted.value as {
unavailable: number[];
checkedAt: string;
};
stored = { version: 1, measures: value };
}
for (const types of requestedTypes.splice(0)) {
if (!types.some((t) => refused.has(t)))
types.forEach((t) => read.add(t));
}
const current = stored as {
measures?: { unavailable: number[] };
} | null;
runs.push({
unavailable: current?.measures?.unavailable ?? [],
startdates: [...requestedStartdates],
});
}
const truly = [...refused].sort((a, b) => a - b);
// No run ever stored a type Withings did not refuse.
for (const r of runs) {
expect(r.unavailable.every((t) => refused.has(t))).toBe(true);
}
expect(runs.at(-1)!.unavailable).toEqual(truly);
// Every type the plan allows was read in an accepted request, the free
// ones the first run never reached included.
expect(read).toEqual(
new Set(WITHINGS_MEASTYPES.filter((t) => !refused.has(t))),
);
expect([...read]).toEqual(expect.arrayContaining([12, 54, 71, 76, 77, 88]));
// The first run read nothing for the unresolved types, so the second
// still reads from the last good sync.
const fromLastGood = String(
Math.floor(
(LAST_GOOD_SYNC.getTime() - WITHINGS_INCREMENTAL_OVERLAP_MS) / 1000,
),
);
expect(runs[1]!.startdates.every((s) => s === fromLastGood)).toBe(true);
});
it("keeps the remembered date when a run that is not the re-probe finds one more refusal", async () => {
const remembered = new Date(Date.now() - 3 * 86_400_000).toISOString();
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
connection({
version: 1,
measures: {
unavailable: [...WITHINGS_ADVANCED_MEASTYPES],
checkedAt: remembered,
},
}) as never,
);
// The plan starts refusing fat ratio too.
refused = new Set([...WITHINGS_ADVANCED_MEASTYPES, 6]);
await syncUserMeasurements("user-1");
expect(persistedLegs()).toEqual([
{
leg: "measures",
value: {
unavailable: [6, ...WITHINGS_ADVANCED_MEASTYPES].sort(
(a, b) => a - b,
),
checkedAt: remembered,
},
},
]);
});
it("reads the whole gap since the last good sync", async () => {
await syncUserMeasurements("user-1");
@@ -326,19 +424,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);
});
});
@@ -423,7 +630,7 @@ describe("requestWithinPlan", () => {
expect(second.unavailable.sort()).toEqual(["c", "f"]);
});
it("stops at the request budget and counts what is left as unavailable", async () => {
it("stops at the request budget and leaves what is left unverified, not unavailable", async () => {
const result = await requestWithinPlan({
keys: ["a", "b", "c", "d", "e", "f", "g", "h"],
knownGated: [],
@@ -433,7 +640,10 @@ describe("requestWithinPlan", () => {
},
});
expect(result.requests).toBe(3);
expect(result.unavailable.sort()).toEqual([
// Only the requests actually made prove anything: [a..h], [a..d] and [a,b]
// were refused, which isolates no single key.
expect(result.unavailable).toEqual([]);
expect(result.unverified.sort()).toEqual([
"a",
"b",
"c",
+66 -17
View File
@@ -16,7 +16,8 @@
* documents as advanced, then, if it is still refused, splits what is left
* in halves until every accepted subset has been read and every refused key
* is isolated. Bounded by `WITHINGS_PLAN_MAX_REQUESTS`; anything the budget
* could not resolve counts as unavailable until the next probe.
* could not resolve is unverified, never remembered as refused, and asked
* for again on the next run, which starts from what this one proved.
* - `WithingsApiPlanState` remembers per request family what the plan
* refused, so later runs skip those keys without paying a refused round
* trip, and re-probes them weekly in case the account's plan changes.
@@ -57,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
@@ -91,8 +104,16 @@ const PLAN_GATED_MESSAGE = /Withings\s+\w+\s+error:\s*277\b/;
export interface WithinPlanResult<K, R> {
/** Everything the accepted requests returned. */
results: R[];
/** The requested keys the plan refused (or the budget could not clear). */
/**
* The requested keys the plan refused: isolated alone, or dropped as
* documented advanced after the whole request was refused.
*/
unavailable: K[];
/**
* The requested keys the budget ran out on. Neither read nor proven
* refused: not to be remembered as unavailable, and to be asked for again.
*/
unverified: K[];
/** True when at least one request was refused with 277. */
gated: boolean;
/** True when at least one request was accepted. */
@@ -108,8 +129,9 @@ export interface WithinPlanResult<K, R> {
* are dropped and the rest asked for again; on a further 277 the remainder is
* split in halves, depth first and in input order, so the same refusal always
* spends the same requests. A single key that is still refused is unavailable.
* When the budget runs out, the subsets not yet tried count as unavailable for
* now; the weekly re-probe asks for them again.
* When the budget runs out, the subsets not yet resolved are unverified: the
* caller remembers only what was proven, so the next run skips that and spends
* its budget on the rest.
*
* Any error other than a 277 propagates unchanged: this only decides what the
* plan allows, never whether the connection works.
@@ -124,6 +146,7 @@ export async function requestWithinPlan<K, R>(opts: {
const keys = [...opts.keys];
const results: R[] = [];
const unavailable: K[] = [];
const unverified: K[] = [];
let requests = 0;
let gated = false;
let acceptedAny = false;
@@ -143,10 +166,10 @@ export async function requestWithinPlan<K, R>(opts: {
};
if (keys.length === 0) {
return { results, unavailable, gated, acceptedAny, requests };
return { results, unavailable, unverified, gated, acceptedAny, requests };
}
if (await attempt(keys)) {
return { results, unavailable, gated, acceptedAny, requests };
return { results, unavailable, unverified, gated, acceptedAny, requests };
}
const knownGated = new Set(opts.knownGated);
@@ -155,14 +178,14 @@ export async function requestWithinPlan<K, R>(opts: {
if (dropped.length > 0) {
unavailable.push(...dropped);
if (remaining.length === 0) {
return { results, unavailable, gated, acceptedAny, requests };
return { results, unavailable, unverified, gated, acceptedAny, requests };
}
if (requests >= budget) {
unavailable.push(...remaining);
return { results, unavailable, gated, acceptedAny, requests };
unverified.push(...remaining);
return { results, unavailable, unverified, gated, acceptedAny, requests };
}
if (await attempt(remaining)) {
return { results, unavailable, gated, acceptedAny, requests };
return { results, unavailable, unverified, gated, acceptedAny, requests };
}
}
@@ -170,14 +193,14 @@ export async function requestWithinPlan<K, R>(opts: {
// part has been read and each refused key stands alone.
if (remaining.length === 1) {
unavailable.push(...remaining);
return { results, unavailable, gated, acceptedAny, requests };
return { results, unavailable, unverified, gated, acceptedAny, requests };
}
const stack: K[][] = halves(remaining).reverse();
remaining = [];
while (stack.length > 0) {
const subset = stack.pop()!;
if (requests >= budget) {
unavailable.push(...subset);
unverified.push(...subset);
continue;
}
if (await attempt(subset)) continue;
@@ -188,7 +211,7 @@ export async function requestWithinPlan<K, R>(opts: {
stack.push(...halves(subset).reverse());
}
return { results, unavailable, gated, acceptedAny, requests };
return { results, unavailable, unverified, gated, acceptedAny, requests };
}
function halves<K>(list: K[]): K[][] {
@@ -274,6 +297,24 @@ export function planSkipFor<K>(
return leg.unavailable;
}
/**
* The `checkedAt` to store with a run's outcome. Only a re-probe asked for the
* full set, so only a re-probe (or a leg with nothing remembered yet) starts
* the week again. A run that skipped remembered keys and found one more
* refusal keeps the old date: moving it would push the re-probe of the keys it
* never asked for further out on every such run.
*/
export function planCheckedAtFor<K>(
leg: WithingsPlanLegState<K> | undefined,
reprobed: boolean,
now: Date,
): string {
if (reprobed || !leg || leg.unavailable.length === 0) {
return now.toISOString();
}
return leg.checkedAt;
}
/**
* Write one leg of the plan state. A single statement merges the leg into the
* stored object, so the measures, sleep and ECG legs (separate crons, separate
@@ -312,15 +353,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 ──────────────────────────────────────
+9 -2
View File
@@ -65,6 +65,7 @@ import {
isPlanReprobeDue,
parseWithingsApiPlanState,
persistWithingsPlanLeg,
planCheckedAtFor,
planSkipFor,
requestWithinPlan,
shouldPersistPlanLeg,
@@ -566,10 +567,16 @@ 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(),
checkedAt: planCheckedAtFor(sleepPlan, reprobed, now),
});
}
} catch (err) {
+51 -15
View File
@@ -18,10 +18,12 @@ import {
isPlanReprobeDue,
parseWithingsApiPlanState,
persistWithingsPlanLeg,
planCheckedAtFor,
planSkipFor,
requestWithinPlan,
shouldPersistPlanLeg,
WITHINGS_ADVANCED_MEASTYPES,
WITHINGS_CORE_MEASTYPES,
WITHINGS_PLAN_GATED_STATUS,
type WithinPlanResult,
} from "./api-plan";
@@ -273,11 +275,17 @@ 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),
checkedAt: now.toISOString(),
checkedAt: planCheckedAtFor(planState.measures, reprobed, now),
});
} catch (err) {
// Losing the memory only costs a refused round trip next run.
@@ -290,21 +298,33 @@ export async function syncUserMeasurements(
name: "withings.sync.plan_gated",
details: {
unavailable: plan.unavailable,
unverified: plan.unverified,
requests: plan.requests,
acceptedAny: plan.acceptedAny,
},
},
});
}
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;
@@ -486,11 +506,27 @@ export async function syncUserMeasurements(
return { imported, failed: true };
}
// Update last synced timestamp
await prisma.withingsConnection.update({
where: { userId },
data: { lastSyncedAt: new Date() },
});
// Types the probe budget could not resolve were not read this run. Moving
// 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.
// 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, skippedCore },
},
});
} else {
await prisma.withingsConnection.update({
where: { userId },
data: { lastSyncedAt: new Date() },
});
}
// Record success — even when measures.length === 0, completing the
// round-trip is the signal "the connection is healthy". A user who
@@ -297,6 +297,81 @@ describe("a printed reference range, end to end", () => {
expect(observations[0].valueQuantity?.unit).toBe("mg/dL");
});
it("drops the printed text with the conversion record when the value is edited", async () => {
const prisma = getPrismaClient();
const marker = await prisma.biomarker.create({
data: {
userId: USER_ID,
name: "Glucose",
unit: "mg/dL",
lowerBound: 70,
upperBound: 100,
},
});
const PRINTED_MMOL = "3,9 - 5,5 mmol/l";
const { POST } = await import("@/app/api/labs/route");
const created = await POST(
postReq({
biomarkerId: marker.id,
value: 5.6,
unit: "mmol/L",
sourceReferenceLow: 3.9,
sourceReferenceHigh: 5.5,
sourceReferenceText: PRINTED_MMOL,
takenAt: "2026-07-03T08:00:00.000Z",
}),
);
expect(created.status).toBe(201);
const id = (await created.json()).data.id as string;
// The person types a new number in mg/dL, and the form echoes the old
// printed text back with it.
const { PUT } = await import("@/app/api/labs/[id]/route");
const edited = await PUT(
new NextRequest(`http://localhost/api/labs/${id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ value: 104, sourceReferenceText: PRINTED_MMOL }),
}),
{ params: Promise.resolve({ id }) },
);
expect(edited.status).toBe(200);
const row = await prisma.labResult.findUniqueOrThrow({ where: { id } });
expect(row.sourceUnit).toBeNull();
expect(row.sourceValue).toBeNull();
expect(row.sourceReferenceText).toBeNull();
// The bounds were converted with the value and stay, in mg/dL.
expect(row.sourceReferenceLow).toBeCloseTo(3.9 * 18.0182, 6);
expect(row.sourceReferenceHigh).toBeCloseTo(5.5 * 18.0182, 6);
// No surface shows mmol/L text beside the mg/dL numbers.
const dto = await readDto("Glucose");
expect(dto.converted).toBe(false);
expect(dto.sourceReferenceText).toBeNull();
expect(dto.printedReferenceText).toBeNull();
expect(dto.referenceOrigin).toBe("source");
expect(dto.referenceHigh).toBeCloseTo(5.5 * 18.0182, 6);
const { loadLabResults } =
await import("@/lib/doctor-report/clinical-records");
const labs = await loadLabResults(
USER_ID,
new Date("2026-01-01T00:00:00.000Z"),
new Date("2026-12-31T23:59:59.000Z"),
);
expect(labs?.[0].sourceReferenceText).toBeNull();
const { labObservations } = await import("@/lib/fhir/resources/labs");
let seq = 0;
const observations = labObservations(
{ labResults: labs } as Parameters<typeof labObservations>[0],
() => `obs-${++seq}`,
);
expect(observations[0].referenceRange?.[0]?.text).toBeUndefined();
expect(observations[0].referenceRange?.[0]?.high?.unit).toBe("mg/dL");
});
it("keeps a window it cannot read as text and leaves the catalog in force", async () => {
const prisma = getPrismaClient();
await seedMarker();