Merge branch 'fix/coldstart-perf' into release/v1.27.32

This commit is contained in:
Marc-André Bombeck committed 2026-07-09 02:08:46 +02:00
commit 57c7972755
27 files changed
+1040 -179

No files matched your search

+104 -23
View File
@@ -8,11 +8,23 @@ vi.mock("@/lib/api-handler", () => ({
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { create: vi.fn(), upsert: vi.fn() },
},
}));
vi.mock("@/lib/db", () => {
const measurement = {
findMany: vi.fn(),
createMany: vi.fn(),
updateMany: vi.fn(),
};
return {
prisma: {
measurement,
// Run the batched write callback against the same measurement mock so
// createMany / updateMany calls are observable.
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) =>
fn({ measurement }),
),
},
};
});
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
@@ -83,6 +95,23 @@ beforeEach(() => {
remaining: 4,
resetAt: new Date(Date.now() + 1000),
} as never);
// Default: nothing pre-exists; the transaction runner is re-stubbed by the
// db mock factory (resetAllMocks clears its implementation), so restore it.
const measurement = prisma.measurement as unknown as {
findMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
};
measurement.findMany.mockResolvedValue([]);
measurement.createMany.mockResolvedValue({ count: 0 });
measurement.updateMany.mockResolvedValue({ count: 0 });
(
prisma.$transaction as unknown as {
mockImplementation: (f: unknown) => void;
}
).mockImplementation(async (fn: (tx: unknown) => unknown) =>
fn({ measurement }),
);
});
describe("POST /api/import/csv — rate limit", () => {
@@ -103,18 +132,23 @@ describe("POST /api/import/csv — rate limit", () => {
});
});
const mMeasurement = () =>
prisma.measurement as unknown as {
findMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
};
describe("POST /api/import/csv — fatal header error", () => {
it("returns 422 when a required column is missing", async () => {
const res = await POST(csvRequest("type,value,unit\nWEIGHT,80,kg"));
expect(res.status).toBe(422);
expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled();
expect(mMeasurement().createMany).not.toHaveBeenCalled();
});
});
describe("POST /api/import/csv — write loop + per-row envelope", () => {
describe("POST /api/import/csv — batched write + per-row envelope", () => {
it("inserts valid rows, skips invalid ones, returns per-row status", async () => {
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
const res = await POST(
csvRequest(
[
@@ -135,12 +169,29 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => {
const reasons = body.data?.rows.map((r) => r.reason);
expect(reasons).toContain("unknown_type");
expect(reasons).toContain("missing_timezone_offset");
// One create call for the single valid row.
expect(vi.mocked(prisma.measurement.create)).toHaveBeenCalledTimes(1);
// One bulk createMany carrying the single valid survivor.
expect(mMeasurement().createMany).toHaveBeenCalledTimes(1);
const arg = mMeasurement().createMany.mock.calls[0][0];
expect(arg.data).toHaveLength(1);
// Rollup re-fold fired for the touched (type, day).
expect(vi.mocked(recomputeBucketsForMeasurement)).toHaveBeenCalled();
});
it("batches many rows into a single createMany (no per-row round-trip)", async () => {
const rows = [HEADER];
for (let i = 0; i < 50; i++) {
const mm = String(i).padStart(2, "0"); // unique minute → unique measuredAt
rows.push(`WEIGHT,${80 + i * 0.1},kg,2026-05-01T08:${mm}:00Z,,,`);
}
const res = await POST(csvRequest(rows.join("\n")));
expect(res.status).toBe(200);
const body = (await res.json()) as CsvEnvelope;
expect(body.data?.inserted).toBe(50);
// 50 rows, one createMany call — the whole point of the batch.
expect(mMeasurement().createMany).toHaveBeenCalledTimes(1);
expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(50);
});
it("dryRun previews without writing and reports projected inserts", async () => {
const res = await POST(
csvRequest(
@@ -153,17 +204,14 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => {
expect(body.data?.dryRun).toBe(true);
expect(body.data?.inserted).toBe(1);
expect(body.data?.rows[0].status).toBe("inserted");
expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled();
expect(mMeasurement().createMany).not.toHaveBeenCalled();
expect(vi.mocked(recomputeBucketsForMeasurement)).not.toHaveBeenCalled();
});
it("upserts an externalId row and surfaces it as updated when it already existed", async () => {
const created = new Date("2026-05-01T08:00:00Z");
const bumped = new Date("2026-05-02T08:00:00Z");
vi.mocked(prisma.measurement.upsert).mockResolvedValue({
createdAt: created,
updatedAt: bumped,
} as never);
it("surfaces an externalId row as updated when it already existed (updateMany)", async () => {
mMeasurement().findMany.mockResolvedValue([
{ type: "WEIGHT", externalId: "ext-1" },
]);
const res = await POST(
csvRequest(
@@ -174,13 +222,28 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => {
expect(body.data?.updated).toBe(1);
expect(body.data?.inserted).toBe(0);
expect(body.data?.rows[0].status).toBe("updated");
expect(vi.mocked(prisma.measurement.upsert)).toHaveBeenCalledTimes(1);
expect(mMeasurement().updateMany).toHaveBeenCalledTimes(1);
expect(mMeasurement().createMany).not.toHaveBeenCalled();
});
it("counts a unique-constraint duplicate as skipped/duplicate", async () => {
vi.mocked(prisma.measurement.create).mockRejectedValue(
new Error("Unique constraint failed"),
it("inserts an externalId row when it does not exist yet", async () => {
mMeasurement().findMany.mockResolvedValue([]);
const res = await POST(
csvRequest(
[HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,ext-2"].join("\n"),
),
);
const body = (await res.json()) as CsvEnvelope;
expect(body.data?.inserted).toBe(1);
expect(body.data?.updated).toBe(0);
expect(body.data?.rows[0].status).toBe("inserted");
expect(mMeasurement().createMany).toHaveBeenCalledTimes(1);
});
it("counts a pre-existing natural-key row as skipped/duplicate", async () => {
mMeasurement().findMany.mockResolvedValue([
{ type: "WEIGHT", measuredAt: new Date("2026-05-01T08:00:00Z") },
]);
const res = await POST(
csvRequest([HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,"].join("\n")),
@@ -192,5 +255,23 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => {
status: "skipped",
reason: "duplicate",
});
expect(mMeasurement().createMany).not.toHaveBeenCalled();
});
it("collapses an in-file duplicate (same type+measuredAt) to one insert + one duplicate", async () => {
const res = await POST(
csvRequest(
[
HEADER,
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,",
"WEIGHT,80.6,kg,2026-05-01T08:00:00Z,,,",
].join("\n"),
),
);
const body = (await res.json()) as CsvEnvelope;
expect(body.data?.inserted).toBe(1);
expect(body.data?.skipped).toBe(1);
// Only the first survivor reaches the bulk insert.
expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(1);
});
});
+147 -64
View File
@@ -19,6 +19,8 @@ import type {
MeasurementType,
GlucoseContext,
} from "@/generated/prisma/client";
import { Prisma } from "@/generated/prisma/client";
import type { NormalisedMeasurementRow } from "@/lib/import/csv-measurements";
/**
* CSV measurement import (v1.17.1).
@@ -106,84 +108,165 @@ export const POST = apiHandler(async (request: NextRequest) => {
const writeOutcome = new Map<number, "inserted" | "updated" | "duplicate">();
if (!dryRun) {
for (const result of okRows) {
const m = result.row;
try {
if (m.externalId) {
// Idempotent re-import keyed on the source-stable id — re-uploading
// the same file updates in place rather than minting duplicates.
const res = await prisma.measurement.upsert({
// Batched write. The previous shape ran one serial `upsert` (or `create`)
// per accepted row — N round-trips for an N-row file, which on a
// few-thousand-row import approaches the request timeout. This adopts the
// fast shape `/measurements/batch` uses: pre-fetch the existing rows under
// the IMPORT source in one query per dedup-key shape, partition into bulk
// insert / update sets, then `createMany` + grouped `updateMany`. The
// per-line inserted/updated/duplicate envelope is reconstructed from the
// partition, so the response contract is unchanged.
const buildCreateData = (
m: NormalisedMeasurementRow,
): Prisma.MeasurementCreateManyInput => ({
userId,
type: m.type as MeasurementType,
value: m.value,
unit: m.unit,
source: "IMPORT",
...(m.externalId ? { externalId: m.externalId } : {}),
measuredAt: m.measuredAt,
notes: null,
notesEncrypted: encryptNote(m.notes ?? null),
glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
});
const extRows = okRows.filter((r) => r.row.externalId);
const plainRows = okRows.filter((r) => !r.row.externalId);
// --- externalId rows: idempotent upsert on
// (userId, type, source=IMPORT, externalId). One probe fetches every
// pre-existing key; a hit means the row updates in place.
const extExisting =
extRows.length > 0
? await prisma.measurement.findMany({
where: {
userId_type_source_externalId: {
userId,
type: m.type as MeasurementType,
source: "IMPORT",
externalId: m.externalId,
},
},
update: {
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
notes: null,
notesEncrypted: encryptNote(m.notes ?? null),
glucoseContext:
(m.glucoseContext as GlucoseContext | undefined) ?? null,
},
create: {
userId,
type: m.type as MeasurementType,
value: m.value,
unit: m.unit,
source: "IMPORT",
externalId: m.externalId,
measuredAt: m.measuredAt,
notes: null,
notesEncrypted: encryptNote(m.notes ?? null),
glucoseContext:
(m.glucoseContext as GlucoseContext | undefined) ?? null,
OR: extRows.map((r) => ({
type: r.row.type as MeasurementType,
externalId: r.row.externalId as string,
})),
},
select: { createdAt: true, updatedAt: true },
});
// A fresh create has createdAt === updatedAt; an in-place update
// bumps updatedAt past createdAt.
if (res.updatedAt.getTime() > res.createdAt.getTime()) {
updated++;
writeOutcome.set(result.line, "updated");
} else {
inserted++;
writeOutcome.set(result.line, "inserted");
}
} else {
await prisma.measurement.create({
data: {
select: { type: true, externalId: true },
})
: [];
const extExistingSet = new Set(
extExisting.map((e) => `${e.type}::${e.externalId}`),
);
// Partition in file order, honouring in-file repeats: the first sighting
// of a brand-new key inserts, every later sighting (or any sighting of a
// key already in the DB) updates in place. The last value for a key wins,
// matching the old sequential-upsert overwrite. Insert/update maps are
// keyed by (type, externalId) so a repeated key collapses to one write.
const seenExt = new Set<string>();
const extInserts = new Map<string, NormalisedMeasurementRow>();
const extUpdates = new Map<string, NormalisedMeasurementRow>();
for (const result of extRows) {
const m = result.row;
const key = `${m.type}::${m.externalId}`;
if (extExistingSet.has(key)) {
extUpdates.set(key, m);
updated++;
writeOutcome.set(result.line, "updated");
} else if (seenExt.has(key)) {
// New-to-DB key seen again in this file: still a single create with
// the latest value, but this line reads as an update just as the
// sequential upsert did.
extInserts.set(key, m);
updated++;
writeOutcome.set(result.line, "updated");
} else {
seenExt.add(key);
extInserts.set(key, m);
inserted++;
writeOutcome.set(result.line, "inserted");
}
touchedMeasurements.push({
type: m.type as MeasurementType,
measuredAt: m.measuredAt,
});
}
// --- externalId-less rows: create-only, deduped on the natural unique key
// (userId, type, measuredAt, source=IMPORT, sleepStage=null). A collision
// — against the DB or an earlier row in this same file — is a duplicate,
// never an overwrite (each sample is a canonical reading).
const plainExisting =
plainRows.length > 0
? await prisma.measurement.findMany({
where: {
userId,
type: m.type as MeasurementType,
value: m.value,
unit: m.unit,
source: "IMPORT",
measuredAt: m.measuredAt,
notes: null,
notesEncrypted: encryptNote(m.notes ?? null),
glucoseContext:
(m.glucoseContext as GlucoseContext | undefined) ?? null,
OR: plainRows.map((r) => ({
type: r.row.type as MeasurementType,
measuredAt: r.row.measuredAt,
})),
},
});
inserted++;
writeOutcome.set(result.line, "inserted");
}
select: { type: true, measuredAt: true },
})
: [];
const plainExistingSet = new Set(
plainExisting.map((e) => `${e.type}::${e.measuredAt.getTime()}`),
);
const seenPlain = new Set<string>();
const plainInserts: NormalisedMeasurementRow[] = [];
for (const result of plainRows) {
const m = result.row;
const key = `${m.type}::${m.measuredAt.getTime()}`;
if (plainExistingSet.has(key) || seenPlain.has(key)) {
skipped++;
writeOutcome.set(result.line, "duplicate");
} else {
seenPlain.add(key);
plainInserts.push(m);
inserted++;
writeOutcome.set(result.line, "inserted");
touchedMeasurements.push({
type: m.type as MeasurementType,
measuredAt: m.measuredAt,
});
} catch {
// Unique-constraint violation (a NULL-externalId duplicate against an
// existing row) — count as skipped rather than failing the file.
skipped++;
writeOutcome.set(result.line, "duplicate");
}
}
// Bulk write: createMany the survivors (chunked under the PG parameter
// cap, skipDuplicates to absorb a concurrent double-submit race), then
// grouped updateMany for the pre-existing externalId rows.
const toCreate: Prisma.MeasurementCreateManyInput[] = [
...[...extInserts.values()].map(buildCreateData),
...plainInserts.map(buildCreateData),
];
const CREATE_CHUNK = 200;
await prisma.$transaction(async (tx) => {
for (let i = 0; i < toCreate.length; i += CREATE_CHUNK) {
await tx.measurement.createMany({
data: toCreate.slice(i, i + CREATE_CHUNK),
skipDuplicates: true,
});
}
for (const m of extUpdates.values()) {
await tx.measurement.updateMany({
where: {
userId,
source: "IMPORT",
type: m.type as MeasurementType,
externalId: m.externalId as string,
},
data: {
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
notes: null,
notesEncrypted: encryptNote(m.notes ?? null),
glucoseContext:
(m.glucoseContext as GlucoseContext | undefined) ?? null,
},
});
}
});
// One bounded rollup re-fold per touched (type, day) — a 10 000-row CSV
// pays at most ~N (type, day) recomputes, not 10 000 per-row hooks.
// Best-effort: a populator hiccup never fails the importer.
+9 -9
View File
@@ -13,18 +13,16 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
/**
* v1.4.25 W14b — onboarding wizard step page (foundation scaffold).
* Onboarding wizard step page.
*
* Routes:
* /onboarding/0 welcome → carousel / value-prop intro
* /onboarding/1 goals → "what do you want to track?"
* /onboarding/2 source → Withings / Apple Health / manual cards
* /onboarding/2 source → device/import source cards (see
* source-card-grid.tsx for the shipped set)
* /onboarding/3 baseline → first measurement or sync confirmation
* /onboarding/4 done → success screen + return to dashboard
*
* This page ships the scaffold only — the step body is a placeholder
* `<div>` that the W14b-Content agent replaces with real step UI.
*
* Step gating:
* - Unauthenticated → `/auth/login` (the proxy also enforces this,
* but the server-side check keeps the contract explicit).
@@ -139,10 +137,12 @@ export default async function OnboardingStepPage({ params }: PageProps) {
);
}
// Source (step 2) — four-card grid. Manual is the implicit default;
// Withings opens OAuth in a new tab; Apple Health is rendered as a
// "coming with v1.5" announce card; Garmin is omitted per the
// W14b-Content brief (not on the roadmap yet).
// Source (step 2) — card grid. Manual is the implicit default; the
// connectable sources (Apple Health, WHOOP, Oura, Polar, Nightscout,
// Fitbit/Pixel via Google Health) live in `source-card-grid.tsx`'s
// SOURCE_CARDS / MORE_SOURCES lists — refer to that component for the
// current shipped set rather than duplicating it here. The selection
// is non-binding: nothing here auto-configures a sync.
if (requested === 2) {
return (
<OnboardingShell step={2} userLocale={user.locale ?? null}>
@@ -96,6 +96,19 @@ export function GeneralSettingsSection() {
disabled={updateSettings.isPending}
/>
{/* Honest nudge: registration stays open after the first admin so a
household can be invited, but on an internet-exposed instance that
means anyone who can reach the URL can sign up. Surface a quiet
reminder while it is open — no behaviour change, just a signal. */}
{(settings?.registrationEnabled ?? true) && (
<p
data-slot="registration-open-hint"
className="text-muted-foreground -mt-2 text-xs"
>
{t("admin.registrationOpenHint")}
</p>
)}
{/* v1.23 — instance-wide "require a second factor" policy. Every
account without an active factor is sent to forced enrollment after
sign-in. This is a web-session nudge, not an API wall: direct API /
@@ -15,6 +15,7 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
import { cn } from "@/lib/utils";
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
/**
* v1.4.32 — `<RecentWorkoutsTile>`.
@@ -70,7 +71,7 @@ function formatDuration(seconds: number): string {
function formatDayLabel(iso: string, locale: string): string {
const d = new Date(iso);
return new Intl.DateTimeFormat(locale, {
return getDateTimeFormat(locale, {
weekday: "short",
day: "2-digit",
month: "short",
+5 -4
View File
@@ -17,6 +17,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import {
hourCycleOptions,
@@ -73,7 +74,7 @@ function formatDuration(seconds: number): string {
function formatDistanceKm(meters: number, locale: string): string {
const km = meters / 1000;
return new Intl.NumberFormat(locale, {
return getNumberFormat(locale, {
maximumFractionDigits: 2,
minimumFractionDigits: km < 10 ? 2 : 1,
}).format(km);
@@ -84,7 +85,7 @@ function formatNumber(
locale: string,
fractionDigits = 0,
): string {
return new Intl.NumberFormat(locale, {
return getNumberFormat(locale, {
maximumFractionDigits: fractionDigits,
minimumFractionDigits: fractionDigits,
}).format(value);
@@ -111,13 +112,13 @@ function formatDateRange(
): string {
const start = new Date(startedAt);
const end = new Date(endedAt);
const dateFmt = new Intl.DateTimeFormat(locale, {
const dateFmt = getDateTimeFormat(locale, {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
});
const timeFmt = new Intl.DateTimeFormat(locale, {
const timeFmt = getDateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(timeFormat),
+4 -3
View File
@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import {
hourCycleOptions,
@@ -68,14 +69,14 @@ function formatDuration(seconds: number): string {
function formatDistanceKm(meters: number, locale: string): string {
const km = meters / 1000;
return new Intl.NumberFormat(locale, {
return getNumberFormat(locale, {
maximumFractionDigits: 2,
minimumFractionDigits: km < 10 ? 2 : 1,
}).format(km);
}
function formatEnergy(kcal: number, locale: string): string {
return new Intl.NumberFormat(locale, {
return getNumberFormat(locale, {
maximumFractionDigits: 0,
}).format(Math.round(kcal));
}
@@ -86,7 +87,7 @@ function formatDate(
timeFormat: TimeFormatPreference,
): string {
const d = new Date(iso);
return new Intl.DateTimeFormat(locale, {
return getDateTimeFormat(locale, {
weekday: "short",
day: "2-digit",
month: "short",
@@ -10,6 +10,7 @@ import { MedicationCardBody } from "@/components/medications/card-parts/medicati
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { formatDateTime, formatTime } from "@/lib/format";
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
import { formatDose } from "@/lib/medications/format-dose";
import {
@@ -335,7 +336,7 @@ export function MedicationCard({
// Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and
// string-comparable for the today / yesterday / older bucketing below.
// The actual user-facing display goes through formatTime / formatDateTime.
const dayFormatter = new Intl.DateTimeFormat("en-CA", {
const dayFormatter = getDateTimeFormat("en-CA", {
timeZone: userTz,
year: "numeric",
month: "2-digit",
+78 -45
View File
@@ -12,6 +12,7 @@ import {
useContext,
useEffect,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
@@ -63,59 +64,91 @@ function applyTheme(resolved: "dark" | "light") {
document.documentElement.classList.add(resolved);
}
// ── Theme as an external store ───────────────────────
//
// The stored preference lives in localStorage, and the OS preference in
// matchMedia — both browser-only values that must NOT be read in a `useState`
// initializer (server renders "dark", a client with a stored "light"/"system"
// initializes differently → the React #418 hydration seam this repo already
// hit with the sidebar). `useSyncExternalStore` is the sanctioned fix: it reads
// the value with an SSR-stable server snapshot ("dark") and reconciles to the
// real value after hydration without a setState-in-effect. The nonce-bound
// inline script in layout.tsx stamps the correct class pre-paint, so there is
// no visual FOUC while React reconciles.
const THEME_STORAGE_KEY = "healthlog-theme";
const themeListeners = new Set<() => void>();
function notifyThemeListeners() {
for (const cb of themeListeners) cb();
}
function subscribeStoredTheme(onChange: () => void): () => void {
themeListeners.add(onChange);
const onStorage = (e: StorageEvent) => {
if (e.key === THEME_STORAGE_KEY) onChange();
};
window.addEventListener("storage", onStorage);
return () => {
themeListeners.delete(onChange);
window.removeEventListener("storage", onStorage);
};
}
function getStoredThemeSnapshot(): Theme {
const saved = localStorage.getItem(THEME_STORAGE_KEY) as Theme | null;
// A stored "light"/"dark"/"system" choice wins. With no stored preference the
// app defaults to dark rather than tracking the OS.
if (saved === "light" || saved === "dark" || saved === "system") return saved;
return "dark";
}
function getServerThemeSnapshot(): Theme {
return "dark";
}
function subscribeSystemTheme(onChange: () => void): () => void {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function getSystemThemeSnapshot(): "dark" | "light" {
return getSystemTheme();
}
function getServerSystemSnapshot(): "dark" | "light" {
return "dark";
}
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window === "undefined") return "dark";
const saved = localStorage.getItem("healthlog-theme") as Theme | null;
// A stored "light"/"dark"/"system" choice wins. With no stored
// preference the app defaults to dark rather than tracking the OS.
if (saved === "light" || saved === "dark") return saved;
if (saved === "system") return "system";
return "dark";
});
const theme = useSyncExternalStore(
subscribeStoredTheme,
getStoredThemeSnapshot,
getServerThemeSnapshot,
);
const systemTheme = useSyncExternalStore(
subscribeSystemTheme,
getSystemThemeSnapshot,
getServerSystemSnapshot,
);
const resolvedTheme = theme === "system" ? systemTheme : theme;
const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">(() => {
if (typeof window === "undefined") return "dark";
const saved = localStorage.getItem("healthlog-theme") as Theme | null;
if (saved === "light" || saved === "dark") return saved;
if (saved === "system") return getSystemTheme();
return "dark";
});
// Apply theme on mount (inline script handles FOUC, this ensures classes are in sync)
// Keep the <html> class in sync with the resolved theme. This is a DOM write,
// not a setState — it runs on mount (reconciling the class the inline script
// set to any stored preference) and whenever the resolved theme changes.
useEffect(() => {
applyTheme(resolvedTheme);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Listen for OS preference changes when in system mode
useEffect(() => {
if (theme !== "system") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
const resolved = e.matches ? "dark" : "light";
setResolvedTheme(resolved);
applyTheme(resolved);
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [theme]);
}, [resolvedTheme]);
const setTheme = useCallback((next: Theme) => {
setThemeState(next);
// Persist every explicit choice, including "system" — the absence of a
// stored value is reserved for a fresh visitor and now defaults to dark.
// stored value is reserved for a fresh visitor and defaults to dark.
// Storing "system" verbatim keeps an explicit OS-tracking choice
// distinguishable from "never chose", so a reload honours it.
localStorage.setItem("healthlog-theme", next);
if (next === "system") {
const resolved = getSystemTheme();
setResolvedTheme(resolved);
applyTheme(resolved);
} else {
setResolvedTheme(next);
applyTheme(next);
}
// distinguishable from "never chose", so a reload honours it. The write +
// notify re-reads the external store; the effect above applies the class.
localStorage.setItem(THEME_STORAGE_KEY, next);
notifyThemeListeners();
}, []);
return (
+12
View File
@@ -3,6 +3,18 @@ import type { Instrumentation } from "next";
export async function register() {
// Only start the worker on the Node.js server runtime (not Edge, not build)
if (process.env.NEXT_RUNTIME === "nodejs") {
// First-boot readiness summary: a human-readable block on the core secrets
// (with the exact fix on a bad one) before any fail-closed loader throws,
// plus a one-glance status of the optional channels. Runs for every process
// type (web + worker). Never throws — the real gate stays the loaders.
try {
const { logReadinessSummary } =
await import("@/lib/boot/readiness-summary");
logReadinessSummary();
} catch {
// A readiness summary must never break boot.
}
// Every Node process watches its own event loop — web-only
// containers included; a stall report from the process serving
// requests is the whole point.
+61 -1
View File
@@ -1,6 +1,13 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { shouldEmitSecureCookie } from "../secure-cookie";
import {
shouldEmitSecureCookie,
detectInsecureCookieTransport,
} from "../secure-cookie";
function req(headers: Record<string, string>, url = "http://10.0.0.5:3000/x") {
return { headers: new Headers(headers), url };
}
describe("shouldEmitSecureCookie", () => {
afterEach(() => {
@@ -47,3 +54,56 @@ describe("shouldEmitSecureCookie", () => {
expect(shouldEmitSecureCookie()).toBe(false);
});
});
describe("detectInsecureCookieTransport", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("returns null when the Secure flag is off (SESSION_COOKIE_SECURE=false)", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
expect(detectInsecureCookieTransport(req({}))).toBeNull();
});
it("warns when a Secure cookie will be set on a plain-HTTP request (no proxy)", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("SESSION_COOKIE_SECURE", "");
const msg = detectInsecureCookieTransport(req({}));
expect(msg).toContain("SESSION_COOKIE_SECURE=false");
});
it("returns null behind a TLS proxy that forwards X-Forwarded-Proto: https", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("SESSION_COOKIE_SECURE", "");
expect(
detectInsecureCookieTransport(req({ "x-forwarded-proto": "https" })),
).toBeNull();
});
it("reads the left-most entry of a comma-separated proto chain", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("SESSION_COOKIE_SECURE", "");
expect(
detectInsecureCookieTransport(
req({ "x-forwarded-proto": "https, http" }),
),
).toBeNull();
});
it("warns when the proxy forwards X-Forwarded-Proto: http explicitly", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("SESSION_COOKIE_SECURE", "");
expect(
detectInsecureCookieTransport(req({ "x-forwarded-proto": "http" })),
).not.toBeNull();
});
it("returns null when the request URL is already https", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("SESSION_COOKIE_SECURE", "");
expect(
detectInsecureCookieTransport(req({}, "https://healthlog.example.com/x")),
).toBeNull();
});
});
+16 -2
View File
@@ -27,6 +27,8 @@ import {
} from "@/lib/auth/native-client";
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import { detectInsecureCookieTransport } from "@/lib/auth/secure-cookie";
import { annotate } from "@/lib/logging/context";
import type { User } from "@/generated/prisma/client";
export interface FinishLoginParams {
@@ -116,8 +118,20 @@ export async function finishLogin(
});
}
// Browser path — session cookie. `createSession` anchors the onboarding
// cookie itself; thread the user's onboarding state through.
// Browser path — session cookie. Before minting it, surface the single
// most common self-host snag: a Secure cookie about to be set on a plain-HTTP
// request drops silently and loops the login. Warn-log + wide-event so an
// operator sees the cause; the login still completes.
const transportWarning = detectInsecureCookieTransport(request);
if (transportWarning) {
console.warn(
`[auth] session-cookie transport mismatch: ${transportWarning}`,
);
annotate({ meta: { session_cookie_secure_transport_mismatch: true } });
}
// `createSession` anchors the onboarding cookie itself; thread the user's
// onboarding state through.
await createSession(
user.id,
user.onboardingCompletedAt == null,
+54
View File
@@ -28,3 +28,57 @@ export function shouldEmitSecureCookie(): boolean {
if (override === "true") return true;
return process.env.NODE_ENV === "production";
}
/**
* One-line diagnostic for the single most common self-host login-loop cause:
* the app is about to set a `Secure` session cookie
* (`shouldEmitSecureCookie() === true`) while the request itself arrived over
* plain HTTP. The browser silently drops a `Secure` cookie on an `http://`
* origin, so the next request is unauthenticated and the user bounces back to
* the login page with no error at all — it reads like a wrong password forever.
*
* Returns a human-readable message when the mismatch is detected, else `null`.
* The caller logs it at login time (`console.warn` survives the prod
* `console.*` strip) so an operator sees WHY login loops without having to find
* the note buried in three docs. Non-fatal: the login still completes; the
* cookie just won't stick until `SESSION_COOKIE_SECURE=false` is set (or TLS is
* fronted).
*
* A request counts as HTTPS when `x-forwarded-proto` names https (the shape
* every documented TLS reverse proxy sets) OR the request URL is already
* https. A proxy that terminates TLS but omits `x-forwarded-proto` can produce
* a benign false positive — the message names that case so it stays actionable
* either way.
*/
export function detectInsecureCookieTransport(req: {
headers: Headers;
url: string;
}): string | null {
if (!shouldEmitSecureCookie()) return null;
// `x-forwarded-proto` can be a comma-separated proto chain; the left-most
// entry is the client-facing scheme the browser actually spoke.
const clientProto = req.headers
.get("x-forwarded-proto")
?.split(",")[0]
?.trim()
.toLowerCase();
if (clientProto === "https") return null;
let urlIsHttps = false;
try {
urlIsHttps = new URL(req.url).protocol === "https:";
} catch {
urlIsHttps = false;
}
if (urlIsHttps) return null;
return (
"This request arrived over plain HTTP but the session cookie is being " +
"issued with the `Secure` flag — the browser will drop it and login will " +
"loop back to the sign-in page with no error message. If you serve HTTP " +
"directly (LAN / NAS / Tailscale, no TLS reverse proxy), set " +
"SESSION_COOKIE_SECURE=false. If a reverse proxy terminates TLS in front " +
"of this container, make sure it forwards `X-Forwarded-Proto: https`."
);
}
@@ -0,0 +1,131 @@
import { describe, it, expect } from "vitest";
import {
collectReadiness,
formatReadiness,
logReadinessSummary,
} from "../readiness-summary";
const HEX32 =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
function baseEnv(): Record<string, string | undefined> {
return {
ENCRYPTION_KEY: HEX32,
API_TOKEN_HMAC_KEY: HEX32,
DATABASE_URL: "postgresql://healthlog:pw@db:5432/healthlog",
NODE_ENV: "production",
};
}
function lineFor(env: Record<string, string | undefined>, label: string) {
return collectReadiness(env).lines.find((l) => l.label === label);
}
describe("collectReadiness — core secrets", () => {
it("reports no blocker when the three core secrets are valid", () => {
const report = collectReadiness(baseEnv());
expect(report.hasBlocker).toBe(false);
expect(lineFor(baseEnv(), "ENCRYPTION_KEY")?.status).toBe("ok");
expect(lineFor(baseEnv(), "API_TOKEN_HMAC_KEY")?.status).toBe("ok");
expect(lineFor(baseEnv(), "DATABASE_URL")?.status).toBe("ok");
});
it("flags a missing ENCRYPTION_KEY as a blocker with the openssl fix", () => {
const env = baseEnv();
delete env.ENCRYPTION_KEY;
const report = collectReadiness(env);
expect(report.hasBlocker).toBe(true);
const line = report.lines.find((l) => l.label === "ENCRYPTION_KEY");
expect(line?.status).toBe("error");
expect(line?.detail).toContain("openssl rand -hex 32");
});
it("flags a too-short ENCRYPTION_KEY as a blocker", () => {
const env = baseEnv();
env.ENCRYPTION_KEY = "deadbeef";
expect(collectReadiness(env).hasBlocker).toBe(true);
});
it("accepts the ENCRYPTION_KEYS rotation map instead of the single key", () => {
const env = baseEnv();
delete env.ENCRYPTION_KEY;
env.ENCRYPTION_KEYS = JSON.stringify({ v1: HEX32, v2: HEX32 });
const report = collectReadiness(env);
expect(report.hasBlocker).toBe(false);
expect(
report.lines.find((l) => l.label === "ENCRYPTION_KEYS")?.status,
).toBe("ok");
});
it("flags a malformed ENCRYPTION_KEYS map", () => {
const env = baseEnv();
delete env.ENCRYPTION_KEY;
env.ENCRYPTION_KEYS = '{"v1":"nothex"}';
expect(collectReadiness(env).hasBlocker).toBe(true);
});
it("flags a missing DATABASE_URL", () => {
const env = baseEnv();
delete env.DATABASE_URL;
expect(collectReadiness(env).hasBlocker).toBe(true);
});
});
describe("collectReadiness — transport + optional", () => {
it("warns Secure-on when NODE_ENV=production and no override", () => {
const line = lineFor(baseEnv(), "SESSION_COOKIE_SECURE");
expect(line?.detail).toContain("Secure cookie flag ON");
});
it("notes Secure-off when SESSION_COOKIE_SECURE=false", () => {
const env = { ...baseEnv(), SESSION_COOKIE_SECURE: "false" };
expect(lineFor(env, "SESSION_COOKIE_SECURE")?.detail).toContain(
"Secure cookie flag OFF",
);
});
it("marks optional subsystems as warn when unconfigured", () => {
const smtp = lineFor(baseEnv(), "SMTP (email)");
expect(smtp?.status).toBe("warn");
});
it("marks off-host backup ok only when every required var is set", () => {
const env = {
...baseEnv(),
BACKUP_S3_ENDPOINT: "https://r2",
BACKUP_S3_BUCKET: "b",
BACKUP_S3_ACCESS_KEY: "a",
BACKUP_S3_SECRET_KEY: "s",
BACKUP_ENCRYPTION_KEY: HEX32,
};
expect(lineFor(env, "Off-host backup")?.status).toBe("ok");
});
});
describe("formatReadiness + logReadinessSummary", () => {
it("renders a header that flags a blocker", () => {
const env = baseEnv();
delete env.API_TOKEN_HMAC_KEY;
const text = formatReadiness(collectReadiness(env));
expect(text).toContain("CORE SECRET MISSING/INVALID");
expect(text).toContain("✗ API_TOKEN_HMAC_KEY");
});
it("logs blockers to error and healthy summaries to log; never throws", () => {
const errors: string[] = [];
const logs: string[] = [];
const sink = {
error: (m: string) => errors.push(m),
log: (m: string) => logs.push(m),
};
logReadinessSummary(baseEnv(), sink);
expect(logs).toHaveLength(1);
expect(errors).toHaveLength(0);
const bad = baseEnv();
delete bad.ENCRYPTION_KEY;
logReadinessSummary(bad, sink);
expect(errors).toHaveLength(1);
});
});
+197
View File
@@ -0,0 +1,197 @@
/**
* First-boot readiness summary.
*
* The bundled image promises "single `docker compose up`", but the reality is
* three hand-generated secrets plus a handful of optional subsystems — and the
* crypto loader is fail-closed, so a missing/short `ENCRYPTION_KEY` throws a
* stack trace on first use rather than a sentence an operator can act on. This
* module inspects the environment ONCE at boot and prints a compact,
* human-readable readiness block: a clear line per core secret (with the exact
* `openssl` fix on a bad one) before anything throws, plus a one-glance status
* of the optional channels.
*
* It NEVER logs a secret value — only presence + shape validity. It never
* throws: the fail-closed loaders remain the real gate; this is a friendlier
* signpost in front of them.
*/
export interface ReadinessLine {
/** Short label, e.g. "ENCRYPTION_KEY". */
label: string;
/** ok = green, warn = optional-not-configured, error = missing/invalid core. */
status: "ok" | "warn" | "error";
/** One-line human detail (no secret material). */
detail: string;
}
export interface ReadinessReport {
lines: ReadinessLine[];
/** True when at least one core secret is missing or malformed. */
hasBlocker: boolean;
}
type Env = Record<string, string | undefined>;
/** A 64-char hex string (= 32 bytes) or a base64 string that decodes to 32 bytes. */
function isValid32ByteKey(raw: string | undefined): boolean {
if (!raw) return false;
const v = raw.trim();
if (/^[0-9a-fA-F]{64}$/.test(v)) return true;
if (/^[A-Za-z0-9+/=]+$/.test(v)) {
try {
return Buffer.from(v, "base64").length === 32;
} catch {
return false;
}
}
return false;
}
function allSet(env: Env, keys: string[]): boolean {
return keys.every((k) => (env[k] ?? "").trim().length > 0);
}
function anySet(env: Env, keys: string[]): boolean {
return keys.some((k) => (env[k] ?? "").trim().length > 0);
}
/** Inspect the environment and build the readiness report. Pure + side-effect free. */
export function collectReadiness(env: Env = process.env): ReadinessReport {
const lines: ReadinessLine[] = [];
// --- Core secrets (boot blockers) ---------------------------------------
// Encryption: the map form (ENCRYPTION_KEYS) supersedes the single key.
const keysMap = (env.ENCRYPTION_KEYS ?? "").trim();
let encryptionOk = false;
if (keysMap) {
try {
const parsed = JSON.parse(keysMap) as Record<string, string>;
const entries = Object.values(parsed ?? {});
encryptionOk =
entries.length > 0 && entries.every((v) => isValid32ByteKey(v));
} catch {
encryptionOk = false;
}
lines.push({
label: "ENCRYPTION_KEYS",
status: encryptionOk ? "ok" : "error",
detail: encryptionOk
? "key-rotation map present and valid"
: "ENCRYPTION_KEYS is not a JSON map of valid 32-byte keys",
});
} else {
encryptionOk = isValid32ByteKey(env.ENCRYPTION_KEY);
lines.push({
label: "ENCRYPTION_KEY",
status: encryptionOk ? "ok" : "error",
detail: encryptionOk
? "present (64 hex chars)"
: "missing or not 64 hex chars — generate with: openssl rand -hex 32",
});
}
const hmacOk = isValid32ByteKey(env.API_TOKEN_HMAC_KEY);
lines.push({
label: "API_TOKEN_HMAC_KEY",
status: hmacOk ? "ok" : "error",
detail: hmacOk
? "present (64 hex chars)"
: "missing or not 64 hex chars — generate with: openssl rand -hex 32",
});
const dbOk = (env.DATABASE_URL ?? "").trim().length > 0;
lines.push({
label: "DATABASE_URL",
status: dbOk ? "ok" : "error",
detail: dbOk
? "present"
: "missing — the bundled compose derives it from POSTGRES_PASSWORD",
});
// --- Transport hint (a warning, never a blocker) ------------------------
const secureOverride = (env.SESSION_COOKIE_SECURE ?? "").trim().toLowerCase();
const secureFlagOn =
secureOverride === "true" ||
(secureOverride !== "false" && env.NODE_ENV === "production");
lines.push({
label: "SESSION_COOKIE_SECURE",
status: "ok",
detail: secureFlagOn
? "Secure cookie flag ON — serve HTTPS, or set SESSION_COOKIE_SECURE=false for a plain-HTTP LAN host"
: "Secure cookie flag OFF — fine for plain-HTTP LAN; do NOT expose plain HTTP to the internet",
});
// --- Optional subsystems (informational) --------------------------------
const optional: Array<[string, boolean]> = [
["SMTP (email)", allSet(env, ["SMTP_HOST", "SMTP_PORT", "SMTP_FROM"])],
[
"Off-host backup",
allSet(env, [
"BACKUP_S3_ENDPOINT",
"BACKUP_S3_BUCKET",
"BACKUP_S3_ACCESS_KEY",
"BACKUP_S3_SECRET_KEY",
"BACKUP_ENCRYPTION_KEY",
]),
],
[
"APNs (native iOS push)",
allSet(env, ["APNS_KEY_ID", "APNS_TEAM_ID", "APNS_BUNDLE_ID"]) &&
anySet(env, ["APNS_KEY_B64", "APNS_KEY", "APNS_KEY_FILE"]),
],
[
"Web Push (VAPID env)",
allSet(env, ["VAPID_PUBLIC_KEY", "VAPID_PRIVATE_KEY", "VAPID_SUBJECT"]),
],
];
for (const [label, configured] of optional) {
lines.push({
label,
status: configured ? "ok" : "warn",
detail: configured ? "configured" : "not configured (optional)",
});
}
const hasBlocker = lines.some((l) => l.status === "error");
return { lines, hasBlocker };
}
const GLYPH: Record<ReadinessLine["status"], string> = {
ok: "✓",
warn: "·",
error: "✗",
};
/** Render the report as a compact multi-line block. */
export function formatReadiness(report: ReadinessReport): string {
const body = report.lines
.map((l) => ` ${GLYPH[l.status]} ${l.label}: ${l.detail}`)
.join("\n");
const header = report.hasBlocker
? "HealthLog boot readiness — CORE SECRET MISSING/INVALID (see ✗ below):"
: "HealthLog boot readiness:";
return `${header}\n${body}`;
}
/**
* Collect + print the readiness summary. Blockers go to stderr (they precede a
* fail-closed throw), the healthy summary to stdout. Never throws.
*/
export function logReadinessSummary(
env: Env = process.env,
log: Pick<Console, "log" | "error"> = console,
): ReadinessReport {
try {
const report = collectReadiness(env);
const text = formatReadiness(report);
if (report.hasBlocker) {
log.error(text);
} else {
log.log(text);
}
return report;
} catch {
// A readiness summary must never be the thing that breaks boot.
return { lines: [], hasBlocker: false };
}
}
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { getNumberFormat, getDateTimeFormat } from "../formatter-cache";
describe("formatter-cache", () => {
it("returns the same NumberFormat instance for identical (locale, options)", () => {
const a = getNumberFormat("en", { maximumFractionDigits: 2 });
const b = getNumberFormat("en", { maximumFractionDigits: 2 });
expect(a).toBe(b);
});
it("returns distinct NumberFormat instances for different options", () => {
const a = getNumberFormat("en", { maximumFractionDigits: 2 });
const b = getNumberFormat("en", { maximumFractionDigits: 0 });
expect(a).not.toBe(b);
});
it("returns distinct instances per locale", () => {
const a = getNumberFormat("en", { maximumFractionDigits: 1 });
const b = getNumberFormat("de", { maximumFractionDigits: 1 });
expect(a).not.toBe(b);
});
it("caches DateTimeFormat and still formats correctly", () => {
const a = getDateTimeFormat("en-CA", {
timeZone: "UTC",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const b = getDateTimeFormat("en-CA", {
timeZone: "UTC",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
expect(a).toBe(b);
expect(a.format(new Date("2026-05-08T12:00:00Z"))).toBe("2026-05-08");
});
});
Binary file not shown.
@@ -6,6 +6,7 @@ import {
runOffhostBackup,
runOffhostRoundtripTest,
} from "../offhost-backup";
import { MEASUREMENT_BACKUP_PAGE_SIZE } from "../backup/measurement-page";
const ENC_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
@@ -138,6 +139,56 @@ describe("runOffhostBackup", () => {
expect(parsed.userId).toBe("u1");
});
it("keyset-pages the intake/mood/cycle tables across multiple pages", async () => {
const s3 = makeS3Mock();
// A full first page (=== page size) followed by a short page proves the
// reader walks the id cursor instead of one unbounded findMany.
const fullPage = Array.from(
{ length: MEASUREMENT_BACKUP_PAGE_SIZE },
(_, i) => ({
id: `e${String(i).padStart(5, "0")}`,
userId: "u1",
takenAt: new Date("2026-05-01T00:00:00Z"),
}),
);
const tailPage = [
{ id: "z9999", userId: "u1", takenAt: new Date("2026-05-02T00:00:00Z") },
];
const intakeFindMany = vi.fn(
async (args: { where?: { id?: { gt?: string } } }) =>
args?.where?.id?.gt ? tailPage : fullPage,
);
const prisma = {
user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) },
measurement: { findMany: vi.fn().mockResolvedValue([]) },
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: { findMany: intakeFindMany },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
};
const report = await runOffhostBackup(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma as any,
s3,
new Date("2026-05-08T00:00:00Z"),
);
expect(report.uploaded).toBe(1);
// Two round-trips: the full page, then the short tail that ends the walk.
expect(intakeFindMany).toHaveBeenCalledTimes(2);
expect(intakeFindMany.mock.calls[0][0]).toMatchObject({
orderBy: { id: "asc" },
take: MEASUREMENT_BACKUP_PAGE_SIZE,
});
// Every paged row lands in the decrypted payload — no rows dropped.
const ct = s3.store.get("2026-05-08/user-u1.json.enc")!;
const parsed = JSON.parse(decryptBackup(ct, Buffer.from(ENC_KEY, "hex")));
expect(parsed.intakeEvents).toHaveLength(MEASUREMENT_BACKUP_PAGE_SIZE + 1);
});
it("counts per-user failures without aborting the whole run", async () => {
const s3 = makeS3Mock();
const prisma = {
+61 -7
View File
@@ -279,16 +279,70 @@ export async function runOffhostBackup(
where: { userId: user.id },
include: { schedules: true },
}),
prisma.medicationIntakeEvent.findMany({ where: { userId: user.id } }),
prisma.moodEntry.findMany({ where: { userId: user.id } }),
// The intake / mood / cycle tables scale with history depth the same
// way measurements do (multiple intakes + a mood + a cycle-day-log per
// day over years), so they get the same keyset-paged read: at most one
// page is resident per round-trip instead of the entire table landing
// in one array alongside the giant JSON.stringify below. The projection
// keeps the full row (no select), so payload bytes are identical to the
// prior default findMany — only peak heap during the read is bounded.
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
orderBy: { id: "asc" },
take,
}),
project: (row) => row,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}),
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.moodEntry.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
orderBy: { id: "asc" },
take,
}),
project: (row) => row,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}),
// v1.15.0 — cycle tables in the DR snapshot. Raw rows (this is a
// disaster-recovery dump, not the canonical user-facing backup);
// `notesEncrypted` stays ciphertext.
// `notesEncrypted` stays ciphertext. The profile is a single row, so it
// stays a direct read; the cycle + day-log tables are paged.
prisma.cycleProfile.findUnique({ where: { userId: user.id } }),
prisma.menstrualCycle.findMany({ where: { userId: user.id } }),
prisma.cycleDayLog.findMany({
where: { userId: user.id },
include: { symptomLinks: true },
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.menstrualCycle.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
orderBy: { id: "asc" },
take,
}),
project: (row) => row,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}),
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.cycleDayLog.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
include: { symptomLinks: true },
orderBy: { id: "asc" },
take,
}),
project: (row) => row,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}),
]);