Claim reminder slots before egress, classify the bulk race, fold the vault probes

- The measurement-reminder tick claims its slot in the record-event
  ledger BEFORE any provider egress (the documented claim-first pattern
  from reminder-dedup.ts); a crash between dispatch and the nextDueAt
  advance can no longer double-send.
- mood-entries/bulk classifies a lost P2002 race as the duplicate it is
  (with the winning row id) instead of a skip carrying a raw Prisma
  message — the row IS stored, the racing twin wrote it. The workouts
  batch already re-derives per-entry status from RETURNING.
- The vault list's content-index and thumbnail probes ride the same
  Promise.all as the link loads: four independent grouped queries, no
  serialized round-trips on a 4s-polled list.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 06:06:43 +02:00
1 parent 14e9edfa7a
commit ac44b35867
5 files changed
+208 -36

No files matched your search

@@ -16,7 +16,20 @@
*
* The Prisma surface is stubbed manually to avoid a testcontainer boot.
*/
import { describe, it, expect, vi } from "vitest";
import { beforeEach, describe, it, expect, vi } from "vitest";
// v1.37.19 (A6-7) — the tick claims the slot BEFORE provider egress via
// the shared record-event ledger. The manual Prisma double has no
// notification_events surface, so the claim is mocked controllable:
// default granted, individual cases flip it to prove the guard.
const claimState = vi.hoisted(() => ({ granted: true }));
const claimMock = vi.hoisted(() => vi.fn(async () => claimState.granted));
vi.mock("@/lib/notifications/reminder-dedup", async () => {
const actual = await vi.importActual<
typeof import("@/lib/notifications/reminder-dedup")
>("@/lib/notifications/reminder-dedup");
return { ...actual, claimNotificationEvent: claimMock };
});
import {
evaluateMeasurementReminderDue,
@@ -35,6 +48,11 @@ const OK: DispatchOutcome = {
const TZ = "Europe/Berlin";
beforeEach(() => {
claimState.granted = true;
claimMock.mockClear();
});
// 09:00 Berlin in June = 07:00Z.
const NINE_LOCAL = new Date("2026-06-15T07:00:00Z");
@@ -228,6 +246,51 @@ describe("runMeasurementReminderTick", () => {
expect(updates[0].data.nextDueAt).toBeInstanceOf(Date);
});
// Watched red: with the claim-before-dispatch guard removed from the
// tick (the pre-v1.37.19 order: dispatch first, advance after), the
// denied-claim case below fails — the provider was contacted although
// another worker (or a crash-recovery replay) already owned the slot.
it("claims the slot BEFORE provider egress and skips when denied", async () => {
claimState.granted = false;
const { prisma, updates } = makePrisma({
reminders: [reminder({})],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await runMeasurementReminderTick(
prisma as never,
NINE_LOCAL,
{ dispatch },
);
expect(claimMock).toHaveBeenCalledTimes(1);
expect(dispatch).not.toHaveBeenCalled();
expect(summary.skippedAlreadyClaimed).toBe(1);
expect(summary.dispatched).toBe(0);
// No advance either — the claimant owns the slot's lifecycle.
expect(updates).toHaveLength(0);
});
it("keys the claim on the reminder + the user-local date", async () => {
const { prisma } = makePrisma({
reminders: [reminder({})],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
expect(claimMock).toHaveBeenCalledWith(
prisma,
expect.objectContaining({
eventType: "MEASUREMENT_REMINDER",
dedupKey: expect.stringMatching(/^measurement:.+:2026-06-15$/),
}),
);
expect(dispatch).toHaveBeenCalledTimes(1);
});
it("dispatches a due reminder and advances nextDueAt past now (ledger-free dedup)", async () => {
const { prisma, updates } = makePrisma({
reminders: [reminder({})],
+34 -3
View File
@@ -34,6 +34,10 @@ import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import {
claimNotificationEvent,
REMINDER_DEDUP_LOOKBACK_MS,
} from "@/lib/notifications/reminder-dedup";
import { getEvent } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import type { ModuleKey } from "@/lib/modules/registry";
@@ -92,6 +96,8 @@ export interface MeasurementReminderSummary {
skippedOutsideWindow: number;
skippedModuleDisabled: number;
skippedNoChannel: number;
/** v1.37.19 (A6-7) — slot already claimed for this local day (racing worker or crash-recovery replay). */
skippedAlreadyClaimed: number;
/** v1.18.1 — expired COACH course-window reminders soft-deleted this tick. */
expiredCleaned: number;
failed: number;
@@ -204,6 +210,7 @@ export async function runMeasurementReminderTick(
skippedOutsideWindow: 0,
skippedModuleDisabled: 0,
skippedNoChannel: 0,
skippedAlreadyClaimed: 0,
expiredCleaned: 0,
failed: 0,
};
@@ -325,6 +332,28 @@ export async function runMeasurementReminderTick(
// user was filed as handled and vanished from the card and the digest.
// Now, when nothing delivers, the no-channel branch below leaves the
// reminder overdue and visible.
// v1.37.19 (A6-7) — claim the slot BEFORE provider egress, the
// documented claim-first pattern from `reminder-dedup.ts`. The old
// order (dispatch, then advance `nextDueAt`) double-sent when the
// worker crashed between the two; the claim is the durable record
// that this reminder's due cycle went out today. The trade is the
// same one the medication tick accepts: a crash mid-dispatch burns
// the slot for this local day rather than risking a repeat, and the
// next local day starts clean.
const localDate = new Date(now).toLocaleDateString("sv-SE", {
timeZone: timezone,
});
const claimed = await claimNotificationEvent(prisma, {
recordUserId: reminder.user.id,
eventType: "MEASUREMENT_REMINDER",
dedupKey: `measurement:${reminder.id}:${localDate}`,
since: new Date(now.getTime() - REMINDER_DEDUP_LOOKBACK_MS),
});
if (!claimed) {
summary.skippedAlreadyClaimed += 1;
continue;
}
const { title, body } = buildMeasurementReminderPayload(
reminder.user.locale,
reminder.label,
@@ -359,9 +388,11 @@ export async function runMeasurementReminderTick(
},
});
// No channel succeeded — leave `nextDueAt` where it is so the next
// tick (or the user's next channel) retries. The reminder simply
// stays overdue, which the surface already shows as "überfällig".
// No channel succeeded — leave `nextDueAt` where it is. The claim
// above holds for the rest of the local day (claim-first burns the
// slot rather than re-flooding a failing channel — see
// reminder-dedup.ts); the reminder stays overdue and visible on the
// surface, and the next local day retries with a fresh key.
if (!outcome.dispatched) {
summary.skippedNoChannel += 1;
continue;