mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user