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