Merge branch 'fix/v1393-review-data' into release/v1.39.3

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 00:18:47 +02:00
commit 72a34b3b47
39 files changed
+1118 -188

No files matched your search

+12 -1
View File
@@ -167,9 +167,20 @@ export const GET = apiHandler(async () => {
a.username.localeCompare(b.username),
);
// A wipe whose copies are already gone waits for the next nightly run
// before its request is removed (see `offhost-purge.ts`); nothing of it is
// left in the bucket, so it is not counted as a pending deletion.
const stillInBucket = {
NOT: {
reason: "data_wiped",
lastFailure: null,
lastAttemptAt: { not: null },
},
};
const [purgeCount, oldestPurge, lifecycle] = await Promise.all([
prisma.offhostPurgeRequest.count(),
prisma.offhostPurgeRequest.count({ where: stillInBucket }),
prisma.offhostPurgeRequest.findFirst({
where: stillInBucket,
orderBy: { requestedAt: "asc" },
select: { requestedAt: true, lastFailure: true },
}),
+51 -2
View File
@@ -227,7 +227,11 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
it("surfaces an externalId row as updated when it already existed (updateMany)", async () => {
mMeasurement().findMany.mockResolvedValue([
{ type: "WEIGHT", externalId: "ext-1" },
{
type: "WEIGHT",
externalId: "ext-1",
measuredAt: new Date("2026-05-01T08:00:00Z"),
},
]);
const res = await POST(
@@ -400,7 +404,11 @@ describe("POST /api/import/csv — contextless blood glucose", () => {
it("keeps a re-upload of the same external id idempotent", async () => {
// The key already exists under (userId, type, source=IMPORT, externalId).
mMeasurement().findMany.mockResolvedValue([
{ type: "BLOOD_GLUCOSE", externalId: "sensor-1" },
{
type: "BLOOD_GLUCOSE",
externalId: "sensor-1",
measuredAt: new Date("2024-04-03T02:15:00Z"),
},
]);
const res = await POST(
@@ -513,6 +521,47 @@ describe("POST /api/import/csv — re-import", () => {
expect(mMeasurement().updateMany).toHaveBeenCalledTimes(1);
});
it("re-folds the day a moved reading left as well as the day it moved to", async () => {
mMeasurement().findMany.mockResolvedValue([stored("ext-1")]);
await POST(
csvRequest(
[HEADER, "WEIGHT,80.5,kg,2026-05-03T08:00:00Z,,after run,ext-1"].join(
"\n",
),
),
);
const days = vi
.mocked(recomputeBucketsForMeasurement)
.mock.calls.map(
([, type, at]) => `${type}@${at.toISOString().slice(0, 10)}`,
)
.sort();
expect(days).toEqual(["WEIGHT@2026-05-01", "WEIGHT@2026-05-03"]);
});
it("re-folds what it touched when a write chunk fails part-way", async () => {
mMeasurement().findMany.mockResolvedValue([]);
mMeasurement().createManyAndReturn.mockRejectedValue(
new Error("connection reset"),
);
await expect(
POST(
csvRequest(
[
HEADER,
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,",
"WEIGHT,80.7,kg,2026-05-02T08:00:00Z,,,",
].join("\n"),
),
),
).rejects.toThrow("connection reset");
const days = vi
.mocked(recomputeBucketsForMeasurement)
.mock.calls.map(([, , at]) => at.toISOString().slice(0, 10))
.sort();
expect(days).toEqual(["2026-05-01", "2026-05-02"]);
});
it("probes per type in bounded chunks and writes updates in bounded batches", async () => {
const lines = Array.from({ length: 2500 }, (_, i) => {
const at = new Date(Date.UTC(2026, 0, 1) + i * 60_000).toISOString();
+87 -71
View File
@@ -284,6 +284,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
type: m.type as MeasurementType,
measuredAt: m.measuredAt,
});
// An update that moves the reading to another instant leaves the day
// it came from: that day is re-folded too, or the old day keeps a
// reading that no longer exists there.
if (stored && stored.measuredAt.getTime() !== m.measuredAt.getTime()) {
touchedMeasurements.push({
type: m.type as MeasurementType,
measuredAt: stored.measuredAt,
});
}
}
// --- externalId-less rows: create-only, deduped on the natural unique key
@@ -353,83 +362,90 @@ export const POST = apiHandler(async (request: NextRequest) => {
type: MeasurementType;
measuredAt: Date;
}> = [];
for (const chunk of chunked(toCreate, WRITE_CHUNK)) {
const rows = await prisma.measurement.createManyAndReturn({
data: chunk,
skipDuplicates: true,
select: { id: true, type: true, measuredAt: true },
});
createdRows.push(...rows);
createdCount += rows.length;
}
for (const chunk of chunked([...extUpdates.values()], WRITE_CHUNK)) {
await prisma.$transaction(
chunk.map((m) =>
prisma.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,
// No-op on a live row, a deliberate RESURRECTION on a
// tombstoned one — IMPORT rows are re-importable by design, so
// a re-imported externalId brings the row back (mirrors the
// source-owned sync resurrect rule).
deletedAt: null,
},
}),
),
);
}
// The re-fold runs whatever happens to the writes. A chunk that fails
// part-way leaves the earlier chunks written, and a retry reads those
// rows as duplicates and touches nothing: without the re-fold here their
// days would keep the rollups from before the import for good. Re-folding
// a (type, day) nothing landed on is harmless.
try {
for (const chunk of chunked(toCreate, WRITE_CHUNK)) {
const rows = await prisma.measurement.createManyAndReturn({
data: chunk,
skipDuplicates: true,
select: { id: true, type: true, measuredAt: true },
});
createdRows.push(...rows);
createdCount += rows.length;
}
for (const chunk of chunked([...extUpdates.values()], WRITE_CHUNK)) {
await prisma.$transaction(
chunk.map((m) =>
prisma.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,
// No-op on a live row, a deliberate RESURRECTION on a
// tombstoned one — IMPORT rows are re-importable by design, so
// a re-imported externalId brings the row back (mirrors the
// source-owned sync resurrect rule).
deletedAt: null,
},
}),
),
);
}
void emitInsertedMeasurementArrivals(
userId,
createdRows,
"csv_import",
).catch(() => {});
void maybeEnqueueMorningRefresh(
userId,
createdRows
.filter((row) => row.type === "SLEEP_DURATION")
.map((row) => row.measuredAt),
).catch(() => {});
void emitInsertedMeasurementArrivals(
userId,
createdRows,
"csv_import",
).catch(() => {});
void maybeEnqueueMorningRefresh(
userId,
createdRows
.filter((row) => row.type === "SLEEP_DURATION")
.map((row) => row.measuredAt),
).catch(() => {});
// Reconcile `inserted` against what `createMany` actually wrote
// (mirrors the batch route's raced-duplicate downgrade). Under
// `skipDuplicates` a conflicting row is silently absorbed — the key is
// present in the table either way, so we cannot identify the SPECIFIC
// raced lines; we only need the counters and per-line statuses to sum
// to the truth. Downgrade enough `inserted` lines to `duplicate` so
// the envelope matches the DB write count.
const racedDuplicates = toCreate.length - createdCount;
if (racedDuplicates > 0) {
let downgraded = 0;
for (const [line, outcome] of writeOutcome) {
if (downgraded >= racedDuplicates) break;
if (outcome === "inserted") {
writeOutcome.set(line, "duplicate");
inserted--;
skipped++;
downgraded++;
// Reconcile `inserted` against what `createMany` actually wrote
// (mirrors the batch route's raced-duplicate downgrade). Under
// `skipDuplicates` a conflicting row is silently absorbed — the key is
// present in the table either way, so we cannot identify the SPECIFIC
// raced lines; we only need the counters and per-line statuses to sum
// to the truth. Downgrade enough `inserted` lines to `duplicate` so
// the envelope matches the DB write count.
const racedDuplicates = toCreate.length - createdCount;
if (racedDuplicates > 0) {
let downgraded = 0;
for (const [line, outcome] of writeOutcome) {
if (downgraded >= racedDuplicates) break;
if (outcome === "inserted") {
writeOutcome.set(line, "duplicate");
inserted--;
skipped++;
downgraded++;
}
}
}
} finally {
// 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
// — and the status re-warm for the touched types. Best-effort: a
// populator hiccup never fails the importer.
await afterMeasurementMutation(userId, touchedMeasurements, "import.csv");
}
// 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 — and
// the status re-warm for the touched types. Best-effort: a populator
// hiccup never fails the importer.
await afterMeasurementMutation(userId, touchedMeasurements, "import.csv");
await auditLog("import.csv.upload", {
userId,
ipAddress: getClientIp(request),
@@ -29,6 +29,7 @@ import { annotate } from "@/lib/logging/context";
import { updateMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
import {
computeReminderNextDueAt,
parseReminderAnchor,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
@@ -141,9 +142,12 @@ export const PATCH = apiHandler(
updateData.intervalDays = null;
}
}
const timezone = await resolveTimezone(user.id);
if (data.anchorDate !== undefined) {
updateData.anchorDate =
data.anchorDate != null ? new Date(data.anchorDate) : null;
data.anchorDate != null
? parseReminderAnchor(data.anchorDate, timezone)
: null;
}
if (data.notifyHour !== undefined) updateData.notifyHour = data.notifyHour;
if (data.location !== undefined) updateData.location = data.location;
@@ -166,7 +170,6 @@ export const PATCH = apiHandler(
// auto-clear a few lines up — so keying off its own keys (present, even
// when the value is `null`) can never diverge from what the database
// ends up holding.
const timezone = await resolveTimezone(user.id);
const now = new Date();
const merged: ReminderScheduleInput = {
intervalDays: Object.hasOwn(updateData, "intervalDays")
@@ -193,10 +196,10 @@ export const PATCH = apiHandler(
// open, overdue check-up quietly moved it to its next slot. Compared by
// value, because a client may resend every field on each save.
//
// The first due date is compared by CALENDAR DAY in the profile zone. The
// web form sends a date as the browser's local midnight, and a booster
// row stores the dose instant plus N days, so the same day arrives as a
// different instant. A resent same-day anchor is dropped from the write
// The first due date is compared by CALENDAR DAY in the profile zone. A
// client may send a date-time (older web builds sent the browser's local
// midnight), and a booster row stores the dose instant plus N days, so the
// same day arrives as a different instant. A resent same-day anchor is dropped from the write
// altogether, so the stored instant stays what it was.
const sameInstant = (a: Date | null, b: Date | null) =>
(a?.getTime() ?? null) === (b?.getTime() ?? null);
+5 -1
View File
@@ -21,6 +21,7 @@ import { withIdempotency } from "@/lib/idempotency";
import { createMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
import {
computeReminderNextDueAt,
parseReminderAnchor,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
@@ -88,7 +89,10 @@ async function postReminder(request: NextRequest): Promise<Response> {
const timezone = await resolveTimezone(user.id);
const now = new Date();
const anchorDate = data.anchorDate != null ? new Date(data.anchorDate) : null;
const anchorDate =
data.anchorDate != null
? parseReminderAnchor(data.anchorDate, timezone)
: null;
// Server-authoritative next-due. No satisfy yet → anchors on
// anchorDate ?? createdAt (createdAt ≈ now for a fresh row).