chore(release): v1.28.25 — every integration held to the same standard

Platform-wide hardening: the failure classes found live this week hunted down
across every integration.

Fitbit carried the same tombstone wedge fixed for Google — probe now matches
soft-deleted rows and the update resurrects them; the comment describing a
partial unique index that never existed is corrected. Sleep segment ids are
stable across re-scoring for Withings (session+segment-start), WHOOP/Polar
(one segment per stage, no positional index) and Oura (run-start keyed), each
with a session-scoped sweep that soft-deletes rows a re-score orphaned — so
legacy and affected nights heal as they are re-read.

One platform rule for deleted rows: source-owned readings resurrect on
re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV
re-imports, Apple stats day-totals); Apple sample-grain deletions stick per
the paired-client contract, and the mood bulk route's tombstone match is now
a true no-op reported as duplicate. Import counts reconcile against actual
createMany counts.

Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so
an expired connection parks instead of stamping success hourly; backfill and
sleep-repair markers stamp only after a clean pass (pg-boss retries are now
real); failed measurement writes hold the watermark; no-op updates are
skipped; mapping.md tells the current truth.

Scale: doctor-report reads only consumed columns and enabled sections;
exports and backups keyset-paginate; achievements tally via SQL buckets;
series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear
two-pointer; distinct-type scans run server-side; the graded-series cold
fallback is bounded; the fitbit defer tracker loops instead of spreading.
This commit is contained in:
Marc-André Bombeck committed 2026-07-11 01:54:12 +02:00
1 parent 1085a1e056
commit 9c63d3ce20
79 files changed
+3221 -564

No files matched your search

