Merge branch 'fix/resilience' into release/v1.27.30

This commit is contained in:
Marc-André Bombeck committed 2026-07-09 01:00:04 +02:00
commit 20275d19e0
40 files changed
+1452 -162

No files matched your search

+3 -1
View File
@@ -189,6 +189,7 @@
"illnessColOnset": "Beginn",
"illnessColResolved": "Abgeklungen",
"allergiesTitle": "Allergien & Unverträglichkeiten",
"reactionUnreadable": "Reaktion erfasst, konnte aber nicht entschlüsselt werden",
"allergiesColSubstance": "Substanz",
"allergiesColCategory": "Kategorie",
"allergiesColKind": "Art",
@@ -209,7 +210,8 @@
"backToDashboard": "Zurück zum Dashboard"
},
"offlineBanner": {
"message": "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist.",
"message": "Keine Verbindung — Änderungen können erst gespeichert werden, wenn du wieder online bist.",
"saveFailed": "Speichern fehlgeschlagen — du bist offline. Bitte versuche es erneut, sobald du wieder online bist.",
"showingCached": "Zeigt deine zuletzt synchronisierten Daten."
},
"demoBanner": {
+3 -1
View File
@@ -189,6 +189,7 @@
"illnessColOnset": "Onset",
"illnessColResolved": "Resolved",
"allergiesTitle": "Allergies & intolerances",
"reactionUnreadable": "Reaction recorded but could not be decrypted",
"allergiesColSubstance": "Substance",
"allergiesColCategory": "Category",
"allergiesColKind": "Kind",
@@ -209,7 +210,8 @@
"backToDashboard": "Back to dashboard"
},
"offlineBanner": {
"message": "No connection — your changes will save once you're back online.",
"message": "No connection — changes can't be saved until you're back online.",
"saveFailed": "Couldn't save — you're offline. Please try again once you're back online.",
"showingCached": "Showing your last synced data."
},
"demoBanner": {
+3 -1
View File
@@ -189,6 +189,7 @@
"illnessColOnset": "Inicio",
"illnessColResolved": "Resuelta",
"allergiesTitle": "Alergias e intolerancias",
"reactionUnreadable": "Reacción registrada pero no se pudo descifrar",
"allergiesColSubstance": "Sustancia",
"allergiesColCategory": "Categoría",
"allergiesColKind": "Tipo",
@@ -209,7 +210,8 @@
"backToDashboard": "Volver al panel"
},
"offlineBanner": {
"message": "Sin conexión — tus cambios se guardarán cuando vuelvas a estar en línea.",
"message": "Sin conexión — los cambios no se pueden guardar hasta que vuelvas a estar en línea.",
"saveFailed": "No se pudo guardar — estás sin conexión. Inténtalo de nuevo cuando vuelvas a estar en línea.",
"showingCached": "Mostrando tus últimos datos sincronizados."
},
"demoBanner": {
+3 -1
View File
@@ -189,6 +189,7 @@
"illnessColOnset": "Début",
"illnessColResolved": "Résolue",
"allergiesTitle": "Allergies et intolérances",
"reactionUnreadable": "Réaction enregistrée mais impossible à déchiffrer",
"allergiesColSubstance": "Substance",
"allergiesColCategory": "Catégorie",
"allergiesColKind": "Type",
@@ -209,7 +210,8 @@
"backToDashboard": "Retour au tableau de bord"
},
"offlineBanner": {
"message": "Pas de connexion — tes modifications seront enregistrées dès que tu seras de nouveau en ligne.",
"message": "Pas de connexion — les modifications ne peuvent pas être enregistrées tant que tu n'es pas de nouveau en ligne.",
"saveFailed": "Enregistrement impossible — tu es hors ligne. Réessaie une fois de nouveau en ligne.",
"showingCached": "Affichage de tes dernières données synchronisées."
},
"demoBanner": {
+3 -1
View File
@@ -189,6 +189,7 @@
"illnessColOnset": "Inizio",
"illnessColResolved": "Risolta",
"allergiesTitle": "Allergie e intolleranze",
"reactionUnreadable": "Reazione registrata ma impossibile da decifrare",
"allergiesColSubstance": "Sostanza",
"allergiesColCategory": "Categoria",
"allergiesColKind": "Tipo",
@@ -209,7 +210,8 @@
"backToDashboard": "Torna alla dashboard"
},
"offlineBanner": {
"message": "Nessuna connessione — le tue modifiche verranno salvate appena torni online.",
"message": "Nessuna connessione — le modifiche non possono essere salvate finché non torni online.",
"saveFailed": "Impossibile salvare — sei offline. Riprova quando torni online.",
"showingCached": "Vengono mostrati i tuoi ultimi dati sincronizzati."
},
"demoBanner": {
+3 -1
View File
@@ -189,6 +189,7 @@
"illnessColOnset": "Początek",
"illnessColResolved": "Ustąpiła",
"allergiesTitle": "Alergie i nietolerancje",
"reactionUnreadable": "Reakcja zapisana, ale nie można jej odszyfrować",
"allergiesColSubstance": "Substancja",
"allergiesColCategory": "Kategoria",
"allergiesColKind": "Rodzaj",
@@ -209,7 +210,8 @@
"backToDashboard": "Powrót do panelu"
},
"offlineBanner": {
"message": "Brak połączenia — twoje zmiany zostaną zapisane, gdy ponownie pojawisz się online.",
"message": "Brak połączenia — zmian nie można zapisać, dopóki nie wrócisz online.",
"saveFailed": "Nie udało się zapisać — jesteś offline. Spróbuj ponownie, gdy wrócisz online.",
"showingCached": "Wyświetlane są ostatnio zsynchronizowane dane."
},
"demoBanner": {
+25
View File
@@ -24,6 +24,10 @@ import {
getPersistentFailureThreshold,
type IntegrationKey,
} from "@/lib/integrations/status";
import {
getSourceMetricFreshness,
type MetricFreshnessEntry,
} from "@/lib/integrations/metric-freshness";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { hasActivityScope } from "@/lib/withings/client";
@@ -131,6 +135,14 @@ export const GET = apiHandler(async () => {
getOuraClientCredentials(user.id).then((c) => !!c),
]);
// F-SYNC-1 — per-metric-type last-value timestamps so the card can show that a
// single metric type has gone silent even while the integration reads green
// ("connected · 5 min ago"). Fail-soft: this is an honesty signal, never worth
// 500-ing the whole Settings card, so a groupBy hiccup degrades to no data.
const metricFreshness = await getSourceMetricFreshness(user.id).catch(
() => ({}) as Partial<Record<IntegrationKey, MetricFreshnessEntry[]>>,
);
const now = Date.now();
return apiSuccess({
@@ -156,6 +168,7 @@ export const GET = apiHandler(async () => {
// Null `scope` = legacy connection that predates activity-scope reads.
scope: withingsConn?.scope ?? null,
hasActivityScope: hasActivityScope(withingsConn?.scope ?? null),
metricFreshness: metricFreshness.withings ?? [],
} satisfies IntegrationViewModel & WithingsExtras,
{
...moodLogStatus,
@@ -183,6 +196,7 @@ export const GET = apiHandler(async () => {
? whoopConn.tokenExpiresAt.getTime() <= now
: null,
backfillCompleted: whoopConn ? !!whoopConn.backfillCompletedAt : null,
metricFreshness: metricFreshness.whoop ?? [],
} satisfies IntegrationViewModel & WhoopExtras,
{
...fitbitStatus,
@@ -197,6 +211,7 @@ export const GET = apiHandler(async () => {
? fitbitConn.tokenExpiresAt.getTime() <= now
: null,
backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null,
metricFreshness: metricFreshness.fitbit ?? [],
} satisfies IntegrationViewModel & FitbitExtras,
{
...googleHealthStatus,
@@ -218,6 +233,7 @@ export const GET = apiHandler(async () => {
// user-revoked grant) surfaced from the connection row; the card reads it
// to raise a distinct "Reconnect" CTA separate from parked/disconnected.
needsReauth: googleHealthConn ? googleHealthConn.needsReauth : false,
metricFreshness: metricFreshness["google-health"] ?? [],
} satisfies IntegrationViewModel & GoogleHealthExtras,
{
...polarStatus,
@@ -231,6 +247,7 @@ export const GET = apiHandler(async () => {
hasOwnCredentials:
!!dbUser?.polarClientIdEncrypted &&
!!dbUser?.polarClientSecretEncrypted,
metricFreshness: metricFreshness.polar ?? [],
} satisfies IntegrationViewModel & OAuthProviderExtras,
{
...ouraStatus,
@@ -240,6 +257,7 @@ export const GET = apiHandler(async () => {
hasOwnCredentials:
!!dbUser?.ouraClientIdEncrypted &&
!!dbUser?.ouraClientSecretEncrypted,
metricFreshness: metricFreshness.oura ?? [],
} satisfies IntegrationViewModel & OAuthProviderExtras,
],
});
@@ -251,6 +269,13 @@ interface IntegrationViewModel {
lastSuccessAt: string | null;
lastAttemptAt: string | null;
lastError: string | null;
/**
* F-SYNC-1 — per-metric-type last-value timestamps for the integration's
* synced measurements. Present on the measurement-backed integrations;
* omitted for moodLog (which writes MoodEntry rows, not Measurements). Lets
* the card flag a silently-dead metric the green integration state hides.
*/
metricFreshness?: MetricFreshnessEntry[];
}
interface WithingsExtras {
@@ -49,16 +49,19 @@ describe("<OfflineBanner>", () => {
});
it("ships the EN copy for `offlineBanner.message`", () => {
// F-OFF-1 — the copy must not over-promise auto-save: offline mutations now
// fail fast (networkMode "always") rather than queue, so the banner states
// the honest thing.
const en = loadMessages("en");
expect(en.offlineBanner.message).toBe(
"No connection — your changes will save once you're back online.",
"No connection — changes can't be saved until you're back online.",
);
});
it("ships the DE copy for `offlineBanner.message`", () => {
const de = loadMessages("de");
expect(de.offlineBanner.message).toBe(
"Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist.",
"Keine Verbindung — Änderungen können erst gespeichert werden, wenn du wieder online bist.",
);
});
+39 -12
View File
@@ -14,8 +14,9 @@ import {
useState,
type ReactNode,
} from "react";
import { I18nProvider } from "@/lib/i18n/context";
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import { VersionPoller } from "@/components/version-poller";
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
@@ -26,6 +27,7 @@ import {
restorePersistedQueryCache,
startPersistingQueryCache,
} from "@/lib/pwa/query-persister";
import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
@@ -177,6 +179,40 @@ function QueryPersistenceBridge() {
return null;
}
// ── Offline mutation backstop ────────────────────────
/**
* F-OFF-1 backstop. With mutations in `always` network mode an offline write
* rejects immediately (rather than pausing forever), so the individual form's
* `onError` fires and shows its own message. This subscriber is the safety net
* for any mutation WITHOUT a call-site error handler: it surfaces one honest,
* de-duplicated toast whenever a mutation errors while the browser is offline,
* so a write can never vanish silently. Gated on `navigator.onLine === false`
* so a normal server-side error (which the form already surfaces) is never
* double-toasted.
*/
function OfflineMutationToaster() {
const { t } = useTranslations();
const queryClient = useQueryClient();
useEffect(() => {
const cache = queryClient.getMutationCache();
const unsubscribe = cache.subscribe((event) => {
if (
event.type === "updated" &&
event.action.type === "error" &&
typeof navigator !== "undefined" &&
navigator.onLine === false
) {
toast.error(t("offlineBanner.saveFailed"), {
id: "offline-mutation-failed",
});
}
});
return unsubscribe;
}, [queryClient, t]);
return null;
}
// ── Root Providers ───────────────────────────────────
export function Providers({
@@ -187,17 +223,7 @@ export function Providers({
initialLocale?: Locale;
}) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
},
},
}),
() => new QueryClient({ defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS }),
);
return (
@@ -206,6 +232,7 @@ export function Providers({
<I18nProvider initialLocale={initialLocale}>
<QueryPersistenceBridge />
<DashboardSnapshotPreloader />
<OfflineMutationToaster />
{children}
<Toaster position="bottom-right" richColors />
<VersionPoller />
@@ -0,0 +1,57 @@
/**
* F-CRYPTO-3 — an undecryptable allergy reaction must be distinguishable from a
* genuinely-unset one on a clinician-facing export. The builder used to fail
* SOFT (→ null, silently omitted) where a sibling dose-change note fails CLOSED,
* so a reaction that existed but was unreadable rendered as a blank "—" that a
* clinician reads as "no reaction recorded". `decryptAllergyReaction` now flags
* the unreadable case so the PDF renders an honest marker.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
const decryptMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: decryptMock,
}));
import { decryptAllergyReaction } from "../doctor-report-data";
afterEach(() => vi.clearAllMocks());
describe("decryptAllergyReaction", () => {
it("returns null / not-unreadable for a genuinely-unset envelope (null)", () => {
expect(decryptAllergyReaction(null)).toEqual({
reaction: null,
reactionUnreadable: false,
});
expect(decryptMock).not.toHaveBeenCalled();
});
it("returns null / not-unreadable for an empty envelope", () => {
expect(decryptAllergyReaction(new Uint8Array(0))).toEqual({
reaction: null,
reactionUnreadable: false,
});
expect(decryptMock).not.toHaveBeenCalled();
});
it("returns the decrypted reaction on success", () => {
decryptMock.mockReturnValue("hives, swelling");
expect(decryptAllergyReaction(new Uint8Array([1, 2, 3]))).toEqual({
reaction: "hives, swelling",
reactionUnreadable: false,
});
});
it("flags reactionUnreadable when the decrypt throws (key gap / GCM corruption)", () => {
decryptMock.mockImplementation(() => {
throw new Error("bad ciphertext");
});
// The reaction WAS recorded (non-empty envelope) but cannot be decrypted:
// reaction stays null, but the honest unreadable flag is raised so the
// clinician sees a marker, not a silent blank.
expect(decryptAllergyReaction(new Uint8Array([9, 9, 9]))).toEqual({
reaction: null,
reactionUnreadable: true,
});
});
});
@@ -0,0 +1,85 @@
/**
* F-CRYPTO-4 — an undecryptable Coach self-context field (allergies /
* conditions) must NOT vanish silently. It still fails closed per field (never
* surfaces ciphertext, never throws into prompt assembly), but a swallowed
* decrypt now emits a wide-event so a key-rotation gap surfaces instead of
* masquerading as "the user never wrote anything".
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { decryptMock, addWarningMock, findUniqueMock } = vi.hoisted(() => ({
decryptMock: vi.fn(),
addWarningMock: vi.fn(),
findUniqueMock: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: { userHealthProfile: { findUnique: findUniqueMock } },
}));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: decryptMock,
encryptToBytes: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ addWarning: addWarningMock }),
}));
import { getSelfContextForUser } from "../about-me";
beforeEach(() => {
decryptMock.mockReset();
addWarningMock.mockReset();
findUniqueMock.mockReset();
});
describe("getSelfContextForUser — undecryptable field", () => {
it("fails closed to null AND logs a wide-event on a decrypt failure", async () => {
findUniqueMock.mockResolvedValue({
aboutMeEncrypted: new Uint8Array([1]),
conditionsEncrypted: null,
allergiesEncrypted: new Uint8Array([2]), // safety-relevant, undecryptable
coachFocusEncrypted: null,
});
decryptMock.mockImplementation(() => {
throw new Error("bad ciphertext");
});
const ctx = await getSelfContextForUser("u1");
// Never surfaces ciphertext; the field is null (fail-closed).
expect(ctx.aboutMe).toBeNull();
expect(ctx.allergies).toBeNull();
// But the swallowed decrypt is now visible — one warning per undecryptable
// field (aboutMe + allergies here).
expect(addWarningMock).toHaveBeenCalledTimes(2);
expect(addWarningMock.mock.calls[0][0]).toContain("self-context");
});
it("does not log when the fields are genuinely unset", async () => {
findUniqueMock.mockResolvedValue({
aboutMeEncrypted: null,
conditionsEncrypted: null,
allergiesEncrypted: null,
coachFocusEncrypted: null,
});
const ctx = await getSelfContextForUser("u1");
expect(ctx).toEqual({
aboutMe: null,
conditions: null,
allergies: null,
coachFocus: null,
});
expect(addWarningMock).not.toHaveBeenCalled();
});
it("logs a wide-event when the DB read itself throws (no silent empty profile)", async () => {
findUniqueMock.mockRejectedValue(new Error("db down"));
const ctx = await getSelfContextForUser("u1");
expect(ctx.allergies).toBeNull();
expect(addWarningMock).toHaveBeenCalledWith(
expect.stringContaining("self-context load failed"),
);
});
});
+31 -5
View File
@@ -27,6 +27,7 @@
* Server-only — reads `@/lib/db`.
*/
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
SELF_REPORT_FENCE_START,
@@ -48,9 +49,18 @@ function decryptOrNull(payload: Uint8Array | null): string | null {
try {
const text = decryptFromBytes(payload).trim();
return text.length > 0 ? text : null;
} catch {
// Fail closed per field — never surface ciphertext, never throw
// into a prompt-assembly path.
} catch (err) {
// Fail closed per field — never surface ciphertext, never throw into a
// prompt-assembly path. But do NOT swallow silently: a stored-but-
// undecryptable safety-relevant field (allergies / conditions) that
// vanishes from the Coach's self-context with no signal is exactly the
// F-CRYPTO-4 gap. Emit a wide-event so a key-rotation gap surfaces instead
// of masquerading as "the user never wrote anything".
getEvent()?.addWarning(
`coach self-context: field decrypt failed (${
err instanceof Error ? err.message : String(err)
})`,
);
return null;
}
}
@@ -83,7 +93,15 @@ export async function getSelfContextForUser(
allergies: decryptOrNull(row?.allergiesEncrypted ?? null),
coachFocus: decryptOrNull(row?.coachFocusEncrypted ?? null),
};
} catch {
} catch (err) {
// Per-field decrypt already fails closed above; this catch is the DB-read
// guard. Surface it (rather than masquerading as an empty profile) so a
// systemic read/key-config failure is visible in wide-events.
getEvent()?.addWarning(
`coach self-context load failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return {
aboutMe: null,
conditions: null,
@@ -390,7 +408,15 @@ export async function getSelfContextTextForUser(
);
if (!selfText && !recordsText) return null;
return [selfText, recordsText].filter(Boolean).join("\n");
} catch {
} catch (err) {
// Per-field decrypt fails closed inside the composer; this catch is the
// DB-read guard. Log it so a systemic failure surfaces rather than the
// Coach silently generating guidance without the user's recorded profile.
getEvent()?.addWarning(
`coach self-context text load failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
+10 -1
View File
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { encrypt, decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
import { CUSTOM_SYMPTOM_KEY_PREFIX } from "@/lib/cycle/custom-symptoms-shared";
@@ -52,7 +53,15 @@ export function decryptCustomLabel(encoded: string | null): string | null {
if (!encoded) return null;
try {
return decrypt(encoded);
} catch {
} catch (err) {
// A custom label WAS stored but is undecryptable — fail soft (the caller
// falls back to the generic i18n label) but log it (F-CRYPTO-5) so the loss
// of a user-renamed symptom is not entirely silent.
getEvent()?.addWarning(
`cycle custom-label decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
+11 -1
View File
@@ -9,6 +9,7 @@
* (NULL = a plain presence link).
*/
import { decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
import type { CyclePredictionResult } from "@/lib/cycle/types";
import type {
CycleDayLog,
@@ -73,7 +74,16 @@ function decryptNote(notesEncrypted: string | null): string | null {
if (!notesEncrypted) return null;
try {
return decrypt(notesEncrypted);
} catch {
} catch (err) {
// A note WAS stored but is undecryptable (key-rotation gap / corruption).
// Fail soft to null so one bad row never throws the day-log read, but log
// it (F-CRYPTO-2) so a systemic key gap surfaces instead of silently
// reading as an empty note.
getEvent()?.addWarning(
`cycle day-log note decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
+48 -8
View File
@@ -28,6 +28,7 @@ import type {
MeasurementType,
} from "@/generated/prisma/client";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import { getEvent } from "@/lib/logging/context";
import { readNote } from "@/lib/crypto/note-cipher";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
@@ -343,8 +344,15 @@ export interface DoctorReportData {
severity: string | null;
/** Status (ACTIVE / INACTIVE / RESOLVED). */
status: string;
/** Decrypted reaction description, or null (unset or key gap). */
/** Decrypted reaction description, or null (genuinely unset). */
reaction: string | null;
/**
* True when a reaction description WAS stored but could not be decrypted
* (a key-rotation gap / GCM corruption). Distinct from `reaction: null`
* (nothing recorded) so the clinician-facing report shows an honest
* "unreadable" marker rather than a blank that reads as "no reaction".
*/
reactionUnreadable?: boolean;
}> | null;
/**
* v1.27.x — structured family-history records. Reference data, not
@@ -765,6 +773,32 @@ export function buildLedgerCompliance(
return compliance;
}
/**
* Decrypt an allergy reaction envelope for a clinician-facing export, keeping
* "unreadable" distinct from "unset". A genuinely-empty envelope yields
* `{ reaction: null, reactionUnreadable: false }`; a stored-but-undecryptable
* one (key-rotation gap / GCM corruption) yields
* `{ reaction: null, reactionUnreadable: true }` so the report can render an
* honest marker instead of a blank that reads as "no reaction recorded". Pure
* (no logging) so it is unit-testable; the caller logs on the unreadable flag.
*/
export function decryptAllergyReaction(reactionEncrypted: Uint8Array | null): {
reaction: string | null;
reactionUnreadable: boolean;
} {
if (!reactionEncrypted || reactionEncrypted.byteLength === 0) {
return { reaction: null, reactionUnreadable: false };
}
try {
return {
reaction: decryptFromBytes(reactionEncrypted),
reactionUnreadable: false,
};
} catch {
return { reaction: null, reactionUnreadable: true };
}
}
/**
* Aggregate the doctor-report payload for a user over a `[start, end]` range.
* Pure data assembly — no auth, no rate-limit, no audit. Idempotent.
@@ -1477,13 +1511,18 @@ export async function collectDoctorReportData(
},
});
const mapped = rows.map((r) => {
let reaction: string | null = null;
if (r.reactionEncrypted && r.reactionEncrypted.byteLength > 0) {
try {
reaction = decryptFromBytes(r.reactionEncrypted);
} catch {
reaction = null;
}
const { reaction, reactionUnreadable } = decryptAllergyReaction(
r.reactionEncrypted,
);
if (reactionUnreadable) {
// A reaction WAS recorded but is undecryptable (key gap / GCM
// corruption). It is flagged (not silently blanked) so the PDF renders
// an honest "unreadable" marker; log the swallowed decrypt so a
// systemic key-config failure is visible rather than masquerading as
// "no reaction recorded" on a clinician-facing export.
getEvent()?.addWarning(
`doctor-report: allergy reaction decrypt failed for ${userId} (substance=${r.substance})`,
);
}
return {
substance: r.substance,
@@ -1492,6 +1531,7 @@ export async function collectDoctorReportData(
severity: r.severity,
status: r.status,
reaction,
reactionUnreadable,
};
});
allergies = mapped.length > 0 ? mapped : null;
+5 -1
View File
@@ -1627,7 +1627,11 @@ export function buildDoctorReportPdfDocument(
t(`records.allergies.category.${al.category}`),
t(`records.allergies.type.${al.type}`),
al.severity ? t(`records.allergies.severity.${al.severity}`) : "—",
al.reaction ?? "—",
// A reaction that WAS recorded but could not be decrypted renders an
// honest marker, never a blank "—" that reads as "no reaction recorded".
al.reactionUnreadable
? t("doctorReport.reactionUnreadable")
: (al.reaction ?? "—"),
t(`records.allergies.status.${al.status}`),
]);
@@ -190,6 +190,56 @@ describe("syncUserFitbit — all-403 looks-healthy guard", () => {
});
});
/** A resource sync that HARD-fails its collection (non-403) and, thanks to the
* ported ledger, records + returns 0 instead of rethrowing. */
async function hardFail500(...args: unknown[]): Promise<number> {
return handleCollectionFetchError(
"test-resource",
String(args[0]),
new FitbitApiError({
verb: "fetchTest",
classification: "transient",
httpStatus: 500,
reason: "http_500",
}),
);
}
describe("syncUserFitbit — hard-fail ledger (F-SYNC-4)", () => {
it("a non-403 hard failure does NOT rethrow and keeps siblings running", async () => {
// Metrics hard-fails (500) but must not abort the cycle: the sibling
// resources still run, and the whole call resolves (no rethrow).
syncUserMetrics.mockImplementation(hardFail500);
syncUserActivity.mockResolvedValue(2);
syncUserSleep.mockResolvedValue(1);
syncUserWorkout.mockResolvedValue(0);
// Resolves (no rethrow) with the siblings' imports.
const total = await syncUserFitbit("user1");
expect(total).toBe(3);
// Every sibling resource still ran despite the metrics hard failure.
expect(syncUserActivity).toHaveBeenCalled();
expect(syncUserSleep).toHaveBeenCalled();
expect(syncUserWorkout).toHaveBeenCalled();
});
it("a hard-failed collection fails the cycle: no success stamp, no watermark", async () => {
syncUserMetrics.mockImplementation(hardFail500);
// Others import so the run is not degenerate — only the ledger keeps it honest.
syncUserActivity.mockResolvedValue(4);
await syncUserFitbit("user1");
// The ledger flips anyFailed → success is NOT stamped and the watermark is
// not advanced, so the next tick refetches the broken collection's window.
expect(recordSyncSuccess).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
// The hard failure was recorded on the status ledger.
expect(recordSyncFailure).toHaveBeenCalled();
});
});
describe("syncUserFitbit — single cycle-wide watermark", () => {
it("snapshots the incremental start ONCE and threads the SAME watermark to every resource", async () => {
syncUserMetrics.mockResolvedValue(1);
+21 -8
View File
@@ -109,14 +109,27 @@ export async function syncUserActivity(
forbidden = true;
break;
}
for (const m of resource.map(body)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: externalIdFor(m),
});
try {
for (const m of resource.map(body)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: externalIdFor(m),
});
}
} catch (err) {
// A malformed point whose mapper throws routes through the same ledger
// as a fetch failure — record it, short-circuit this resource, and let
// the sibling resources keep syncing.
imported += await handleCollectionFetchError(
resource.verb,
userId,
err,
);
forbidden = true;
break;
}
}
if (forbidden) continue;
+21 -8
View File
@@ -103,14 +103,27 @@ export async function syncUserMetrics(
forbidden = true;
break;
}
for (const m of resource.map(body)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: m.fieldTag,
});
try {
for (const m of resource.map(body)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: m.fieldTag,
});
}
} catch (err) {
// A malformed point whose mapper throws routes through the same ledger
// as a fetch failure — record it, short-circuit this metric, and let the
// sibling metrics keep syncing.
imported += await handleCollectionFetchError(
resource.verb,
userId,
err,
);
forbidden = true;
break;
}
}
if (forbidden) continue;
+47 -15
View File
@@ -227,12 +227,34 @@ interface RollupDeferTracker {
}
const rollupDeferStorage = new AsyncLocalStorage<RollupDeferTracker>();
/**
* Per-cycle ledger of collection fetches that HARD-failed (non-403). A hard
* failure on one endpoint must not abort its siblings — weight 500ing must not
* suppress body-fat / spo2 / hrv / rhr / respiratory in the same resource — so
* `handleCollectionFetchError` records the failure here and RETURNS instead of
* rethrowing (the old pattern that aborted every endpoint ordered after the bad
* one and stalled the watermark). The orchestrator reads the ledger to keep the
* cycle's verdict honest: any entry ⇒ the run counts as failed, so success is
* NOT stamped and the next tick refetches the window. Ported from Google
* Health's `hardFailStorage`.
*/
interface HardFailTracker {
failures: string[];
}
const hardFailStorage = new AsyncLocalStorage<HardFailTracker>();
/**
* Single-source the per-resource collection-fetch error handling. A 403 on one
* data class soft-skips it (warn + return 0) so sibling resources still sync;
* anything else records a classified sync failure and rethrows. A soft-skip
* increments the ambient tracker so `syncUserFitbit` can refuse to stamp success
* on an all-403 grant-revoke cycle that imported nothing.
* data class soft-skips it (warn + return 0) so sibling resources still sync; a
* soft-skip increments the ambient tracker so `syncUserFitbit` can refuse to
* stamp success on an all-403 grant-revoke cycle that imported nothing.
*
* Anything else records a classified sync failure, notes the collection on the
* ambient hard-fail ledger, and RETURNS 0 — it does NOT rethrow. Every call site
* sits in a per-collection catch, so returning lets the sibling endpoints in the
* same resource keep fetching (one 500 on weight must not kill body-fat / spo2 /
* hrv / rhr / respiratory), while the ledger still fails the cycle so the
* watermark is not stamped past the broken collection.
*/
export async function handleCollectionFetchError(
resource: string,
@@ -248,7 +270,10 @@ export async function handleCollectionFetchError(
return 0;
}
await recordFitbitSyncFailure(userId, err);
throw err;
getEvent()?.addWarning(`fitbit ${resource} failed for ${userId}: ${err}`);
const hardTracker = hardFailStorage.getStore();
if (hardTracker) hardTracker.failures.push(resource);
return 0;
}
/**
@@ -640,22 +665,29 @@ export async function syncUserFitbit(
const tracker: SoftSkipTracker = { count: 0 };
const deferTracker: RollupDeferTracker = { keys: [] };
const hardFailTracker: HardFailTracker = { failures: [] };
let total = 0;
let anyFailed = false;
await softSkipStorage.run(tracker, async () => {
await rollupDeferStorage.run(deferTracker, async () => {
for (const fn of resources) {
try {
total += await fn(userId, resourceOpts);
} catch (err) {
anyFailed = true;
getEvent()?.addWarning(
`fitbit ${fn.name} failed for ${userId}: ${err}`,
);
await hardFailStorage.run(hardFailTracker, async () => {
await rollupDeferStorage.run(deferTracker, async () => {
for (const fn of resources) {
try {
total += await fn(userId, resourceOpts);
} catch (err) {
anyFailed = true;
getEvent()?.addWarning(
`fitbit ${fn.name} failed for ${userId}: ${err}`,
);
}
}
}
});
});
});
// A collection that hard-failed inside a resource (recorded on the ledger by
// `handleCollectionFetchError` without aborting its siblings) still fails the
// cycle: partial error, no watermark stamp.
if (hardFailTracker.failures.length > 0) anyFailed = true;
// On a `fullSync` backfill the per-write inline rollup hook was deferred; the
// accumulated touched type-days collapse into ONE range-recompute spanning the
@@ -0,0 +1,82 @@
/**
* F-SYNC-3 — the metric MAPPER runs inside a per-type catch, not just the
* fetch. A single malformed point whose `resource.map(point)` throws used to
* escape the METRIC_RESOURCES loop and skip every metric type ordered after it
* (body-fat, spo2, hrv, rhr, respiratory, glucose, temps), also blocking the
* watermark so the bad point refetched hourly and those types stayed dead.
*
* This pins the contract: a throwing mapper on the first type routes through
* `handleCollectionFetchError` and the loop continues to fetch every sibling.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fetchDataPointsMock, handleErrorMock, mapWeightMock } = vi.hoisted(
() => ({
fetchDataPointsMock: vi.fn(),
handleErrorMock: vi.fn(async () => 0),
mapWeightMock: vi.fn(() => {
throw new Error("malformed weight point");
}),
}),
);
vi.mock("../client", async (importOriginal) => {
const actual = await importOriginal<typeof import("../client")>();
return {
...actual,
fetchDataPoints: fetchDataPointsMock,
mapWeight: mapWeightMock,
};
});
vi.mock("../sync", () => ({
getValidToken: vi.fn(async () => ({
accessToken: "token",
connection: { id: "c1", googleUserId: "g1" },
})),
handleCollectionFetchError: handleErrorMock,
upsertGoogleHealthMeasurements: vi.fn(async () => ({
imported: 0,
touched: [],
})),
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
}));
import { syncUserMetrics } from "../sync-metrics";
beforeEach(() => {
handleErrorMock.mockClear();
mapWeightMock.mockClear();
// Weight returns one point (whose mapper throws); everything else is empty so
// its real mapper is never exercised.
fetchDataPointsMock
.mockReset()
.mockImplementation(async (_dt: unknown, _token: string, verb: string) =>
verb === "fetchWeight" ? [{ any: "point" }] : [],
);
});
describe("syncUserMetrics — a throwing mapper never skips sibling types", () => {
it("routes the map throw through the ledger and keeps fetching the rest", async () => {
await expect(syncUserMetrics("user-1")).resolves.toBe(0);
// The weight mapper threw and was routed exactly once.
expect(mapWeightMock).toHaveBeenCalled();
expect(handleErrorMock).toHaveBeenCalledWith(
"fetchWeight",
"user-1",
expect.any(Error),
);
// Every metric type ordered AFTER weight still fetched — the loop was not
// aborted by the throw.
const verbs = fetchDataPointsMock.mock.calls.map((c) => c[2]);
expect(verbs).toContain("fetchBodyFat");
expect(verbs).toContain("fetchSpo2");
expect(verbs).toContain("fetchRespiratoryRate");
expect(verbs).toContain("fetchHeartRate");
});
});
+20 -9
View File
@@ -146,17 +146,28 @@ export async function syncUserMetrics(
continue;
}
// The mapper runs INSIDE a per-type catch too: a single malformed point
// whose `resource.map(point)` throws must not escape the METRIC_RESOURCES
// loop and skip every metric type ordered after it (which also blocked the
// watermark, so the bad point refetched hourly and those types stayed dead).
// Route a map throw through the same ledger as a fetch failure — record it,
// fail the cycle, and move on to the next type.
const readings: GoogleHealthMeasurementUpsert[] = [];
for (const point of points) {
for (const m of resource.map(point)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: m.fieldTag,
});
try {
for (const point of points) {
for (const m of resource.map(point)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: m.fieldTag,
});
}
}
} catch (err) {
imported += await handleCollectionFetchError(resource.verb, userId, err);
continue;
}
imported += (
await upsertGoogleHealthMeasurements(userId, readings, {
+9 -1
View File
@@ -10,6 +10,7 @@
* Jackson/WURSS intensity (NULL = a plain presence link).
*/
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { getEvent } from "@/lib/logging/context";
import type {
IllnessEpisode,
IllnessDayLog,
@@ -51,7 +52,14 @@ function decryptNote(noteEncrypted: Uint8Array | null): string | null {
if (!noteEncrypted || noteEncrypted.byteLength === 0) return null;
try {
return decryptFromBytes(noteEncrypted);
} catch {
} catch (err) {
// Undecryptable note (key gap / corruption): fail soft to null but log it
// (F-CRYPTO-2) so a systemic key gap surfaces instead of reading as blank.
getEvent()?.addWarning(
`illness note decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
@@ -0,0 +1,82 @@
/**
* F-SYNC-1 — per-metric-type last-value freshness.
*
* Pins that the helper distinguishes a silently-dead metric (its newest reading
* frozen in the past) from a genuinely-current one, keyed by integration, from
* one grouped read over the live Measurement rows — the honest signal the
* per-integration "connected · 5 min ago" pill cannot carry.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const groupByMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/db", () => ({
prisma: { measurement: { groupBy: groupByMock } },
}));
import {
getSourceMetricFreshness,
INTEGRATION_MEASUREMENT_SOURCE,
} from "../metric-freshness";
beforeEach(() => groupByMock.mockReset());
describe("getSourceMetricFreshness", () => {
it("maps grouped source/type rows to per-integration last-seen entries", async () => {
groupByMock.mockResolvedValue([
{
source: "OURA",
type: "RESPIRATORY_RATE",
_max: { measuredAt: new Date("2026-01-01T00:00:00.000Z") },
},
{
source: "OURA",
type: "RECOVERY_SCORE",
_max: { measuredAt: new Date("2026-07-07T00:00:00.000Z") },
},
{
source: "WITHINGS",
type: "WEIGHT",
_max: { measuredAt: new Date("2026-07-06T00:00:00.000Z") },
},
]);
const result = await getSourceMetricFreshness("u1");
// The dead metric (respiratory rate, frozen in January) is visible as a
// distinct, older timestamp next to the current recovery score — exactly
// the "broken pipe vs healthy-idle" distinction the pill can't express.
expect(result.oura).toEqual([
{ type: "RECOVERY_SCORE", lastSeenAt: "2026-07-07T00:00:00.000Z" },
{ type: "RESPIRATORY_RATE", lastSeenAt: "2026-01-01T00:00:00.000Z" },
]);
expect(result.withings).toEqual([
{ type: "WEIGHT", lastSeenAt: "2026-07-06T00:00:00.000Z" },
]);
// Only the sync sources are queried.
expect(groupByMock.mock.calls[0][0].where.source.in).toEqual(
Object.values(INTEGRATION_MEASUREMENT_SOURCE),
);
expect(groupByMock.mock.calls[0][0].where.deletedAt).toBeNull();
});
it("skips rows with no reading and unmapped sources", async () => {
groupByMock.mockResolvedValue([
{ source: "OURA", type: "VO2_MAX", _max: { measuredAt: null } },
{
source: "MANUAL",
type: "WEIGHT",
_max: { measuredAt: new Date("2026-07-01T00:00:00.000Z") },
},
]);
const result = await getSourceMetricFreshness("u1");
// A null max (no rows) yields no entry; MANUAL isn't a sync integration.
expect(result.oura).toBeUndefined();
expect(Object.keys(result)).toHaveLength(0);
});
it("returns an empty map when the user has no synced measurements", async () => {
groupByMock.mockResolvedValue([]);
expect(await getSourceMetricFreshness("u1")).toEqual({});
});
});
+90
View File
@@ -0,0 +1,90 @@
/**
* F-SYNC-1 — per-metric-type freshness (the smallest honest signal).
*
* Freshness was tracked only per-integration (`IntegrationStatus.lastSuccessAt`
* / a connection's `lastSyncedAt`). A provider that returns HTTP 200 with an
* empty body for ONE data type produces zero rows, throws nothing, and the
* other types in the same cycle stamp the whole integration green — so the
* Settings pill reads "connected · 5 min ago" while, say, respiratory rate has
* been dead since launch, and nothing distinguishes "broken pipe" from "healthy
* but idle".
*
* This computes the last-value timestamp per `(source, type)` straight from the
* `Measurement` table (no schema change, no migration) so a caller can surface
* per-metric liveness honestly: a metric whose newest reading is frozen weeks in
* the past is visibly distinct from one that is genuinely current. The coarser
* "is this stale for THIS metric's expected cadence" classification (per-type
* thresholds / an expected-cadence probe cron) is the deferred fuller model;
* this exposes the raw honest timestamp the UI renders as "last seen …".
*/
import { prisma } from "@/lib/db";
import type { MeasurementSource } from "@/generated/prisma/client";
import type { IntegrationKey } from "./status";
/**
* The `MeasurementSource` each sync integration's rows carry. `moodlog` is
* absent — it writes `MoodEntry` rows, not `Measurement` rows, so it has no
* per-metric measurement freshness.
*/
export const INTEGRATION_MEASUREMENT_SOURCE: Partial<
Record<IntegrationKey, MeasurementSource>
> = {
withings: "WITHINGS",
whoop: "WHOOP",
fitbit: "FITBIT",
nightscout: "NIGHTSCOUT",
polar: "POLAR",
oura: "OURA",
"google-health": "GOOGLE_HEALTH",
};
/** Newest recorded reading for one `(source, type)` pair. */
export interface MetricFreshnessEntry {
/** The `MeasurementType` (e.g. "RESPIRATORY_RATE"). */
type: string;
/** ISO timestamp of the newest (non-deleted) reading for this metric. */
lastSeenAt: string;
}
/**
* Compute per-`(integration, type)` last-value timestamps for the sync
* integrations, keyed by `IntegrationKey`. One grouped query over the live
* `Measurement` rows; a source with no rows simply has no entry. Types are
* sorted alphabetically for a stable wire shape.
*/
export async function getSourceMetricFreshness(
userId: string,
): Promise<Partial<Record<IntegrationKey, MetricFreshnessEntry[]>>> {
const sources = Object.values(INTEGRATION_MEASUREMENT_SOURCE);
const grouped = await prisma.measurement.groupBy({
by: ["source", "type"],
where: { userId, deletedAt: null, source: { in: sources } },
_max: { measuredAt: true },
});
// Invert the source→key map once so each grouped row resolves its integration.
const sourceToKey = new Map<MeasurementSource, IntegrationKey>(
(
Object.entries(INTEGRATION_MEASUREMENT_SOURCE) as Array<
[IntegrationKey, MeasurementSource]
>
).map(([key, source]) => [source, key]),
);
const out: Partial<Record<IntegrationKey, MetricFreshnessEntry[]>> = {};
for (const row of grouped) {
const lastSeen = row._max.measuredAt;
if (!lastSeen) continue;
const key = sourceToKey.get(row.source);
if (!key) continue;
(out[key] ??= []).push({
type: row.type,
lastSeenAt: lastSeen.toISOString(),
});
}
for (const key of Object.keys(out) as IntegrationKey[]) {
out[key]!.sort((a, b) => a.type.localeCompare(b.type));
}
return out;
}
+8 -1
View File
@@ -26,7 +26,7 @@
*/
import { unlinkSync } from "node:fs";
import { PrismaClient } from "@/generated/prisma/client";
import { toJson } from "@/lib/db";
import { buildSessionOptions, getPoolMax, toJson } from "@/lib/db";
import { PrismaPg } from "@prisma/adapter-pg";
import type { Job } from "pg-boss";
@@ -61,8 +61,15 @@ export interface AppleHealthImportPayload {
let workerPrismaSingleton: PrismaClient | null = null;
function getWorkerPrisma(): PrismaClient {
if (!workerPrismaSingleton) {
// Inherit the web client's pool ceiling + per-session statement timeouts
// (`src/lib/db.ts`). The timeout is per-statement, not per-job, so the
// long-running bulk import stays safe while a single wedged UPSERT can no
// longer pin a worker connection indefinitely.
const sessionOptions = buildSessionOptions();
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
max: getPoolMax(),
...(sessionOptions ? { options: sessionOptions } : {}),
});
workerPrismaSingleton = new PrismaClient({ adapter });
}
@@ -0,0 +1,107 @@
/**
* F-DB-1 — the worker Prisma client must inherit the same pool ceiling +
* session timeouts as the web client (`src/lib/db.ts`).
*
* Before this fix the worker adapter was built with a bare
* `{ connectionString }`, so a pathological nightly drain on a heavy tenant
* could run unbounded, pin a connection indefinitely, and wedge the shared
* worker pool. This test pins the contract: `getWorkerPrisma()` constructs its
* `PrismaPg` adapter with `max` from `getPoolMax()` and the `options` startup
* string from `buildSessionOptions()`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const adapterCtor = vi.fn();
const clientCtor = vi.fn();
vi.mock("@prisma/adapter-pg", () => ({
PrismaPg: class {
constructor(...args: unknown[]) {
adapterCtor(...args);
}
},
}));
vi.mock("@/generated/prisma/client", () => ({
PrismaClient: class {
constructor(...args: unknown[]) {
clientCtor(...args);
}
},
}));
describe("getWorkerPrisma", () => {
beforeEach(() => {
vi.resetModules();
adapterCtor.mockClear();
clientCtor.mockClear();
process.env.DATABASE_URL = "postgres://user:pass@localhost:5432/db";
delete process.env.DATABASE_POOL_MAX;
delete process.env.DATABASE_STATEMENT_TIMEOUT_MS;
});
afterEach(() => {
delete process.env.DATABASE_POOL_MAX;
delete process.env.DATABASE_STATEMENT_TIMEOUT_MS;
});
it("builds the adapter with a pool cap and session timeouts", async () => {
const { getWorkerPrisma } = await import("../shared");
// Importing ../shared pulls in src/lib/db.ts, which constructs the web
// client's adapter at module load. Clear so we count only the worker's.
adapterCtor.mockClear();
clientCtor.mockClear();
getWorkerPrisma();
expect(adapterCtor).toHaveBeenCalledTimes(1);
const config = adapterCtor.mock.calls[0][0] as {
connectionString: string;
max: number;
options?: string;
};
expect(config.connectionString).toBe(
"postgres://user:pass@localhost:5432/db",
);
// Default pool ceiling — must not fall back to the pg library default of 10.
expect(config.max).toBe(20);
// Both session timeouts applied at connection establishment.
expect(config.options).toContain("-c statement_timeout=60000");
expect(config.options).toContain(
"-c idle_in_transaction_session_timeout=60000",
);
});
it("honours the shared env knobs", async () => {
process.env.DATABASE_POOL_MAX = "12";
process.env.DATABASE_STATEMENT_TIMEOUT_MS = "30000";
const { getWorkerPrisma } = await import("../shared");
adapterCtor.mockClear();
getWorkerPrisma();
const config = adapterCtor.mock.calls[0][0] as {
max: number;
options?: string;
};
expect(config.max).toBe(12);
expect(config.options).toContain("-c statement_timeout=30000");
});
it("omits the options string when the timeout is disabled", async () => {
process.env.DATABASE_STATEMENT_TIMEOUT_MS = "0";
const { getWorkerPrisma } = await import("../shared");
adapterCtor.mockClear();
getWorkerPrisma();
const config = adapterCtor.mock.calls[0][0] as { options?: string };
expect(config.options).toBeUndefined();
});
it("reuses a single client across calls", async () => {
const { getWorkerPrisma } = await import("../shared");
clientCtor.mockClear();
const a = getWorkerPrisma();
const b = getWorkerPrisma();
expect(a).toBe(b);
expect(clientCtor).toHaveBeenCalledTimes(1);
});
});
+17 -1
View File
@@ -6,6 +6,7 @@
*/
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { buildSessionOptions, getPoolMax } from "@/lib/db";
export function parseTimeToMinutes(value: string): number {
const [h, m] = value.split(":").map(Number);
@@ -36,7 +37,22 @@ let workerPrisma: PrismaClient | null = null;
export function getWorkerPrisma(): PrismaClient {
if (!workerPrisma) {
const adapter = new PrismaPg({ connectionString: DATABASE_URL });
// Apply the same pool ceiling + session timeouts the web client uses
// (`src/lib/db.ts`). Without them the worker client ran unbounded: a
// pathological nightly drain on a heavy tenant could pin a connection
// indefinitely and wedge the shared worker pool. `getPoolMax()` caps the
// slot count; `buildSessionOptions()` applies `statement_timeout` +
// `idle_in_transaction_session_timeout` at connection establishment so
// every worker session is timeout-bounded from its first query. Both share
// the same `DATABASE_POOL_MAX` / `DATABASE_STATEMENT_TIMEOUT_MS` env knobs
// as the web tier, and the web + worker containers share one Postgres so a
// single ceiling covers both pools.
const sessionOptions = buildSessionOptions();
const adapter = new PrismaPg({
connectionString: DATABASE_URL,
max: getPoolMax(),
...(sessionOptions ? { options: sessionOptions } : {}),
});
workerPrisma = new PrismaClient({ adapter });
}
return workerPrisma;
+10 -1
View File
@@ -13,6 +13,7 @@ import { Buffer } from "node:buffer";
import { decrypt, encrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
/** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */
export function encryptContextToBytes(
@@ -40,7 +41,15 @@ export function decryptContextSoft(buf: Uint8Array | null): string | null {
if (!buf) return null;
try {
return decryptContextFromBytes(buf);
} catch {
} catch (err) {
// Undecryptable context (key gap / corruption): fail soft to null so one
// bad row never 500s the catalog list, but log it (F-CRYPTO-2) so a
// systemic key gap surfaces instead of masking as an empty context.
getEvent()?.addWarning(
`biomarker context decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
@@ -158,3 +158,56 @@ describe("readDailySeries — long-range tier step-up", () => {
expect(mocks.findMany).toHaveBeenCalled();
});
});
describe("readDailySeries — rollup read throw falls back to live SQL (F-DB-2)", () => {
it("a rollup-table throw on a normal window degrades to the live date_trunc aggregate, not a 500", async () => {
const to = new Date("2026-06-21T00:00:00.000Z");
const from = new Date(to.getTime() - 90 * DAY_MS);
// The primary rollup read throws (statement_timeout / deadlock / reset).
mocks.findMany
.mockReset()
.mockRejectedValue(new Error("statement timeout"));
// Live fallback serves the tile.
mocks.queryRaw.mockResolvedValueOnce([
{
type: "WEIGHT",
bucket_start: new Date("2026-06-10T00:00:00.000Z"),
avg: 80.5,
cnt: 2,
},
]);
const result = await readDailySeries({
userId: "u",
type: "WEIGHT",
from,
to,
priorityJson: null,
});
// The read did NOT throw — it fell through to live SQL and served a row.
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ type: "WEIGHT", value: 80.5, count: 2 });
expect(mocks.queryRaw).toHaveBeenCalled();
});
it("a tiered-reader throw on a long window falls through to the daily path", async () => {
const to = new Date("2026-06-21T00:00:00.000Z");
const from = new Date(to.getTime() - 3650 * DAY_MS);
mocks.readTieredRollupSeries.mockRejectedValueOnce(new Error("deadlock"));
const result = await readDailySeries({
userId: "u",
type: "WEIGHT",
from,
to,
priorityJson: null,
});
// The daily DAY-rollup read still ran as the fallback (the default findMany
// returns 3 rows), so the throw did not 500 the read.
expect(mocks.findMany).toHaveBeenCalled();
expect(result).toHaveLength(3);
});
});
+45 -12
View File
@@ -76,17 +76,32 @@ export async function readDailySeries(opts: {
(to.getTime() - from.getTime()) / 86_400_000,
);
if (windowDaysFull > cap) {
const tiered = await readTieredRollupSeries({
userId,
type,
// v1.26.0 SEAM-N2 — pass the resolved `[from, to]` bounds so the tier
// reads the REQUESTED window (which may be entirely historic), not a
// trailing "to now" slice. `windowDaysFull` still gates entry above and
// drives the tier width inside the reader.
from,
to,
priorityJson,
});
// A rollup-table throw here (statement_timeout, deadlock, connection reset)
// must not 500 the whole read — treat it as a coverage miss and fall
// through to the daily path (which itself falls back to live SQL).
let tiered: Awaited<ReturnType<typeof readTieredRollupSeries>> = null;
try {
tiered = await readTieredRollupSeries({
userId,
type,
// v1.26.0 SEAM-N2 — pass the resolved `[from, to]` bounds so the tier
// reads the REQUESTED window (which may be entirely historic), not a
// trailing "to now" slice. `windowDaysFull` still gates entry above and
// drives the tier width inside the reader.
from,
to,
priorityJson,
});
} catch (err) {
annotate({
meta: {
tiered_series_read_threw: true,
tiered_series_read_error:
err instanceof Error ? err.message : String(err),
type,
},
});
}
if (tiered && tiered.rows.length > 0) {
return tiered.rows;
}
@@ -129,7 +144,25 @@ export async function readDailySeries(opts: {
priorityJson,
).slice(0, cap);
const rollupRows = await readRollup();
// F-DB-2 — the primary rollup read is wrapped: a transient rollup-table error
// (statement_timeout, deadlock, connection reset) must NOT 500 the tile when
// the live table can still serve it. Treat a throw as a coverage miss and fall
// straight through to the live `date_trunc` aggregate — the same posture the
// batched series reader already takes, so the single-type route and the batch
// route no longer diverge on a rollup hiccup.
let rollupRows: Awaited<ReturnType<typeof readRollup>>;
try {
rollupRows = await readRollup();
} catch (err) {
annotate({
meta: {
rollup_read_threw: true,
rollup_read_error: err instanceof Error ? err.message : String(err),
type,
},
});
return readLiveDaily({ userId, type, from, to, cap, priorityJson });
}
// Coverage-mismatch probe + inline fold (identical to the route).
const windowMs = to.getTime() - from.getTime();
@@ -0,0 +1,49 @@
/**
* F-CRYPTO-5 — an undecryptable custom mood label fails soft (the caller falls
* back to the generic i18n label) but must NOT be entirely silent: a swallowed
* decrypt now emits a wide-event so the loss of a user-renamed tag surfaces.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { decryptMock, addWarningMock } = vi.hoisted(() => ({
decryptMock: vi.fn(),
addWarningMock: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: decryptMock,
encrypt: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ addWarning: addWarningMock }),
}));
import { decryptCustomLabel } from "../custom-tags";
beforeEach(() => {
decryptMock.mockReset();
addWarningMock.mockReset();
});
describe("decryptCustomLabel", () => {
it("returns null without logging for a genuinely-unset label", () => {
expect(decryptCustomLabel(null)).toBeNull();
expect(addWarningMock).not.toHaveBeenCalled();
});
it("returns the decrypted label on success", () => {
decryptMock.mockReturnValue("Morning walk");
expect(decryptCustomLabel("cipher")).toBe("Morning walk");
expect(addWarningMock).not.toHaveBeenCalled();
});
it("fails soft to null AND logs a wide-event when the decrypt throws", () => {
decryptMock.mockImplementation(() => {
throw new Error("bad key");
});
expect(decryptCustomLabel("cipher")).toBeNull();
expect(addWarningMock).toHaveBeenCalledWith(
expect.stringContaining("custom-label decrypt failed"),
);
});
});
+10 -1
View File
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
import { z } from "zod/v4";
import { encrypt, decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
import { MOOD_TAG_ICON_NAMES } from "@/lib/mood/icon-catalog";
/**
@@ -77,7 +78,15 @@ export function decryptCustomLabel(encoded: string | null): string | null {
if (!encoded) return null;
try {
return decrypt(encoded);
} catch {
} catch (err) {
// A custom label WAS stored but is undecryptable — fail soft (the caller
// falls back to the generic i18n label) but log it (F-CRYPTO-5) so the loss
// of a user-renamed tag is not entirely silent.
getEvent()?.addWarning(
`mood custom-label decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
+73 -1
View File
@@ -338,7 +338,79 @@ describe("syncUserOura", () => {
reason: "http_403",
}),
);
await expect(syncUserOura("u1")).rejects.toBeInstanceOf(OuraApiError);
// A 403 is a per-collection hard failure now (isolated, not a whole-batch
// reject): it records a partial failure and does NOT trigger a refresh.
await syncUserOura("u1");
expect(refreshMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledWith(
expect.objectContaining({ integration: "oura" }),
);
expect(recordSuccessMock).not.toHaveBeenCalled();
});
it("isolates one bad collection — siblings still import (F-SYNC-2)", async () => {
getConnMock.mockResolvedValue(CONN);
// Readiness endpoint is flaky (500), but sleep-score + spo2 return data.
fetchReadinessMock.mockRejectedValue(
new OuraApiError({
verb: "fetchReadiness",
classification: "transient",
httpStatus: 500,
reason: "http_500",
}),
);
fetchDailySleepMock.mockResolvedValue([
{ id: "s", day: "2026-06-10", score: 80 },
]);
fetchSpo2Mock.mockResolvedValue([
{ id: "o", day: "2026-06-10", spo2_percentage: { average: 97 } },
]);
const imported = await syncUserOura("u1");
// The healthy collections still wrote their rows — the source is not blanked.
expect(imported).toBe(2);
const written = upsertMock.mock.calls.map(
(c) => c[0].where.userId_type_source_externalId.type,
);
expect(written).toContain("SLEEP_SCORE");
expect(written).toContain("OXYGEN_SATURATION");
// The cycle is still marked failed (partial) so freshness stays honest.
expect(recordFailureMock).toHaveBeenCalledWith(
expect.objectContaining({ integration: "oura" }),
);
expect(recordSuccessMock).not.toHaveBeenCalled();
expect(refreshMock).not.toHaveBeenCalled();
});
it("isolates a throwing mapper — one malformed record does not blank siblings (F-SYNC-2)", async () => {
getConnMock.mockResolvedValue(CONN);
// A malformed readiness record whose mapper throws must not reject the
// whole batch (the old Promise.all failure mode).
fetchReadinessMock.mockReturnValue(
Promise.resolve([
new Proxy(
{},
{
get() {
throw new Error("malformed readiness point");
},
},
),
]),
);
fetchSpo2Mock.mockResolvedValue([
{ id: "o", day: "2026-06-10", spo2_percentage: { average: 96 } },
]);
const imported = await syncUserOura("u1");
expect(imported).toBe(1);
const written = upsertMock.mock.calls.map(
(c) => c[0].where.userId_type_source_externalId.type,
);
expect(written).toContain("OXYGEN_SATURATION");
expect(recordFailureMock).toHaveBeenCalled();
expect(recordSuccessMock).not.toHaveBeenCalled();
});
});
+188 -65
View File
@@ -114,11 +114,42 @@ function toUpsert(
}));
}
/** One Oura collection: its ledger name and a self-contained fetch+map closure
* that resolves to the collection's mapped upserts. Each closure owns its own
* client call and mapper so a throw is contained to this one collection. */
interface OuraCollectionSpec {
name: string;
collect: (token: string) => Promise<OuraMeasurementUpsert[]>;
}
/** A single collection that failed to fetch/map, carried so the caller can
* classify + record the failure without blanking the collections that did
* succeed. */
export interface OuraCollectionFailure {
name: string;
err: unknown;
}
export interface OuraFetchResult {
readings: OuraMeasurementUpsert[];
failures: OuraCollectionFailure[];
}
/**
* Fetch every Oura daily collection for a user with a single reactive
* refresh-on-401 retry. Returns the raw mapped readings (not yet upserted).
* Throws a classified `OuraApiError` on a hard failure so the caller records
* the ledger entry.
* refresh-on-401 retry, ISOLATED per collection. Unlike a bare `Promise.all`,
* one flaky endpoint or one throwing mapper no longer rejects the whole batch
* (which used to blank readiness, sleep, activity, spo2 all at once). Each
* collection is settled independently (`Promise.allSettled`); the ones that
* succeed import, the ones that throw are returned as `failures` for the caller
* to record — mirroring Google Health's / Fitbit's per-collection hard-fail
* ledger.
*
* The reactive refresh is preserved: because every collection shares one access
* token, an expired token 401s all of them together; a single refresh is done
* once and ONLY the failed collections are retried with the rotated token. A
* failed refresh (`invalid_grant`) still throws so the caller parks the whole
* connection at `reauth_required`.
*/
async function fetchAll(
userId: string,
@@ -126,72 +157,132 @@ async function fetchAll(
refreshToken: string,
refreshTokenCiphertext: string,
lookbackDays: number,
): Promise<OuraMeasurementUpsert[]> {
): Promise<OuraFetchResult> {
const now = new Date();
const start = new Date(now.getTime() - lookbackDays * 24 * 60 * 60 * 1000);
const query = { startDate: ymd(start), endDate: ymd(now) };
const run = async (token: string): Promise<OuraMeasurementUpsert[]> => {
const [
readiness,
sleeps,
activities,
dailySleep,
spo2,
vo2max,
cardioAge,
resilience,
] = await Promise.all([
fetchReadiness(token, query),
fetchSleep(token, query),
fetchDailyActivity(token, query),
fetchDailySleep(token, query),
fetchDailySpo2(token, query),
fetchVo2Max(token, query),
fetchCardiovascularAge(token, query),
fetchResilience(token, query),
]);
const out: OuraMeasurementUpsert[] = [];
for (const r of readiness)
out.push(...toUpsert(mapReadiness(r), "readiness"));
for (const s of sleeps) out.push(...toUpsert(mapSleep(s), "sleep"));
for (const a of activities)
out.push(...toUpsert(mapDailyActivity(a), "activity"));
for (const d of dailySleep)
out.push(...toUpsert(mapDailySleep(d), "daily_sleep"));
for (const s of spo2) out.push(...toUpsert(mapDailySpo2(s), "spo2"));
for (const v of vo2max) out.push(...toUpsert(mapVo2Max(v), "vo2max"));
for (const c of cardioAge)
out.push(...toUpsert(mapCardiovascularAge(c), "cardio_age"));
for (const r of resilience)
out.push(...toUpsert(mapResilience(r), "resilience"));
return out;
const collections: OuraCollectionSpec[] = [
{
name: "readiness",
collect: async (t) =>
(await fetchReadiness(t, query)).flatMap((r) =>
toUpsert(mapReadiness(r), "readiness"),
),
},
{
name: "sleep",
collect: async (t) =>
(await fetchSleep(t, query)).flatMap((s) =>
toUpsert(mapSleep(s), "sleep"),
),
},
{
name: "activity",
collect: async (t) =>
(await fetchDailyActivity(t, query)).flatMap((a) =>
toUpsert(mapDailyActivity(a), "activity"),
),
},
{
name: "daily_sleep",
collect: async (t) =>
(await fetchDailySleep(t, query)).flatMap((d) =>
toUpsert(mapDailySleep(d), "daily_sleep"),
),
},
{
name: "spo2",
collect: async (t) =>
(await fetchDailySpo2(t, query)).flatMap((s) =>
toUpsert(mapDailySpo2(s), "spo2"),
),
},
{
name: "vo2max",
collect: async (t) =>
(await fetchVo2Max(t, query)).flatMap((v) =>
toUpsert(mapVo2Max(v), "vo2max"),
),
},
{
name: "cardio_age",
collect: async (t) =>
(await fetchCardiovascularAge(t, query)).flatMap((c) =>
toUpsert(mapCardiovascularAge(c), "cardio_age"),
),
},
{
name: "resilience",
collect: async (t) =>
(await fetchResilience(t, query)).flatMap((r) =>
toUpsert(mapResilience(r), "resilience"),
),
},
];
// Run a set of collections isolated from one another. A rejection is captured
// per-collection, never propagated, so siblings still resolve.
const attempt = async (
token: string,
specs: OuraCollectionSpec[],
): Promise<{
readings: OuraMeasurementUpsert[];
failed: OuraCollectionSpec[];
failures: OuraCollectionFailure[];
auth401: boolean;
}> => {
const settled = await Promise.allSettled(
specs.map((spec) => spec.collect(token)),
);
const readings: OuraMeasurementUpsert[] = [];
const failed: OuraCollectionSpec[] = [];
const failures: OuraCollectionFailure[] = [];
let auth401 = false;
settled.forEach((res, i) => {
const spec = specs[i]!;
if (res.status === "fulfilled") {
readings.push(...res.value);
} else {
const err = res.reason;
if (err instanceof OuraApiError && err.httpStatus === 401)
auth401 = true;
failed.push(spec);
failures.push({ name: spec.name, err });
}
});
return { readings, failed, failures, auth401 };
};
try {
return await run(accessToken);
} catch (err) {
// Reactive refresh: a 401 means the access token expired. Refresh once
// (rotating both tokens) and retry. A 403 / other error is NOT a refresh
// case — rethrow so the caller classifies it.
if (!(err instanceof OuraApiError) || err.httpStatus !== 401) throw err;
const creds = await getOuraClientCredentials(userId);
if (!creds) throw err;
const rotated = await refreshAccessToken(refreshToken, creds);
// Compare-and-swap persist: on a lost race against a concurrent sync this
// returns the peer's freshly rotated access token rather than the (now
// invalidated) one we just minted, so neither sync parks the connection.
const usableToken = await storeOuraTokens(
userId,
rotated.access_token,
rotated.refresh_token,
refreshTokenCiphertext,
);
if (!usableToken) throw err;
return run(usableToken);
const first = await attempt(accessToken, collections);
if (!first.auth401) {
return { readings: first.readings, failures: first.failures };
}
// Reactive refresh: the access token expired (a 401 on the shared token).
// Refresh once (rotating both tokens) and retry ONLY the collections that
// failed. A failed refresh throws so the caller parks reauth_required.
const creds = await getOuraClientCredentials(userId);
if (!creds) return { readings: first.readings, failures: first.failures };
const rotated = await refreshAccessToken(refreshToken, creds);
// Compare-and-swap persist: on a lost race against a concurrent sync this
// returns the peer's freshly rotated access token rather than the (now
// invalidated) one we just minted, so neither sync parks the connection.
const usableToken = await storeOuraTokens(
userId,
rotated.access_token,
rotated.refresh_token,
refreshTokenCiphertext,
);
if (!usableToken)
return { readings: first.readings, failures: first.failures };
const retry = await attempt(usableToken, first.failed);
return {
readings: [...first.readings, ...retry.readings],
failures: retry.failures,
};
}
/**
@@ -206,9 +297,9 @@ export async function syncUserOura(
const conn = await getOuraConnection(userId);
if (!conn) return 0;
let readings: OuraMeasurementUpsert[];
let result: OuraFetchResult;
try {
readings = await fetchAll(
result = await fetchAll(
userId,
conn.accessToken,
conn.refreshToken,
@@ -216,6 +307,8 @@ export async function syncUserOura(
opts.lookbackDays ?? OURA_SYNC_LOOKBACK_DAYS,
);
} catch (err) {
// Only a whole-connection failure (a failed token refresh → reauth) reaches
// here now; per-collection failures are captured on `result.failures`.
await recordSyncFailure({
userId,
integration: "oura",
@@ -229,7 +322,37 @@ export async function syncUserOura(
throw err;
}
const imported = await upsertOuraMeasurements(userId, readings);
// Import everything the healthy collections returned regardless of whether a
// sibling collection failed — one bad collection must not blank the source.
const imported = await upsertOuraMeasurements(userId, result.readings);
if (result.failures.length > 0) {
// Partial failure: keep the cycle honest. Record the failure and do NOT
// stamp success, so the freshness surface reflects that some collections
// are behind and the next tick refetches them, rather than showing green.
const firstErr = result.failures[0]!.err;
getEvent()?.addWarning(
`oura: ${result.failures.length} collection(s) failed for ${userId}: ${result.failures
.map((f) => f.name)
.join(", ")}`,
);
await recordSyncFailure({
userId,
integration: "oura",
kind: classifyOuraFailure(firstErr),
message: `partial sync failure (${result.failures
.map((f) => f.name)
.join(", ")}): ${
firstErr instanceof Error ? firstErr.message : String(firstErr)
}`,
errorCode:
firstErr instanceof OuraApiError && firstErr.httpStatus != null
? String(firstErr.httpStatus)
: undefined,
});
return imported;
}
await recordSyncSuccess(userId, "oura");
return imported;
}
@@ -0,0 +1,67 @@
/**
* F-OFF-1 — offline mutations must FAIL fast (surfacing state) rather than
* pause forever and lose the write on reload.
*
* The library default network mode (`online`) pauses a mutation fired offline:
* it never rejects, `onError` never fires, and the paused mutation is dropped on
* reload — a silent health-data loss. This pins that the app config sets
* `networkMode: "always"` on mutations AND that, under that config, an offline
* mutation actually reaches the `error` state instead of `pending`/paused.
*/
import { afterEach, describe, expect, it } from "vitest";
import {
QueryClient,
MutationObserver,
onlineManager,
} from "@tanstack/react-query";
import { QUERY_CLIENT_DEFAULT_OPTIONS } from "../query-client-options";
afterEach(() => {
// Restore the online flag for any following test in the same worker.
onlineManager.setOnline(true);
});
describe("QUERY_CLIENT_DEFAULT_OPTIONS", () => {
it("runs mutations in 'always' network mode", () => {
expect(QUERY_CLIENT_DEFAULT_OPTIONS.mutations?.networkMode).toBe("always");
});
it("an offline mutation errors instead of pausing (no silent write loss)", async () => {
const client = new QueryClient({
defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS,
});
// Simulate the browser going offline.
onlineManager.setOnline(false);
const observer = new MutationObserver(client, {
mutationFn: async () => {
// A real fetch would reject with a TypeError while offline.
throw new Error("Failed to fetch");
},
retry: 0,
});
let settled: "error" | "paused-timeout" = "paused-timeout";
await Promise.race([
observer
.mutate()
.then(() => {})
.catch(() => {
settled = "error";
}),
// If the mutation paused (the old default), it would never settle — cap
// the wait so the test fails as a timeout rather than hanging.
new Promise((resolve) => setTimeout(resolve, 200)),
]);
// In `always` mode the mutation ran and rejected — an honest error the UI
// can surface, not a paused mutation lost on reload.
expect(settled).toBe("error");
expect(observer.getCurrentResult().status).toBe("error");
expect(observer.getCurrentResult().isPaused).toBe(false);
client.clear();
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { DefaultOptions } from "@tanstack/react-query";
/**
* Shared TanStack Query default options for the app's single `QueryClient`.
* Extracted from `providers.tsx` so the offline-mutation contract (F-OFF-1) is
* unit-testable without importing the whole client-component tree.
*
* Mutations run in `always` network mode. The library default (`online`) PAUSES
* a mutation fired offline — `isPending` stays true forever, `onError` never
* fires, and the paused mutation is silently DROPPED on reload, so a health
* entry vanishes with no "not saved" signal. In `always` mode the mutation runs
* regardless, the offline `fetch` rejects immediately, and `onError` fires — an
* honest failure the form surfaces (and the global `OfflineMutationToaster`
* backstops), instead of a spinner-of-death plus silent write loss.
*/
export const QUERY_CLIENT_DEFAULT_OPTIONS: DefaultOptions = {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
},
mutations: {
networkMode: "always",
},
};
+9 -1
View File
@@ -8,6 +8,7 @@
* never 500s the whole page) — the IllnessEpisode DTO precedent.
*/
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { getEvent } from "@/lib/logging/context";
import type { Allergy, FamilyHistoryEntry } from "@/generated/prisma/client";
export interface AllergyDTO {
@@ -39,7 +40,14 @@ function decryptOptional(buf: Uint8Array | null): string | null {
if (!buf || buf.byteLength === 0) return null;
try {
return decryptFromBytes(buf);
} catch {
} catch (err) {
// Undecryptable payload (key gap / corruption): fail soft to null but log
// it (F-CRYPTO-2) so a systemic key gap surfaces instead of reading blank.
getEvent()?.addWarning(
`records field decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
+17 -1
View File
@@ -1,5 +1,21 @@
import { afterEach, describe, expect, it } from "vitest";
import { getWithingsWebhookCallbackUrl } from "../sync";
import {
getWithingsWebhookCallbackUrl,
WITHINGS_INCREMENTAL_OVERLAP_MS,
} from "../sync";
/**
* F-SYNC-5 — the incremental overlap must stay comfortably wider than the old
* 60s so a backdated Withings reading landing just before the next cycle's
* `now()` is not missed (compounded by `lastSyncedAt` advancing on a healthy
* 200-with-0 cycle). The upserts are idempotent, so a wider overlap is safe.
*/
describe("WITHINGS_INCREMENTAL_OVERLAP_MS", () => {
it("is at least a few minutes (not the old 60s)", () => {
expect(WITHINGS_INCREMENTAL_OVERLAP_MS).toBeGreaterThanOrEqual(5 * 60_000);
expect(WITHINGS_INCREMENTAL_OVERLAP_MS).toBeGreaterThan(60_000);
});
});
/**
* v1.4.25 W17a — Withings webhook callback URL must put the shared
+17 -1
View File
@@ -131,6 +131,20 @@ export async function getValidToken(userId: string): Promise<{
};
}
/**
* Incremental overlap window (ms). Withings backdates some readings (a scale
* step syncs to the cloud minutes after the reading's timestamp), and
* `lastSyncedAt` advances on every success — including a healthy 200-with-0
* cycle (F-SYNC-1). A too-tight overlap let a reading that landed just before
* the next cycle's `now()` slip through the gap. 10 minutes is comfortably wider
* than the observed backdating skew while staying far short of a full re-scan;
* the upserts are idempotent, so a wider overlap only re-touches a handful of
* rows. (Still narrower than Google's 24h / Oura's 7-day because Withings is
* webhook-primary — this overlap is the poll-catch-up safety net, not the main
* path.)
*/
export const WITHINGS_INCREMENTAL_OVERLAP_MS = 10 * 60 * 1000;
/**
* Sync measurements from Withings for a given user.
* Fetches data since last sync (or last 30 days if first sync).
@@ -172,7 +186,9 @@ export async function syncUserMeasurements(
const startDate = opts.fullSync
? undefined
: connection.lastSyncedAt
? new Date(connection.lastSyncedAt.getTime() - 60 * 1000) // overlap 1 min to avoid gaps
? new Date(
connection.lastSyncedAt.getTime() - WITHINGS_INCREMENTAL_OVERLAP_MS,
)
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days back
let measures: Awaited<ReturnType<typeof fetchMeasurements>>;