Merge branch 'fix/v1438-db' into release/v1.43.8

This commit is contained in:
Marc-André Bombeck committed 2026-10-20 14:09:44 +02:00
commit 1d9758e3e8
91 files changed
+2107 -2058

No files matched your search

@@ -23,14 +23,6 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// v1.37.19 — the zone change enqueues a compliance re-fold.
const refoldMock = vi.hoisted(() =>
vi.fn(async () => ({ enqueued: true, error: null })),
);
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
enqueueUserMedicationComplianceBackfill: refoldMock,
}));
// The supplement nutrient rows are keyed by the same local day.
const nutrientMock = vi.hoisted(() => vi.fn(async () => ({ queued: true })));
vi.mock("@/lib/medications/ingredients/recompute-job", () => ({
@@ -110,11 +102,9 @@ describe("PUT /api/auth/me/timezone", () => {
expect(res.status).toBe(422);
});
// Watched red: with the re-fold enqueue removed from the route (the
// pre-v1.37.19 write), the changed-zone case fails — the compliance
// rollup's day keys were minted under the old zone and nothing ever
// re-bucketed them (the coverage probe compares counts, not keys).
it("enqueues a compliance re-fold when the zone actually changes", async () => {
// The supplement nutrient rows' day keys were minted under the old zone;
// without the enqueue nothing re-buckets them.
it("enqueues the nutrient recompute when the zone actually changes", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
@@ -123,11 +113,10 @@ describe("PUT /api/auth/me/timezone", () => {
const res = await PUT(mkReq({ timezone: "America/New_York" }));
expect(res.status).toBe(200);
expect(refoldMock).toHaveBeenCalledWith("user-1");
expect(nutrientMock).toHaveBeenCalledWith("user-1");
});
it("does not re-fold when the zone is unchanged", async () => {
it("does not recompute when the zone is unchanged", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
@@ -136,7 +125,6 @@ describe("PUT /api/auth/me/timezone", () => {
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
expect(res.status).toBe(200);
expect(refoldMock).not.toHaveBeenCalled();
expect(nutrientMock).not.toHaveBeenCalled();
});
+5 -17
View File
@@ -30,7 +30,6 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver";
import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -62,24 +61,13 @@ export const PUT = apiHandler(async (request: NextRequest) => {
invalidateUserTimezone(user.id);
// v1.37.19 — the medication-compliance rollup keys its rows by
// the LOCAL day the dose fell on, minted under the timezone in force at
// write time. A zone change re-buckets those days, so the stored keys go
// stale (and the coverage probe compares counts, not keys — it cannot
// notice). Re-fold the trailing window through the ordinary backfill
// job; best-effort, the singleton key collapses rapid re-picks.
// The supplement nutrient rows are keyed by the LOCAL day the dose fell
// on, minted under the timezone in force at write time. A zone change
// re-buckets those days, so the account's rows are recomputed through
// the ordinary job; best-effort, the singleton key collapses rapid
// re-picks.
if ((before?.timezone ?? null) !== tz) {
const refold = await enqueueUserMedicationComplianceBackfill(user.id);
// The supplement nutrient rows are keyed by the same local day.
await enqueueSupplementNutrientRecompute(user.id);
annotate({
meta: {
timezone_compliance_refold_enqueued: refold.enqueued,
...(refold.error
? { timezone_compliance_refold_error: refold.error }
: {}),
},
});
}
await auditLog("user.timezone.update", {
@@ -38,8 +38,8 @@ vi.mock("@/lib/rate-limit", () => ({
vi.mock("@/lib/app-settings", () => ({
isApiGloballyEnabled: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
vi.mock("@/lib/medications/dose-day-refresh", () => ({
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/medications/inventory/consumption", () => ({
consumeForIntake: vi.fn().mockResolvedValue(undefined),
+2 -2
View File
@@ -23,7 +23,7 @@ import {
unstableExternalIdShape,
} from "@/lib/validations/external-id";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
import {
applyCanonicalSlotWrite,
resolveSlotForWriteByBand,
@@ -337,7 +337,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.4.39 — refresh the compliance rollup for the ingested
// event's user-day.
await recomputeMedicationComplianceForEvent({
await refreshDoseSlot({
userId: apiToken.userId,
medicationId: medication.id,
scheduledFor: event.scheduledFor,
@@ -51,9 +51,9 @@ vi.mock("@/lib/cache/invalidate", () => ({
// v1.16.1 — the schedule-replace branch recomputes the compliance
// rollups of the tombstoned slots; stub the rollup module so the route
// test never reaches `$executeRaw`.
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
vi.mock("@/lib/medications/dose-day-refresh", () => ({
dayKeyForScheduledFor: vi.fn(() => "2026-06-10"),
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
refreshDoseDay: vi.fn().mockResolvedValue(undefined),
}));
// v1.40 (#1024) — the course writer and reader run against their own
// integration suite; here they answer "no course, nothing refused".
@@ -99,8 +99,8 @@ import {
import { auditLog } from "@/lib/auth/audit";
import {
dayKeyForScheduledFor,
recomputeMedicationComplianceForDay,
} from "@/lib/rollups/medication-compliance-rollups";
refreshDoseDay,
} from "@/lib/medications/dose-day-refresh";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -297,7 +297,7 @@ describe("PUT /api/medications/[id] — schedule replace archives a revision (v1
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
vi.mocked(refreshDoseDay).mockResolvedValue(undefined);
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
PREVIOUS_ROW,
] as never);
@@ -447,19 +447,19 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", (
ROUTE_CTX,
);
expect(res.status).toBe(200);
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2);
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith(
"user-1",
"m1",
"2026-06-10",
"Europe/Berlin",
);
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith(
"user-1",
"m1",
"2026-06-11",
"Europe/Berlin",
);
expect(refreshDoseDay).toHaveBeenCalledTimes(2);
expect(refreshDoseDay).toHaveBeenCalledWith({
userId: "user-1",
medicationId: "m1",
dayKey: "2026-06-10",
tz: "Europe/Berlin",
});
expect(refreshDoseDay).toHaveBeenCalledWith({
userId: "user-1",
medicationId: "m1",
dayKey: "2026-06-11",
tz: "Europe/Berlin",
});
});
it("does NOT touch intake rows when the body carries no schedules array", async () => {
@@ -865,7 +865,7 @@ describe("PUT /api/medications/[id] — intake tracking switch (#1033)", () => {
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
vi.mocked(refreshDoseDay).mockResolvedValue(undefined);
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
LIVE_ROW,
] as never);
@@ -1055,7 +1055,7 @@ describe("PUT /api/medications/[id] — shipped client on a record-only medicati
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
vi.mocked(refreshDoseDay).mockResolvedValue(undefined);
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
STORED_ROW,
] as never);
@@ -37,8 +37,8 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({
consumeForIntake: vi.fn().mockResolvedValue(undefined),
restoreForIntake: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
vi.mock("@/lib/medications/dose-day-refresh", () => ({
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
@@ -19,7 +19,7 @@ import {
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
import {
applyCanonicalSlotWrite,
@@ -392,14 +392,14 @@ export const PUT = apiHandler(
// scheduledFor day-keys when the dose moved across a day boundary
// (band re-attribution can move it as well as an explicit edit); if the
// slot is unchanged the second call collapses to a no-op upsert.
await recomputeMedicationComplianceForEvent({
await refreshDoseSlot({
userId: user.id,
medicationId: id,
scheduledFor: event.scheduledFor,
tz: user.timezone,
});
if (slotMoved) {
await recomputeMedicationComplianceForEvent({
await refreshDoseSlot({
userId: user.id,
medicationId: id,
scheduledFor: targetScheduledFor,
@@ -488,7 +488,7 @@ export const DELETE = apiHandler(
// v1.4.39 — refresh the rollup row for the day the deleted
// event sat on. When that day now holds zero events the helper
// drops the rollup row entirely.
await recomputeMedicationComplianceForEvent({
await refreshDoseSlot({
userId: user.id,
medicationId: id,
scheduledFor: event.scheduledFor,
@@ -78,8 +78,8 @@ vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
vi.mock("@/lib/medications/dose-day-refresh", () => ({
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -86,8 +86,8 @@ vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
vi.mock("@/lib/medications/dose-day-refresh", () => ({
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
@@ -46,12 +46,12 @@ vi.mock("@/lib/rate-limit", () => ({
rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
vi.mock("@/lib/medications/dose-day-refresh", () => ({
dayKeyForScheduledFor: vi.fn((d: Date) => {
const iso = d.toISOString();
return iso.slice(0, 10);
}),
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
refreshDoseDay: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/medications/inventory/consumption", () => ({
@@ -98,8 +98,8 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { checkRateLimit } from "@/lib/rate-limit";
import {
dayKeyForScheduledFor,
recomputeMedicationComplianceForDay,
} from "@/lib/rollups/medication-compliance-rollups";
refreshDoseDay,
} from "@/lib/medications/dose-day-refresh";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -174,7 +174,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => {
const json = await res.json();
expect(json.data).toEqual({ deleted: 0 });
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
expect(recomputeMedicationComplianceForDay).not.toHaveBeenCalled();
expect(refreshDoseDay).not.toHaveBeenCalled();
// (#22) — a no-op delete queues no cross-device sync fan-out.
expect(queueMedicationIntakeSync).not.toHaveBeenCalled();
});
@@ -201,7 +201,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => {
expect(dayKeyForScheduledFor).toHaveBeenCalledTimes(3);
// Three rows, two unique dayKeys → two recompute calls.
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2);
expect(refreshDoseDay).toHaveBeenCalledTimes(2);
// (#22) — the whole bulk delete queues exactly ONE cross-device
// sync fan-out, not one per removed row.
@@ -15,8 +15,8 @@ import { restoreForIntake } from "@/lib/medications/inventory/consumption";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import {
dayKeyForScheduledFor,
recomputeMedicationComplianceForDay,
} from "@/lib/rollups/medication-compliance-rollups";
refreshDoseDay,
} from "@/lib/medications/dose-day-refresh";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { NextRequest } from "next/server";
@@ -159,26 +159,11 @@ export const POST = apiHandler(
},
});
// Best-effort rollup refresh — one trip per unique dayKey so a
// 14-row delete spanning two days closes in two SQL trips. The
// helper is idempotent and the catch swallows transient
// populator failures (same contract as the single-event delete).
// Best-effort dose-day refresh, one per unique dayKey, so a 14-row
// delete spanning two days refreshes two days (same contract as the
// single-event delete).
for (const dayKey of dayKeysToRefresh) {
try {
await recomputeMedicationComplianceForDay(user.id, id, dayKey, tz);
} catch (err) {
annotate({
action: {
name: "medications.intake.bulk-delete.recompute-failed",
entity_type: "medication",
entity_id: id,
},
meta: {
day_key: dayKey,
error: err instanceof Error ? err.message : String(err),
},
});
}
await refreshDoseDay({ userId: user.id, medicationId: id, dayKey, tz });
}
await auditLog("medication.intake.bulk_delete", {
@@ -2,9 +2,9 @@
* v1.5.5 — purge route fires the medication cache bundle.
*
* Pins the contract the detail-page Verlauf-löschen cascade depends
* on: dropping the rollup rows is necessary but not sufficient. The
* on: tombstoning the rows is necessary but not sufficient. The
* analytics + iOS today-tally + dashboard tiles all read off cached
* shapes that survive the rollup delete for their TTL; the bundle
* shapes that survive the tombstoning for their TTL; the bundle
* invalidation forces every downstream reader to converge on the
* post-purge counts in the same tick the user sees the toast.
*/
@@ -16,7 +16,6 @@ vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationIntakeEvent: { updateMany: vi.fn() },
medicationComplianceRollup: { deleteMany: vi.fn() },
// v1.43 — no composition, so no supplement recompute is queued.
medicationIngredient: { findFirst: vi.fn().mockResolvedValue(null) },
},
@@ -87,9 +86,6 @@ beforeEach(() => {
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
count: 7,
} as never);
vi.mocked(prisma.medicationComplianceRollup.deleteMany).mockResolvedValue({
count: 5,
} as never);
});
describe("DELETE /api/medications/[id]/intake/purge", () => {
@@ -17,8 +17,8 @@ type RouteParams = { params: Promise<{ id: string }> };
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
// v1.37.0 — MANAGE. It tombstones rather than deletes (since the
// delegated-write release) and drops only rollups that recompute, so the
// history it clears is reconstructable from the rows themselves.
// delegated-write release), so the history it clears is reconstructable
// from the rows themselves.
const { user } = await requireRecordAuth("manage", "medications");
const { id } = await params;
@@ -60,13 +60,6 @@ export const DELETE = apiHandler(
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
// v1.4.39 — drop every rollup row for this medication so the
// next compliance read returns zero-filled buckets rather than the
// last-known scheduled / taken totals for now-deleted events.
await prisma.medicationComplianceRollup.deleteMany({
where: { medicationId: id, userId: user.id },
});
// v1.43 — the purged doses no longer count toward the micronutrients.
if (await medicationHasComposition(user.id, id)) {
await enqueueSupplementNutrientRecompute(user.id);
+2 -2
View File
@@ -36,7 +36,7 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
import {
applyCanonicalSlotWrite,
findPinConflict,
@@ -608,7 +608,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// v1.4.39 — refresh the persistent compliance rollup for the
// affected day. The hook is best-effort; failures annotate but never
// block the user's POST response.
await recomputeMedicationComplianceForEvent({
await refreshDoseSlot({
userId: user.id,
medicationId: id,
scheduledFor: event.scheduledFor,
+15 -25
View File
@@ -48,8 +48,8 @@ import {
} from "@/lib/medications/scheduling/next-due";
import {
dayKeyForScheduledFor,
recomputeMedicationComplianceForDay,
} from "@/lib/rollups/medication-compliance-rollups";
refreshDoseDay,
} from "@/lib/medications/dose-day-refresh";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import {
effectiveUnitsPerDose,
@@ -716,9 +716,9 @@ export const PUT = apiHandler(
// Tombstone today's and future open pending rows of this medication.
// Runs on a schedule replace (the old anchors are stale) and when intake
// tracking is switched off (nothing is due any more).
// The rollup days the tombstoned slots sat on, recomputed once the
// The dose days the tombstoned slots sat on, refreshed once the
// transaction below has committed the tombstones.
const staleRollupDays = new Set<string>();
const staleDoseDays = new Set<string>();
const tombstoneOpenSlots = async (
db: Prisma.TransactionClient,
): Promise<void> => {
@@ -768,7 +768,7 @@ export const PUT = apiHandler(
});
// Deduped via the user-tz day key.
for (const row of tombstoned) {
staleRollupDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz));
staleDoseDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz));
}
};
@@ -1085,26 +1085,16 @@ export const PUT = apiHandler(
throw err;
}
// Recompute each rollup day the tombstoned slots sat on, now that the
// tombstones are committed. Best-effort like every other rollup
// write-hook: a failure is annotated, never fails the edit.
for (const dayKey of staleRollupDays) {
try {
await recomputeMedicationComplianceForDay(
user.id,
id,
dayKey,
user.timezone || DEFAULT_TIMEZONE,
);
} catch (err) {
annotate({
meta: {
medication_compliance_rollup_failed: true,
medication_compliance_rollup_error:
err instanceof Error ? err.message : String(err),
},
});
}
// Refresh each dose day the tombstoned slots sat on, now that the
// tombstones are committed. Best-effort like every dose write-hook: a
// failure is reported, never fails the edit.
for (const dayKey of staleDoseDays) {
await refreshDoseDay({
userId: user.id,
medicationId: id,
dayKey,
tz: user.timezone || DEFAULT_TIMEZONE,
});
}
// Apply the v1.5.5 primary-schedule grace bridge after the
@@ -45,7 +45,6 @@ import {
toRevisionPayloadEntry,
type ScheduleRevisionEntry,
} from "@/lib/medications/scheduling/schedule-eras";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import type { Prisma } from "@/generated/prisma/client";
@@ -286,13 +285,10 @@ export const PATCH = apiHandler(
},
});
// The corrected era re-segments history; refresh the pre-aggregated
// compliance rollups asynchronously (best-effort).
// v1.16.9 — an era write re-segments the bands every cached payload
// (list next-due, compliance cells, dashboard tally) was built on;
// hard-evict so the next read reflects the new history immediately.
invalidateUserMedications(user.id, { evict: true });
await enqueueUserMedicationComplianceBackfill(user.id);
return apiSuccess({
id: outcome.revision.id,
@@ -404,13 +400,10 @@ export const DELETE = apiHandler(
meta: { revision_id: revisionId },
});
// History re-segments without the era; refresh the pre-aggregated
// compliance rollups asynchronously (best-effort).
// v1.16.9 — an era write re-segments the bands every cached payload
// (list next-due, compliance cells, dashboard tally) was built on;
// hard-evict so the next read reflects the new history immediately.
invalidateUserMedications(user.id, { evict: true });
await enqueueUserMedicationComplianceBackfill(user.id);
return apiSuccess({ deleted: true });
},
@@ -42,11 +42,6 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
enqueueUserMedicationComplianceBackfill: vi
.fn()
.mockResolvedValue({ enqueued: true, error: null }),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
@@ -64,7 +59,6 @@ import { GET, POST } from "../route";
import { DELETE, PATCH } from "../[revisionId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -349,11 +343,6 @@ describe("POST /api/medications/[id]/schedule-revisions", () => {
expect(createArgs.data.payload[0].rrule).toBe("FREQ=DAILY");
expect(createArgs.data.payload[0].scheduleType).toBe("SCHEDULED");
// History re-segmented — the rollup backfill must be enqueued.
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
"user-1",
);
// Check-then-insert ran inside the transaction with the
// per-medication `FOR UPDATE` lock taken first.
expect(prisma.$transaction).toHaveBeenCalled();
@@ -432,9 +421,6 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => {
where: { medicationId: "m1", supersededByRevisionId: "rev-m" },
data: { supersededByRevisionId: null },
});
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
"user-1",
);
});
});
@@ -533,9 +519,6 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => {
expect(updateArgs.data.payload[0].rrule).toBe("FREQ=DAILY");
// In-place — no superseding row is minted.
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
"user-1",
);
// Check-then-write ran inside the locked transaction.
expect(prisma.$transaction).toHaveBeenCalled();
expect(prisma.$queryRaw).toHaveBeenCalled();
@@ -573,8 +556,5 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => {
where: { id: "rev-t" },
data: { supersededByRevisionId: "rev-fix" },
});
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
"user-1",
);
});
});
@@ -40,7 +40,6 @@ import {
toRevisionPayloadEntry,
type ScheduleRevisionEntry,
} from "@/lib/medications/scheduling/schedule-eras";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import type { Prisma } from "@/generated/prisma/client";
@@ -282,14 +281,10 @@ export const POST = apiHandler(
},
});
// The new era re-segments history the compliance rollups already
// pre-aggregated; refresh them asynchronously. Best-effort like
// every other rollup write-hook.
// v1.16.9 — an era write re-segments the bands every cached payload
// (list next-due, compliance cells, dashboard tally) was built on;
// hard-evict so the next read reflects the new history immediately.
invalidateUserMedications(user.id, { evict: true });
await enqueueUserMedicationComplianceBackfill(user.id);
return apiSuccess(
{
@@ -34,12 +34,6 @@ vi.mock("@/lib/db", () => ({
// v1.15.18 — the band resolver loads the medication via findFirst.
findFirst: vi.fn(),
},
medicationComplianceRollup: {
findFirst: vi.fn(),
findMany: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
// v1.4.43 — audit-ledger breadcrumb for validation-failed paths.
auditLog: { create: vi.fn() },
$transaction: vi.fn(),
@@ -119,15 +113,6 @@ beforeEach(() => {
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
count: 0,
} as never);
// v1.4.39 — default the coverage probe to "uncovered" so the
// legacy compliance test still exercises the live-fallback branch
// and finds the legacy mocked intake events.
vi.mocked(prisma.medicationComplianceRollup.findFirst).mockResolvedValue(
null,
);
vi.mocked(prisma.medicationComplianceRollup.findMany).mockResolvedValue(
[] as never,
);
// v1.4.39 — the coverage probe is now a single `$queryRaw`
// aggregate returning `{ rolled_days, event_days }`. Default to
// "zero rollups, zero events" (covered/trivial-empty) so tests that
@@ -51,8 +51,8 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({
consumeForIntake: vi.fn().mockResolvedValue(undefined),
restoreForIntake: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
vi.mock("@/lib/medications/dose-day-refresh", () => ({
refreshDoseDay: vi.fn().mockResolvedValue(undefined),
dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+14 -29
View File
@@ -65,9 +65,9 @@ import {
} from "@/lib/integrations/healthkit-type-sync";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
recomputeMedicationComplianceForDay,
dayKeyForScheduledFor,
} from "@/lib/rollups/medication-compliance-rollups";
refreshDoseDay,
} from "@/lib/medications/dose-day-refresh";
import {
applyCanonicalSlotWrite,
findPinConflict,
@@ -365,10 +365,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
let extraDosesUnscheduled = 0;
const skipped: Array<{ index: number; reason: string }> = [];
const unstableShapes: UnstableExternalIdShape[] = [];
// v1.4.39 — collect distinct `(medicationId, dayKey)` pairs
// touched by the batch so one rollup recompute fires per pair after
// all inserts complete. Per-row recompute would balloon a 500-entry
// batch into 500 sequential rollup hits.
// Distinct `(medicationId, dayKey)` pairs touched by the batch, so one
// dose-day refresh fires per pair after all inserts complete. A per-row
// refresh would balloon a 500-entry batch into 500 sequential refreshes.
const touchedDays = new Map<
string,
{ medicationId: string; dayKey: string }
@@ -949,30 +948,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
invalidateUserMedications(user.id, { evict: true });
}
// v1.4.39 — refresh one rollup row per distinct
// `(medicationId, dayKey)` touched by the batch. Best-effort: failures
// are swallowed by the helper's `recomputeMedicationComplianceForEvent`
// wrapper, but bulk callers wrap recompute directly to keep the
// tight ingest loop unaffected by populator issues.
// Refresh the derived rows of each distinct `(medicationId, dayKey)` the
// batch touched. Best-effort: the refresh reports its own failures and
// never fails the ingest.
for (const { medicationId, dayKey } of touchedDays.values()) {
try {
await recomputeMedicationComplianceForDay(
user.id,
medicationId,
dayKey,
user.timezone,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
medication_compliance_rollup_bulk_failed: true,
medication_compliance_rollup_bulk_error: message,
medication_compliance_rollup_medication: medicationId,
medication_compliance_rollup_day: dayKey,
},
});
}
await refreshDoseDay({
userId: user.id,
medicationId,
dayKey,
tz: user.timezone,
});
}
// v1.17.1 (#22) — silent cross-device intake sync. ONE queued fan-out
+3 -3
View File
@@ -28,7 +28,7 @@ import { auditLog } from "@/lib/auth/audit";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
import {
buildScheduleAnchoredComplianceBuckets,
type ScheduleAnchoredComplianceBucket,
@@ -573,7 +573,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// the affected day so the next read after the cache miss returns
// the up-to-date `(scheduled, taken, skipped)` tuple. Best-effort:
// a populator failure annotates ops without blocking the response.
await recomputeMedicationComplianceForEvent({
await refreshDoseSlot({
userId: user.id,
medicationId: existing.medicationId,
scheduledFor: existing.scheduledFor,
@@ -584,7 +584,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// source day self-corrects while the destination keeps its stale tuple.
const movedTo = updated?.scheduledFor;
if (movedTo && movedTo.getTime() !== existing.scheduledFor.getTime()) {
await recomputeMedicationComplianceForEvent({
await refreshDoseSlot({
userId: user.id,
medicationId: existing.medicationId,
scheduledFor: movedTo,
@@ -93,8 +93,8 @@ vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
vi.mock("@/lib/medications/dose-day-refresh", () => ({
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
}));
import { POST, GET } from "../route";