@@ -17,6 +17,9 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
// v1.28.25 — the achievements vitals read is a raw (day, hour, type)
// bucket aggregation.
$queryRaw: vi.fn(),
measurement: { findMany: vi.fn(), count: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() },
@@ -31,6 +34,14 @@ vi.mock("@/lib/db", () => ({
createMany: vi.fn(),
},
auditEvent: { findMany: vi.fn() },
// v1.28.25 — the achievements aggregation previously aborted mid-flight
// on this mock (no `auditLog` model), which the old assertion masked
// because the vitals findMany fired before the abort. With the vitals
// read on `$queryRaw`, the sleep findMany the assertion now pins runs
// AFTER these models — mock them so the aggregation completes.
auditLog: { findMany: vi.fn() },
userHealthProfile: { findUnique: vi.fn() },
illnessEpisode: { findMany: vi.fn() },
},
}));
@@ -95,6 +106,7 @@ beforeEach(() => {
remaining: 9,
resetAt: Date.now() + 3_600_000,
});
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.count).mockResolvedValue(0);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
@@ -107,6 +119,12 @@ beforeEach(() => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: null,
} as never);
vi.mocked(prisma.passkey.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
});
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
@@ -154,5 +172,14 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
where: expect.objectContaining({ deletedAt: null }),
});
}
// v1.28.25 — the vitals read moved to a raw (day, hour, type) bucket
// aggregation; the soft-delete scope must survive on that path too.
const rawCalls = vi.mocked(prisma.$queryRaw).mock.calls;
expect(rawCalls.length).toBeGreaterThan(0);
for (const [strings] of rawCalls) {
expect((strings as unknown as readonly string[]).join("?")).toContain(
`"deleted_at" IS NULL`,
);
}
});
});
+19 -3
View File
@@ -23,6 +23,7 @@ import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { toCSV, formatMeasurementsForExport } from "@/lib/export";
import { findMeasurementsPaged } from "@/lib/export/paged-measurements";
import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
@@ -49,9 +50,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
const [measurements, userTz, sourcePriorityJson, profile] = await Promise.all(
[
prisma.measurement.findMany({
where,
orderBy: { measuredAt: "desc" },
// v1.28.25 — keyset-paginated read with a narrow select. The route
// keeps its contract (an absent `since`/`until` means the full
// history), but the read is now chunked so an unbounded window on a
// CGM / per-sample-HR account never materialises the whole table in
// one query. The select is exactly the formatter's `ExportMeasurement`
// shape + note decryption + the `id` cursor key.
findMeasurementsPaged(prisma, where, {
id: true,
type: true,
value: true,
unit: true,
measuredAt: true,
source: true,
notes: true,
notesEncrypted: true,
glucoseContext: true,
sleepStage: true,
deviceType: true,
}),
resolveUserTimezone(user.id),
loadUserSourcePriority(user.id),
+25 -4
View File
@@ -12,6 +12,7 @@ import {
formatMoodEntriesForExport,
} from "@/lib/export";
import { shapeMeasurementNotes, shapeMoodNote } from "@/lib/crypto/note-cipher";
import { findMeasurementsPaged } from "@/lib/export/paged-measurements";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { NextRequest, NextResponse } from "next/server";
@@ -59,13 +60,33 @@ export const GET = apiHandler(async (request: NextRequest) => {
const data: Record<string, unknown> = {};
if (type === "measurements" || type === "all") {
const measurements = await prisma.measurement.findMany({
// v1.28.25 — keyset-paginated read with a narrow select. The legacy
// export has no date window, so a CGM / per-sample-HR account holds a
// six-figure row set; one unbounded full-width `findMany` here was the
// scale hazard. The select carries exactly the fields the formatter +
// note decryption consume (`ExportMeasurement` + `shapeMeasurementNotes`
// + the `id` cursor key) — no Bytes column beyond the note ciphertext
// the export must decrypt.
const measurements = await findMeasurementsPaged(
prisma,
// v1.4.41 W-DELETED-2 — exclude soft-deleted measurements from
// the legacy /api/export endpoint so CSV + JSON downloads stay
// consistent with the live measurement reads.
where: { userId, deletedAt: null },
orderBy: { measuredAt: "desc" },
});
{ userId, deletedAt: null },
{
id: true,
type: true,
value: true,
unit: true,
measuredAt: true,
source: true,
notes: true,
notesEncrypted: true,
glucoseContext: true,
sleepStage: true,
deviceType: true,
},
);
// v1.11.5 — sleep collapses to one row per night by default (the
// formatter's `night` granularity), matching the per-type CSV route.
// Thread the user's source-priority so a multi-source night dedups to
@@ -3,6 +3,9 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
// v1.28.25 — the achievements vitals read is a raw (day, hour, type)
// bucket aggregation.
$queryRaw: vi.fn(),
measurement: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
@@ -84,6 +87,7 @@ beforeEach(() => {
// v1.4.34 IW-G — reset achievement LRU between tests so each case
// observes a cold cache.
__resetAllCachesForTests();
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
@@ -3,6 +3,9 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
// v1.28.25 — the achievements vitals read is a raw (day, hour, type)
// bucket aggregation.
$queryRaw: vi.fn(),
measurement: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
@@ -70,6 +73,7 @@ beforeEach(() => {
// v1.4.34 IW-G — reset achievement LRU between tests so each case
// observes a cold cache.
__resetAllCachesForTests();
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
@@ -16,6 +16,9 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
// v1.28.25 — the achievements vitals read is a raw (day, hour, type)
// bucket aggregation.
$queryRaw: vi.fn(),
measurement: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
@@ -76,6 +79,7 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
__resetAllCachesForTests();
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
@@ -123,6 +127,7 @@ describe("GET /api/gamification/achievements — achievements module gate", () =
expect(body.meta.module).toBe("achievements");
// The disappearance is total: no badge aggregation ran.
expect(prisma.$queryRaw).not.toHaveBeenCalled();
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
expect(prisma.userAchievement.findMany).not.toHaveBeenCalled();
expect(prisma.userAchievement.createMany).not.toHaveBeenCalled();
+1 -1
View File
@@ -55,6 +55,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
const imported = await syncUserGoogleHealth(user.id, { fullSync });
const { imported } = await syncUserGoogleHealth(user.id, { fullSync });
return apiSuccess({ imported, fullSync });
});
+57 -1
View File
@@ -103,7 +103,11 @@ beforeEach(() => {
updateMany: ReturnType<typeof vi.fn>;
};
measurement.findMany.mockResolvedValue([]);
measurement.createMany.mockResolvedValue({ count: 0 });
// Echo the attempted chunk size, matching Postgres semantics when no row
// collides — the route now reconciles `inserted` against this count.
measurement.createMany.mockImplementation(
async (arg: { data: unknown[] }) => ({ count: arg.data.length }),
);
measurement.updateMany.mockResolvedValue({ count: 0 });
(
prisma.$transaction as unknown as {
@@ -226,6 +230,29 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
expect(mMeasurement().createMany).not.toHaveBeenCalled();
});
it("resurrects a tombstoned externalId row on re-import (deletedAt: null in update)", async () => {
// The ext-probe is deliberately deletedAt-less, so a tombstoned IMPORT
// row matches like a live one; the update must carry the resurrection
// (IMPORT rows are re-importable by design).
mMeasurement().findMany.mockResolvedValue([
{ type: "WEIGHT", externalId: "ext-tomb" },
]);
const res = await POST(
csvRequest(
[HEADER, "WEIGHT,81.0,kg,2026-05-01T08:00:00Z,,,ext-tomb"].join("\n"),
),
);
const body = (await res.json()) as CsvEnvelope;
expect(body.data?.updated).toBe(1);
expect(body.data?.rows[0].status).toBe("updated");
const updateArg = mMeasurement().updateMany.mock.calls[0][0] as {
data: { value: number; deletedAt: Date | null };
};
expect(updateArg.data.value).toBe(81.0);
expect(updateArg.data.deletedAt).toBeNull();
});
it("inserts an externalId row when it does not exist yet", async () => {
mMeasurement().findMany.mockResolvedValue([]);
const res = await POST(
@@ -274,4 +301,33 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
// Only the first survivor reaches the bulk insert.
expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(1);
});
it("reconciles `inserted` against the createMany count when skipDuplicates absorbs a race", async () => {
// Two fresh rows attempted, but a concurrent double-submit already
// landed one of them — `skipDuplicates` absorbs the conflict and the
// count comes back short. The envelope must sum to the DB truth: one
// inserted, one downgraded to skipped/duplicate.
mMeasurement().createMany.mockImplementation(
async (arg: { data: unknown[] }) => ({ count: arg.data.length - 1 }),
);
const res = await POST(
csvRequest(
[
HEADER,
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,",
"WEIGHT,81.5,kg,2026-05-01T09:00:00Z,,,",
].join("\n"),
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as CsvEnvelope;
expect(body.data?.inserted).toBe(1);
expect(body.data?.skipped).toBe(1);
const statuses = body.data?.rows.map((r) => r.status).sort();
expect(statuses).toEqual(["inserted", "skipped"]);
expect(body.data?.rows.find((r) => r.status === "skipped")?.reason).toBe(
"duplicate",
);
});
});
+29 -1
View File
@@ -239,12 +239,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
...plainInserts.map(buildCreateData),
];
const CREATE_CHUNK = 200;
let createdCount = 0;
await prisma.$transaction(async (tx) => {
for (let i = 0; i < toCreate.length; i += CREATE_CHUNK) {
await tx.measurement.createMany({
const res = await tx.measurement.createMany({
data: toCreate.slice(i, i + CREATE_CHUNK),
skipDuplicates: true,
});
createdCount += res.count;
}
for (const m of extUpdates.values()) {
await tx.measurement.updateMany({
@@ -262,11 +264,37 @@ export const POST = apiHandler(async (request: NextRequest) => {
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,
},
});
}
});
// 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++;
}
}
}
// 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.
@@ -104,6 +104,10 @@ vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({
// enqueue pipeline itself is covered by the comprehensive-generate tests.
vi.mock("@/lib/insights/comprehensive-generate", () => ({
enqueueStatusRefillForUser: vi.fn(async () => 7),
// v1.28.25 — the route shares the lib's comparison-snapshot builder
// (its private near-copy was deleted). Null = comparison toggle off,
// matching the pre-existing fixtures (no dashboardWidgetsJson set).
buildComparisonSnapshotForUser: vi.fn(async () => null),
}));
vi.mock("@/lib/logging/context", () => ({
+8 -163
View File
@@ -30,10 +30,7 @@ import {
buildDerivedBriefingPrompt,
} from "@/lib/insights/derived-briefing";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import {
buildUserPrompt,
type ComparisonSnapshot,
} from "@/lib/ai/prompts/insight-system-prompt";
import { buildUserPrompt } from "@/lib/ai/prompts/insight-system-prompt";
import {
buildSystemPromptWithReferences,
buildBriefingPersonalisationBlock,
@@ -43,18 +40,7 @@ import {
getSelfContextTextForUser,
} from "@/lib/ai/coach/about-me";
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
resolveDashboardLayout,
type ComparisonBaseline,
} from "@/lib/dashboard-layout";
import type { MeasurementType } from "@/generated/prisma/client";
import {
insightResultSchema,
singleUserTurn,
@@ -88,7 +74,13 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate";
// v1.28.25 — the comparison-snapshot builder was a private near-copy of
// the lib export (drifted only in comments); the route now shares the
// one implementation the nightly warm pass uses.
import {
buildComparisonSnapshotForUser,
enqueueStatusRefillForUser,
} from "@/lib/insights/comprehensive-generate";
export const dynamic = "force-dynamic";
@@ -120,153 +112,6 @@ export function resolveInsightsRateLimit(): number {
return parsed;
}
/**
* v1.4.16 phase B8 — fetch the user's persisted comparison toggle and
* build a `ComparisonSnapshot` for the prompt builder. Returns null
* when comparison is off (most users), so the prompt builder skips
* the context block entirely.
*
* Snapshot construction reuses the analytics `summarize()` helper
* (which now emits `avg30LastMonth` + `avg30LastYear`) so the numbers
* the LLM sees match the numbers the dashboard tile renders — no
* second source of truth for the comparison delta.
*/
async function buildComparisonSnapshotForUser(
userId: string,
): Promise<ComparisonSnapshot | null> {
// The features payload doesn't carry prior-period values — pulling
// a fresh `summarize()` per metric below keeps the snapshot
// self-contained without retrofitting features.ts.
const row = await prisma.user.findUnique({
where: { id: userId },
select: { dashboardWidgetsJson: true },
});
const layout = resolveDashboardLayout(row?.dashboardWidgetsJson);
const baseline: ComparisonBaseline = layout.comparisonBaseline ?? "none";
if (baseline === "none") return null;
// Pull every measurement type once and run summarize() so the
// prior-period averages line up with what the dashboard analytics
// computes. Only include rows where the LLM can sensibly narrate
// a prior comparison (i.e. the metric exists in our snapshot type
// mapping below).
const typeToSnapshotKey: Record<string, string> = {
WEIGHT: "weight",
BLOOD_PRESSURE_SYS: "bloodPressureSys",
BLOOD_PRESSURE_DIA: "bloodPressureDia",
PULSE: "pulse",
BODY_FAT: "bodyFat",
SLEEP_DURATION: "sleep",
ACTIVITY_STEPS: "steps",
};
const typeUnits: Record<string, string> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
SLEEP_DURATION: "h",
ACTIVITY_STEPS: "",
};
const types = Object.keys(typeToSnapshotKey) as MeasurementType[];
// The snapshot only feeds summarize()'s avg30 / avg30LastMonth /
// avg30LastYear. The widest of those reaches back into the [365, 395)-day
// window (ageMs < 395 * DAY in meanOfWindow), so nothing older than 395
// days can change a result. Bound the read at 400 days — a 5-day floor over
// the strict-less-than boundary — instead of scanning the full history,
// which on multi-year accounts is tens of thousands of rows per type on the
// page-blocking generate path and the nightly pregenerate cron.
const sinceMeasuredAt = new Date(Date.now() - 400 * 86_400_000);
const rows = await Promise.all(
types.map(async (type) => {
// SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the
// raw stage rows mislabels a per-stage average as a night total (and
// double-counts a bare ASLEEP aggregate against its granular twin). Route
// the comparison through the per-night dedup reconstruction so the
// current/baseline averages are per-night TIME-ASLEEP totals in minutes.
let dataPoints: DataPoint[];
if (type === "SLEEP_DURATION") {
const [sleepRows, sleepTz, sleepPriority] = await Promise.all([
prisma.measurement.findMany({
where: {
userId,
type,
deletedAt: null,
measuredAt: { gte: sinceMeasuredAt },
},
orderBy: { measuredAt: "asc" },
select: {
measuredAt: true,
value: true,
sleepStage: true,
source: true,
// Writer-level collapse: two HealthKit apps behind one
// source (watch stages vs phone in-bed) must not blend.
deviceType: true,
},
}),
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
dataPoints = reconstructSleepNights(
sleepRows as SleepStageRow[],
sleepTz,
sleepPriority,
)
.filter((n) => n.asleepMinutes > 0)
.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes }));
} else {
const measurements = await prisma.measurement.findMany({
where: {
userId,
type,
deletedAt: null,
measuredAt: { gte: sinceMeasuredAt },
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
dataPoints = measurements.map((m): DataPoint => ({
date: m.measuredAt,
value: m.value,
}));
}
const summary = summarize(dataPoints);
const baselineAvg =
baseline === "lastMonth"
? (summary.avg30LastMonth ?? null)
: (summary.avg30LastYear ?? null);
const currentAvg = summary.avg30 ?? null;
const delta =
currentAvg !== null && baselineAvg !== null
? Math.round((currentAvg - baselineAvg) * 100) / 100
: null;
const deltaPercent =
delta !== null && baselineAvg !== null && baselineAvg !== 0
? Math.round((delta / Math.abs(baselineAvg)) * 100 * 10) / 10
: null;
return {
type: typeToSnapshotKey[type] ?? type,
currentAvg,
baselineAvg,
delta,
deltaPercent,
unit: typeUnits[type] ?? "",
};
}),
);
// Drop fully-empty rows (current AND baseline missing) so the prompt
// doesn't pad with noise. Keep partial rows (one side null) — the
// block renderer flags them with "no prior-period data available"
// which is useful context for the model.
const metrics = rows.filter(
(row) => row.currentAvg !== null || row.baselineAvg !== null,
);
return { baseline, metrics };
}
/**
* GET /api/insights/generate — read-only advisor read.
*
@@ -145,6 +145,9 @@ beforeEach(() => {
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
// v1.28.25 — the latest-ever-per-type read is a raw `DISTINCT ON`
// (Prisma's `distinct` dedups client-side after pulling every row).
(prisma.$queryRawUnsafe as ReturnType<typeof vi.fn>).mockResolvedValue([]);
});
describe("GET /api/insights/targets — mood-rollup tier swap", () => {
@@ -291,36 +294,33 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
expect(moodTarget).toBeUndefined();
});
it("floors the `findMany distinct` latest-ever-per-type read at one year", async () => {
// Audit High finding 3: the `findMany({orderBy: measuredAt desc,
// distinct: ["type"]})` for "absolute latest measurement per type"
// used to run WITHOUT a `measuredAt >=` floor. On a 347 k-row
// tenant Postgres still has to sort the full set before applying
// DISTINCT ON because Prisma's `distinct` does not compile to PG's
// `DISTINCT ON` — it dedups in the driver. v1.4.40 adds a
// 365-day floor so the planner-side walk is bounded.
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
it("reads latest-ever-per-type via raw DISTINCT ON with the one-year floor", async () => {
// Audit High finding 3 established the 365-day floor; v1.28.25 moves
// the read onto a real Postgres `DISTINCT ON` (Prisma's `distinct`
// dedups in the driver AFTER pulling every row in the window, which
// on a dense-type tenant shipped a year of raw rows to Node just to
// keep seven). Pin the raw query: DISTINCT ON, per-type descending
// walk, user id + floored date as bound parameters.
await callGet(makeReq());
// Find the `distinct: ["type"]` call.
const calls = (
prisma.measurement.findMany as ReturnType<typeof vi.fn>
).mock.calls.map((c) => c[0]);
const distinctCall = calls.find(
(c) =>
c &&
Array.isArray(c.distinct) &&
c.distinct[0] === "type" &&
c.orderBy?.measuredAt === "desc",
const rawCalls = (prisma.$queryRawUnsafe as ReturnType<typeof vi.fn>).mock
.calls;
const distinctCall = rawCalls.find(
(c) => typeof c[0] === "string" && c[0].includes("DISTINCT ON"),
);
expect(distinctCall).toBeDefined();
// The `measuredAt >= oneYearAgo` floor must be present.
expect(distinctCall?.where?.measuredAt).toBeDefined();
expect(distinctCall?.where?.measuredAt?.gte).toBeInstanceOf(Date);
const flooredAt = distinctCall?.where?.measuredAt?.gte as Date;
const [sql, boundUserId, flooredAt] = distinctCall as [
string,
string,
Date,
];
expect(sql).toContain(`DISTINCT ON (m."type")`);
expect(sql).toContain(`ORDER BY m."type" ASC, m."measured_at" DESC`);
// Parameter-bound, never spliced: user id + date ride $1/$2.
expect(sql).toContain("$1");
expect(sql).toContain("$2");
expect(boundUserId).toBe("user-targets-1");
expect(flooredAt).toBeInstanceOf(Date);
const oneYearMs = 365 * 24 * 60 * 60 * 1000;
const ageMs = Date.now() - flooredAt.getTime();
// Floor is somewhere around one year ago — give a ±1-hour window
@@ -133,6 +133,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
source: "MOODLOG",
date: entry.date,
moodLoggedAt,
// No-op on a live row, a deliberate RESURRECTION on a tombstoned
// one — moodLog is the source of truth for the rows it minted; a
// created/updated event means the entry exists upstream (deletion
// arrives as its own `mood.deleted` event).
deletedAt: null,
},
create: {
userId: user.id,
@@ -160,6 +165,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
// Same resurrection rule as the externalId-keyed upsert above.
deletedAt: null,
},
create: {
userId: user.id,
@@ -308,3 +308,69 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
expect(data.entries[0].status).toBe("updated");
});
});
describe("POST /api/measurements/batch — stats tombstone resurrection", () => {
it("resurrects a tombstoned stats: day-total on re-post (deletedAt: null, status updated)", async () => {
const STEP_STATS_ID = "stats:HKQuantityTypeIdentifierStepCount:2026-06-21";
// The existence probe is deliberately deletedAt-less, so a tombstoned
// day-total row matches exactly like a live one and routes into the
// overwrite branch.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
type: "ACTIVITY_STEPS",
source: "APPLE_HEALTH",
externalId: STEP_STATS_ID,
},
] as never);
const res = await POST(
makeRequest({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
value: 9001,
unit: "count",
startDate: "2026-06-21T00:00:00.000Z",
endDate: "2026-06-21T23:59:59.000Z",
externalId: STEP_STATS_ID,
},
],
}),
);
expect(res.status).toBe(200);
const { data } = await readJson(res);
expect(data.updated).toBe(1);
expect(data.entries[0].status).toBe("updated");
// The overwrite carries the resurrection: the observer re-posts the
// day's canonical total, so the update data pins `deletedAt: null`.
expect(prisma.measurement.updateMany).toHaveBeenCalledTimes(1);
const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
const updateData = (
updateArg as { data: { value: number; deletedAt: Date | null } }
).data;
expect(updateData.value).toBe(9001);
expect(updateData.deletedAt).toBeNull();
});
it("keeps a tombstoned SAMPLE-grain row suppressed (duplicate, no resurrect write)", async () => {
// Apple LWW contract: the iOS reconciler propagates HealthKit
// deletions for sample-grain rows, so a re-post of a tombstoned
// sample stays a checkpointing `duplicate` — no update runs at all.
const SAMPLE_ID = "D00DAD00-0000-4000-8000-000000000000";
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "PULSE", source: "APPLE_HEALTH", externalId: SAMPLE_ID },
] as never);
const res = await POST(
makeRequest({ entries: [hrBucketEntry(SAMPLE_ID, 61)] }),
);
expect(res.status).toBe(200);
const { data } = await readJson(res);
expect(data.duplicates).toBe(1);
expect(data.updated).toBe(0);
expect(data.entries[0].status).toBe("duplicate");
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
});
});
+8
View File
@@ -545,6 +545,14 @@ async function postBatch(request: NextRequest): Promise<Response> {
string | null,
deviceType: p.row.deviceType as Prepared["row"]["deviceType"],
sleepStage: p.row.sleepStage as Prepared["row"]["sleepStage"],
// No-op on a live row, a deliberate RESURRECTION on a
// tombstoned one — the observer re-posts the day's canonical
// total, so a `stats:` day-total row follows the
// source-owned resurrect rule (mirrors Google / Fitbit).
// SAMPLE-grain rows never reach this branch: their
// tombstones stick (the iOS LWW reconciler propagates
// HealthKit deletions and reports them as `duplicate`).
deletedAt: null,
},
});
results[p.index] = { index: p.index, status: "updated" };
@@ -6,6 +6,9 @@ vi.mock("@/lib/db", () => ({
measurement: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
auditLog: { create: vi.fn() },
// v1.28.25 — dense kinds (glucose / pulse) day-bucket in SQL for
// windows beyond the 90-day raw cap.
$queryRaw: vi.fn(),
},
}));
@@ -65,6 +68,7 @@ function req(query: string): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mg/dL",
@@ -122,6 +126,48 @@ describe("GET /api/measurements/series", () => {
expect(body.data.stats.count).toBe(1);
});
it("pairs BP via the sorted two-pointer merge exactly like the old scan (v1.28.25)", async () => {
// Pins the O(sys + dia) merge against the previous per-systolic full
// rescan: nearest diastolic within ±5 min wins, a tie keeps the
// EARLIER diastolic row, an out-of-window systolic pairs null.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const at = (iso: string) => new Date(iso);
const findManyMock = vi.mocked(prisma.measurement.findMany);
findManyMock.mockImplementation(((args: unknown) => {
const a = args as { where: { type: string } };
if (a.where.type === "BLOOD_PRESSURE_SYS") {
return Promise.resolve([
{ id: "s1", value: 120, measuredAt: at("2026-05-01T10:00:00Z") },
// Equidistant (±2 min) between the 10:03 and 10:07 dia rows.
{ id: "s2", value: 124, measuredAt: at("2026-05-01T10:05:00Z") },
{ id: "s3", value: 130, measuredAt: at("2026-05-01T10:10:00Z") },
// > 5 min from every diastolic row — stays unpaired.
{ id: "s4", value: 140, measuredAt: at("2026-05-01T11:00:00Z") },
]) as never;
}
if (a.where.type === "BLOOD_PRESSURE_DIA") {
return Promise.resolve([
{ id: "d1", value: 80, measuredAt: at("2026-05-01T10:01:00Z") },
{ id: "d2", value: 82, measuredAt: at("2026-05-01T10:03:00Z") },
{ id: "d3", value: 84, measuredAt: at("2026-05-01T10:07:00Z") },
{ id: "d4", value: 86, measuredAt: at("2026-05-01T10:11:00Z") },
]) as never;
}
return Promise.resolve([]) as never;
}) as never);
const res = await GET(req("kind=bloodPressure&days=30"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { points: Array<{ id: string; secondary: number | null }> };
};
expect(body.data.points.map((p) => [p.id, p.secondary])).toEqual([
["s1", 80], // nearest is d1 (1 min)
["s2", 82], // d2/d3 tie at 2 min — earlier row wins
["s3", 86], // nearest is d4 (1 min)
["s4", null], // nothing within ±5 min
]);
});
it("returns single-value series for kind=weight", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
@@ -377,6 +423,184 @@ describe("GET /api/measurements/series", () => {
);
});
describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)", () => {
it("day-buckets glucose in SQL beyond the 90-day raw window", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.$queryRaw)
// 1st query — day buckets.
.mockResolvedValueOnce([
{
bucket_start: new Date("2025-01-01T00:00:00Z"),
mean: 100,
min_value: 70,
max_value: 180,
},
{
bucket_start: new Date("2025-01-02T00:00:00Z"),
mean: 126,
min_value: 80,
max_value: 200,
},
] as never)
// 2nd query — raw-row aggregate for the stats strip.
.mockResolvedValueOnce([
{ n: 576, mean: 113.04, min: 70, max: 200, sd: 22.34 },
] as never);
const res = await GET(req("kind=glucose&days=365"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
unit: string;
points: Array<{
id: string;
at: string;
value: number;
secondary: number | null;
}>;
stats: {
mean: number;
min: number;
max: number;
stdDev: number;
count: number;
};
};
};
// No raw row walk — the read is SQL-aggregate only.
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
expect(body.data.unit).toBe("mg/dL");
expect(body.data.points).toEqual([
{
id: "day:2025-01-01",
at: "2025-01-01T00:00:00.000Z",
value: 100,
secondary: null,
},
{
id: "day:2025-01-02",
at: "2025-01-02T00:00:00.000Z",
value: 126,
secondary: null,
},
]);
// Stats come from the RAW-row aggregate (count = raw samples, not
// day buckets), matching the raw path's mg/dL parity convention.
expect(body.data.stats).toEqual({
mean: 113,
min: 70,
max: 200,
stdDev: 22,
count: 576,
});
});
it("converts day-bucketed glucose for a mmol/L-preference user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([
{
bucket_start: new Date("2025-01-01T00:00:00Z"),
mean: 100,
min_value: 70,
max_value: 180,
},
] as never)
.mockResolvedValueOnce([
{ n: 288, mean: 100, min: 70, max: 180, sd: 18 },
] as never);
const res = await GET(req("kind=glucose&days=3650"));
const body = (await res.json()) as {
data: {
unit: string;
points: Array<{ value: number }>;
stats: { mean: number };
};
};
expect(body.data.unit).toBe("mmol/L");
expect(body.data.points[0].value).toBe(5.5); // 100 mg/dL → 5.5 mmol/L
expect(body.data.stats.mean).toBe(5.5);
});
it("day-buckets pulse beyond 90 days with the day's min/max band", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([
{
bucket_start: new Date("2025-06-01T00:00:00Z"),
mean: 71.666,
min_value: 52,
max_value: 143,
},
] as never)
.mockResolvedValueOnce([
{ n: 24, mean: 71.666, min: 58, max: 132, sd: 9.876 },
] as never);
const res = await GET(req("kind=pulse&days=365"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
points: Array<{
id: string;
value: number;
valueMin: number | null;
valueMax: number | null;
}>;
stats: { mean: number; stdDev: number; count: number };
};
};
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
expect(body.data.points).toEqual([
{
id: "day:2025-06-01",
at: "2025-06-01T00:00:00.000Z",
value: 71.67,
secondary: null,
valueMin: 52,
valueMax: 143,
},
]);
expect(body.data.stats.mean).toBe(71.67);
expect(body.data.stats.stdDev).toBe(9.88);
expect(body.data.stats.count).toBe(24);
});
it("keeps the 90-day window on the raw path (byte-identical short ranges)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "g1", value: 100, measuredAt: new Date("2026-06-01T08:00:00Z") },
] as never);
const res = await GET(req("kind=glucose&days=90"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { points: Array<{ id: string }> };
};
// Raw read, individual row ids — the SQL bucket path never fires.
expect(prisma.$queryRaw).not.toHaveBeenCalled();
expect(body.data.points[0].id).toBe("g1");
});
it("keeps sparse kinds raw even on the ten-year window", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "w1", value: 78.4, measuredAt: new Date() },
] as never);
const res = await GET(req("kind=weight&days=3650"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { points: Array<{ id: string }> };
};
expect(prisma.$queryRaw).not.toHaveBeenCalled();
expect(body.data.points[0].id).toBe("w1");
});
});
describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+161 -13
View File
@@ -35,6 +35,21 @@ import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
*/
const SLEEP_SERIES_MAX_DAYS = 365;
/**
* v1.28.25 — sample-dense kinds read raw rows only up to this window.
* A CGM account stores ~288 BLOOD_GLUCOSE rows/day (105k+ rows/year) and
* per-sample PULSE piles up similarly, so the 365 / 3650-day requests
* walked a six-figure row set into JS, mapped it twice, and serialized
* everything. Mirrors the sleep precedent above, but instead of silently
* clamping the window, requests beyond the cap keep their full range and
* are day-bucketed in SQL (one point per day, mean + min/max band) — the
* chart still shows the whole requested history, just at daily grain.
* The short windows users actually browse (7 / 30 / 90 d) stay raw and
* byte-identical. Sparse kinds (weight, BP, …) are untouched.
*/
const DENSE_SERIES_RAW_WINDOW_DAYS = 90;
const DENSE_SERIES_KINDS: ReadonlySet<string> = new Set(["glucose", "pulse"]);
const kindEnum = z.enum([
"weight",
"bloodPressure",
@@ -204,7 +219,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
// v1.16.16 — when glucose stats are computed over RAW mg/dL (parity with the
// detail page + FHIR), the branch fills this so the response below skips the
// generic over-points path that would double-round mmol/L figures.
let glucoseStats: {
// v1.28.25 — the dense day-bucket branch also fills it (stats stay
// aggregated over the RAW rows in SQL, not over the day-bucket points).
let statsOverride: {
mean: number;
min: number;
max: number;
@@ -306,24 +323,155 @@ export const GET = apiHandler(async (request: NextRequest) => {
}),
]);
// Pair by closest timestamp within ±5 minutes.
// Pair by closest timestamp within ±5 minutes. Both reads arrive
// sorted ascending, so the nearest-diastolic index is non-decreasing
// across the systolic walk — a two-pointer merge finds each pair in
// O(sys + dia) instead of the previous full diastolic rescan per
// systolic row (O(sys × dia) — quadratic on dense imports). Ties keep
// the earlier diastolic row, matching the old scan's strict-`<` keep.
const PAIR_WINDOW_MS = 5 * 60_000;
let diaIdx = 0;
points = sys.map((s) => {
let bestDia: { value: number; dt: number } | null = null;
for (const d of dia) {
const dt = Math.abs(d.measuredAt.getTime() - s.measuredAt.getTime());
if (dt > PAIR_WINDOW_MS) continue;
if (!bestDia || dt < bestDia.dt) {
bestDia = { value: d.value, dt };
}
const sysMs = s.measuredAt.getTime();
while (
diaIdx + 1 < dia.length &&
Math.abs(dia[diaIdx + 1].measuredAt.getTime() - sysMs) <
Math.abs(dia[diaIdx].measuredAt.getTime() - sysMs)
) {
diaIdx += 1;
}
const best = dia[diaIdx];
const paired =
best !== undefined &&
Math.abs(best.measuredAt.getTime() - sysMs) <= PAIR_WINDOW_MS;
return {
id: s.id,
at: s.measuredAt.toISOString(),
value: s.value,
secondary: bestDia?.value ?? null,
secondary: paired ? best.value : null,
};
});
} else if (
DENSE_SERIES_KINDS.has(kind) &&
days > DENSE_SERIES_RAW_WINDOW_DAYS
) {
// v1.28.25 — long-window read of a sample-dense kind (CGM glucose,
// per-sample / hourly pulse). Day-bucket in SQL instead of walking
// every raw row into JS: one aggregate pass in Postgres returns at
// most `days` rows regardless of sample density. Parameter-bound
// tagged-template `$queryRaw`, mirroring the rollup tier's
// `date_trunc(... ) GROUP BY` aggregate (measurement-rollups.ts).
const type = KIND_TO_TYPE[kind];
const bucketRows = await prisma.$queryRaw<
Array<{
bucket_start: Date;
mean: number;
min_value: number;
max_value: number;
}>
>`
SELECT
date_trunc('day', m."measured_at") AS bucket_start,
AVG(m."value")::double precision AS mean,
MIN(COALESCE(m."value_min", m."value"))::double precision AS min_value,
MAX(COALESCE(m."value_max", m."value"))::double precision AS max_value
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."type" = ${type}::"measurement_type"
AND m."measured_at" >= ${since}
AND m."deleted_at" IS NULL
GROUP BY date_trunc('day', m."measured_at")
ORDER BY bucket_start ASC
`;
// Stats stay aggregated over the RAW rows (not the day buckets) so the
// strip's mean/min/max/stdDev/count are the same figures the raw path
// computed: `AVG`/`MIN`/`MAX` over `value`, `STDDEV_POP` matching the
// JS population-variance helper, exact row count. Aggregate-only —
// no row transfer.
const [rawAgg] = await prisma.$queryRaw<
Array<{
n: number;
mean: number | null;
min: number | null;
max: number | null;
sd: number | null;
}>
>`
SELECT
COUNT(*)::int AS n,
AVG(m."value")::double precision AS mean,
MIN(m."value")::double precision AS min,
MAX(m."value")::double precision AS max,
STDDEV_POP(m."value")::double precision AS sd
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."type" = ${type}::"measurement_type"
AND m."measured_at" >= ${since}
AND m."deleted_at" IS NULL
`;
const round2 = (v: number) => Math.round(v * 100) / 100;
const dayId = (d: Date) => `day:${d.toISOString().slice(0, 10)}`;
if (kind === "glucose") {
// Same unit engine as the raw glucose branch: canonical mg/dL in
// the DB, converted ONCE at serialization to the user's preference.
const profile = await prisma.user.findUnique({
where: { id: user.id },
select: { glucoseUnit: true },
});
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
unit = glucoseUnit;
points = bucketRows.map((r) => ({
id: dayId(r.bucket_start),
at: r.bucket_start.toISOString(),
value: convertGlucose(r.mean, glucoseUnit),
secondary: null,
}));
if (
rawAgg !== undefined &&
rawAgg.n > 0 &&
rawAgg.mean !== null &&
rawAgg.min !== null &&
rawAgg.max !== null
) {
// Mirrors the raw branch's v1.16.16 parity: round the mg/dL
// aggregate to 2 decimals (summarize()'s convention), then
// convert each figure once.
statsOverride = {
mean: convertGlucose(round2(rawAgg.mean), glucoseUnit),
min: convertGlucose(rawAgg.min, glucoseUnit),
max: convertGlucose(rawAgg.max, glucoseUnit),
stdDev: convertGlucose(round2(rawAgg.sd ?? 0), glucoseUnit),
count: rawAgg.n,
};
}
} else {
// pulse — `value` is the day's average; valueMin/valueMax carry the
// day's low/high band (folding each hourly bucket's own spread via
// the COALESCE above), same band semantics as the per-hour shape.
points = bucketRows.map((r) => ({
id: dayId(r.bucket_start),
at: r.bucket_start.toISOString(),
value: round2(r.mean),
secondary: null,
valueMin: r.min_value,
valueMax: r.max_value,
}));
if (
rawAgg !== undefined &&
rawAgg.n > 0 &&
rawAgg.mean !== null &&
rawAgg.min !== null &&
rawAgg.max !== null
) {
statsOverride = {
mean: round2(rawAgg.mean),
min: rawAgg.min,
max: rawAgg.max,
stdDev: round2(rawAgg.sd ?? 0),
count: rawAgg.n,
};
}
}
} else {
const type = KIND_TO_TYPE[kind];
// v1.19.2 (iOS #34 extension) — pull the per-bucket spread for the
@@ -371,7 +519,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// already-converted+rounded points double-rounds the mean and stdDev
// for mmol/L users, drifting the last decimal off the detail page.
// Single-reading sets stay identical (100 → 5.5). Hand back stats here
// so the shared block below leaves a populated `glucoseStats` alone.
// so the shared block below leaves a populated `statsOverride` alone.
const rawDataPoints: DataPoint[] = rows.map((r) => ({
date: r.measuredAt,
value: r.value,
@@ -379,7 +527,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (rawDataPoints.length > 0) {
const rawSummary = summarize(rawDataPoints);
const rawValues = rawDataPoints.map((p) => p.value);
glucoseStats = {
statsOverride = {
mean:
rawSummary.mean === null
? 0
@@ -443,7 +591,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
unit,
points,
stats:
glucoseStats ??
statsOverride ??
(summary
? {
mean: summary.mean,
@@ -413,3 +413,71 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
expect(json.data.entries[1].status).toBe("inserted");
});
});
describe("POST /api/mood-entries/bulk — tombstone suppression", () => {
it("keeps a tombstoned match deleted: true no-op reported as duplicate", async () => {
// A soft-deleted row under the probe key: the sync feed already
// surfaced the deletion to paired clients, so a stale offline re-post
// must NOT resurrect it — and must not churn the hidden row either.
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({
id: "entry-tomb",
deletedAt: new Date("2026-05-01T00:00:00.000Z"),
} as never);
const res = await POST(
postReq({
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
externalId: "ios-uuid-tomb",
tagKeys: ["movies"],
},
],
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
inserted: number;
duplicates: number;
entries: Array<{ status: string; id?: string; externalId?: string }>;
};
};
expect(json.data.inserted).toBe(0);
expect(json.data.duplicates).toBe(1);
expect(json.data.entries[0].status).toBe("duplicate");
expect(json.data.entries[0].id).toBe("entry-tomb");
expect(json.data.entries[0].externalId).toBe("ios-uuid-tomb");
// No write at all on the hidden row: no upsert (value churn /
// updatedAt bump), no tag-link insert.
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
expect(createTagLinks).not.toHaveBeenCalled();
});
it("still refreshes a LIVE match in place (duplicate + upsert runs)", async () => {
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({
id: "entry-live",
deletedAt: null,
} as never);
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
id: "entry-live",
} as never);
const res = await POST(
postReq({
entries: [
{
mood: "OKAY",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
externalId: "ios-uuid-live",
},
],
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { duplicates: number };
};
expect(json.data.duplicates).toBe(1);
expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(1);
});
});
+20 -1
View File
@@ -227,9 +227,28 @@ async function postBulk(request: NextRequest): Promise<Response> {
// grows.
const existing = await prisma.moodEntry.findUnique({
where: probeWhere,
select: { id: true },
select: { id: true, deletedAt: true },
});
// Tombstone suppression: a soft-deleted match stays deleted. The
// user-facing DELETE route flips `deletedAt` so the `/api/sync/changes`
// feed surfaces the deletion to paired clients offline at delete time
// (see the MoodEntry schema note) — an offline client's later re-post
// of the same entry is stale state, not a recreation, and must not
// resurrect the row. True no-op: no value churn, no `updatedAt` bump,
// no tag-link writes on the hidden row; report `duplicate` so the
// client checkpoints past it exactly like a live-row match.
if (existing?.deletedAt) {
duplicates += 1;
results.push({
index: i,
status: "duplicate",
id: existing.id,
...(entry.externalId ? { externalId: entry.externalId } : {}),
});
continue;
}
const result = await prisma.moodEntry.upsert({
where: probeWhere,
create: {