mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge branch 'fix/resilience' into release/v1.27.30
This commit is contained in:
commit
20275d19e0
40 files changed
+1452
-162
No files matched your search
+3
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
Reference in new issue
Block a user