Merge work/side-effecting-gets

This commit is contained in:
Marc-André Bombeck committed 2026-08-02 01:29:46 +02:00
commit b40e9bc40f
28 files changed
+1479 -139

No files matched your search

+6 -5
View File
@@ -2369,9 +2369,9 @@ paths:
tags:
- Sync
summary: Sync handshake + window metadata
description: Cheap 'should I sync?' summary. Returns the previous `lastSyncedAt` checkpoint and advances it server-side
on each call. The `sync` block carries the incremental-delta window + tombstone retention so the client reads
them rather than hardcoding.
description: Cheap 'should I sync?' summary. Returns the `lastSyncedAt` checkpoint, which the batch ingest endpoints
advance when a client push lands; this route is a read and writes nothing. The `sync` block carries the
incremental-delta window + tombstone retention so the client reads them rather than hardcoding.
responses:
"200":
description: Sync state summary.
@@ -18160,8 +18160,9 @@ components:
- intakes
- sync
additionalProperties: false
description: iOS SyncMode handshake. Each GET also advances the server-side `lastSyncedAt` checkpoint and returns the
previous value. The cheap 'should I sync?' summary; the durable delta cursor lives on `/api/sync/changes`.
description: iOS SyncMode handshake. Reports the server-side `lastSyncedAt` checkpoint, which the batch ingest endpoints
set when a client push lands; the handshake itself writes nothing. The cheap 'should I sync?' summary; the
durable delta cursor lives on `/api/sync/changes`.
SyncChangesEnvelope:
type: object
properties:
+6
View File
@@ -502,6 +502,12 @@ model User {
// SwiftData backlog before opening a fresh anchored-query window.
// NULL = never synced; the SyncMode store treats that as "force
// a full refresh on the next pair".
//
// Written by the client-push ingest boundaries only — the batch /
// bulk endpoints for measurements, mood and intakes (see
// `markSyncCheckpoint`). The handshake that reports it is a GET and
// writes nothing, so the checkpoint can only trail a real sync
// instead of running ahead of one.
lastSyncedAt DateTime? @map("last_synced_at")
// v0.5.4 ios-coord — daily mood-reminder opt-in.
@@ -0,0 +1,90 @@
/**
* Structural guard: the three read routes that used to write must stay reads.
*
* `src/lib/api-handler.ts` states the invariant next to `READ_HTTP_METHODS` —
* an MCP-audience token is admitted on GET/HEAD/OPTIONS on the REST surface
* because those methods are assumed side-effect-free, and adding a
* side-effecting GET silently widens that token's reach. These three routes
* each broke it once. The runtime tests beside each route prove the specific
* write is gone; this one fails on a NEW one.
*
* Deliberately scoped to these three files and to writes issued directly in
* the handler. A repo-wide version is not written today because it cannot be
* written without false positives: several read routes call helpers that
* legitimately upsert a lazily-created row (`getOrCreateCycleProfile` behind
* `requireCycleEnabled` is the clearest one), and a text matcher cannot tell
* that apart from a real side effect. Widening this guard means resolving
* those cases first, not loosening the pattern.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
/** Mutating Prisma model methods, as they appear at a call site. */
const PRISMA_WRITE =
/\.(create|createMany|createManyAndReturn|update|updateMany|upsert|delete|deleteMany|executeRaw|executeRawUnsafe)\s*\(/;
/**
* Write helpers these routes used to reach for. A helper hides its writes
* from the pattern above, so each one that mattered here is named.
*/
const WRITE_HELPERS =
/\b(persistPredictionCache|markSyncCheckpoint|auditLog\s*\()/;
const ROUTES = [
"sync/state/route.ts",
"gamification/achievements/route.ts",
"cycle/calendar/route.ts",
];
function sourceOf(relative: string): string {
return readFileSync(join(__dirname, "..", relative), "utf8");
}
/**
* Strip comments before matching. Every one of these files DESCRIBES the
* write it no longer performs, and a guard that reads prose as code is a
* guard that fires on its own documentation.
*/
function code(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
}
describe("read routes issue no writes", () => {
it("the patterns actually match a write — otherwise every case below is vacuous", () => {
expect(
PRISMA_WRITE.test(`await prisma.user.update({ where: { id } });`),
).toBe(true);
expect(
PRISMA_WRITE.test(`prisma.userAchievement.createMany({ data });`),
).toBe(true);
expect(WRITE_HELPERS.test(`void persistPredictionCache(userId, p);`)).toBe(
true,
);
// And do NOT match the reads these routes are made of.
expect(PRISMA_WRITE.test(`await prisma.measurement.findMany({});`)).toBe(
false,
);
expect(PRISMA_WRITE.test(`await prisma.moodEntry.count({});`)).toBe(false);
});
it("scans all three routes and finds real source in each", () => {
// An empty read set would pass every assertion below without looking at
// anything.
expect(ROUTES).toHaveLength(3);
for (const route of ROUTES) {
expect(code(sourceOf(route)).length).toBeGreaterThan(500);
}
});
for (const route of ROUTES) {
it(`${route} calls no mutating Prisma method`, () => {
expect(code(sourceOf(route))).not.toMatch(PRISMA_WRITE);
});
it(`${route} calls no known write helper`, () => {
expect(code(sourceOf(route))).not.toMatch(WRITE_HELPERS);
});
}
});
@@ -22,6 +22,12 @@ vi.mock("@/lib/db", () => ({
menstrualCycle: { findMany: vi.fn() },
cycleDayLog: { findMany: vi.fn() },
measurement: { findMany: vi.fn() },
// Present so a re-introduced forecast persist is VISIBLE here rather
// than blowing up on an undefined model. `persistPredictionCache` calls
// `findUnique` synchronously before its first await, so even the old
// fire-and-forget `void persistPredictionCache(...)` lands on this mock
// before the assertion runs.
cyclePrediction: { findUnique: vi.fn(), upsert: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
@@ -34,10 +40,6 @@ vi.mock("@/lib/rate-limit", () => ({
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/cycle/gate", () => ({ requireCycleEnabled: vi.fn() }));
// The cache persist is fire-and-forget and would reach the database.
vi.mock("@/lib/cycle/prediction-cache", () => ({
persistPredictionCache: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
@@ -142,6 +144,22 @@ afterEach(() => {
vi.useRealTimers();
});
describe("GET /api/cycle/calendar is a read", () => {
it("persists no forecast — the reminder cron's row is the job's, not this route's", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-10T12:00:00Z"));
const { status, body } = await callCalendar();
expect(status).toBe(200);
// There IS a forecast to persist, so the absence of a write is a
// decision rather than an empty case.
expect((body.data as Record<string, unknown>).prediction).not.toBeNull();
expect(prisma.cyclePrediction.findUnique).not.toHaveBeenCalled();
expect(prisma.cyclePrediction.upsert).not.toHaveBeenCalled();
});
});
describe("GET /api/cycle/calendar carries the resolved verdict", () => {
it("puts the whole verdict on the wire", async () => {
// 2026-06-10 12:00 UTC is already 2026-06-11 for a user at UTC+14.
+11 -11
View File
@@ -3,9 +3,9 @@
* (ios-contract §2.D).
*
* Calls the pure engine (`predictCycle` + per-day phase) to build
* `{ profile, prediction, days }`. The synchronous engine call is cheap
* (pure stats); the heavy part is the cache persist, which is debounced
* stale-while-revalidate and fire-and-forget so it never blocks the GET.
* `{ profile, prediction, days }`. The engine call is cheap (pure stats) and
* the route writes nothing — the `CyclePrediction` row the reminder cron
* reads is owned by the `cycle-prediction-refresh` job.
* Fertile-window fields are goal-gated (surfaced for TRYING_TO_CONCEIVE and
* AVOID_PREGNANCY — the latter with the stronger "not a contraceptive method"
* disclaimer; suppressed for GENERAL_HEALTH / PERIMENOPAUSE / OFF),
@@ -31,7 +31,6 @@ import {
cycleDisclaimerKey,
} from "@/lib/cycle/dto";
import { resolveCycleVerdict } from "@/lib/cycle/verdict";
import { persistPredictionCache } from "@/lib/cycle/prediction-cache";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import { BBT_WINDOW } from "@/lib/cycle/types";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
@@ -157,13 +156,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
goalAllowsFertile,
);
// Persist the cache fire-and-forget (debounced) — never block the read.
let generatedAt = new Date().toISOString();
if (prediction) {
const now = new Date();
generatedAt = now.toISOString();
void persistPredictionCache(user.id, prediction, now);
}
// v1.35.3 — this read persists nothing. It used to fire
// `void persistPredictionCache(...)`: a write on a GET, and a discarded
// promise on top, so a failed write left no trace at all. The forecast the
// response carries is computed right here from the account's rows; the
// `CyclePrediction` row the reminder cron scans is written by the
// `cycle-prediction-refresh` job, which reaches every tracking account
// rather than the ones that opened this page.
const generatedAt = new Date().toISOString();
const locale = await resolveServerLocale({
request,
@@ -217,14 +217,8 @@ describe("GET /api/gamification/achievements — per-module badge skipping", ()
expect(badges.length).toBeGreaterThan(0);
});
it("never persists an unlock for a disabled-module badge", async () => {
// A mood badge would unlock at >=1 entry; with mood OFF the createMany
// must carry no mood achievement row.
await badgesFor({ mood: false });
const createCalls = vi.mocked(prisma.userAchievement.createMany).mock.calls;
for (const [arg] of createCalls) {
const rows = (arg as { data: Array<{ achievementId: string }> }).data;
expect(rows.some((r) => r.achievementId.includes("mood"))).toBe(false);
}
});
// The "a disabled module never gets its badge pinned" invariant moved with
// the write itself — see `src/lib/jobs/__tests__/achievement-unlock-sweep.test.ts`.
// That this route persists nothing at all is proved in
// `writes-nothing.test.ts`, against a result that HAS a pending unlock.
});
@@ -0,0 +1,125 @@
/**
* `GET /api/gamification/achievements` is a read.
*
* It used to INSERT a `UserAchievement` row for every unlock it noticed,
* which is the side-effecting GET the MCP-audience note in `api-handler.ts`
* warns against. The evaluation still runs here; only the persistence moved
* to the `achievement-unlock-sweep` job.
*
* The builder is faked so the result ALWAYS carries a pending unlock. That is
* the point: an assertion that the route did not write is worth nothing
* against a fixture that had nothing to write, and the fixture in
* `module-gate.test.ts` turns out to be exactly that.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: { userAchievement: { findMany: vi.fn(), createMany: vi.fn() } },
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
resolveModuleMap: vi.fn(async () => ({})),
MODULE_DISABLED_ERROR_CODE: "module.disabled",
}));
const PENDING_UNLOCK = {
achievementId: "mood-first-entry",
unlockedAt: new Date("2026-03-02T08:00:00.000Z"),
};
vi.mock("@/lib/gamification/achievements-result", () => ({
buildAchievementsResult: vi.fn(async () => ({
summary: { unlockedCount: 1, totalCount: 1 },
achievements: [
{
id: "mood-first-entry",
category: "mood",
unlocked: true,
completedAt: "2026-03-02T08:00:00.000Z",
titleKey: "achievements.moodFirst.title",
descriptionKey: "achievements.moodFirst.description",
icon: "Smile",
progressPercent: 100,
points: 10,
target: 1,
current: 1,
isHidden: false,
},
],
metrics: {},
pendingUnlocks: [PENDING_UNLOCK],
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { buildAchievementsResult } from "@/lib/gamification/achievements-result";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
createdAt: new Date("2026-03-01T00:00:00.000Z"),
locale: "en",
},
};
beforeEach(() => {
vi.clearAllMocks();
__resetAllCachesForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
async function callGet() {
const res = await GET(
new NextRequest("http://localhost/api/gamification/achievements"),
);
return { status: res.status, body: JSON.parse(await res.text()) };
}
describe("GET /api/gamification/achievements", () => {
it("has a pending unlock to notice — otherwise the case below is empty", async () => {
await callGet();
const result = await vi.mocked(buildAchievementsResult).mock.results[0]!
.value;
expect(result.pendingUnlocks).toHaveLength(1);
});
it("persists nothing — a GET does not write", async () => {
const { status } = await callGet();
expect(status).toBe(200);
expect(prisma.userAchievement.createMany).not.toHaveBeenCalled();
});
it("still renders the unlocked badge, so the payload never depended on the row", async () => {
const { body } = await callGet();
const achievements = (body.data as { achievements: Array<unknown> })
.achievements as Array<{ id: string; unlocked: boolean }>;
expect(achievements).toHaveLength(1);
expect(achievements[0]!.unlocked).toBe(true);
});
it("does not leak the internal pendingUnlocks carrier onto the wire", async () => {
const { body } = await callGet();
expect(body.data).not.toHaveProperty("pendingUnlocks");
});
});
+15 -25
View File
@@ -1,4 +1,3 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
@@ -62,9 +61,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// iOS-format branch runs the locale-aware transform after the cache
// read so the cache stays format-agnostic and the achievement-progress
// dashboard duplicate (seen twice per dashboard mount in the v1.4.33
// HAR) coalesces into one builder call. v1.18.0 B5 — unlock persistence
// now happens in the handler after the cache read (idempotent), not as
// a side effect inside the cached factory.
// HAR) coalesces into one builder call.
// v1.18.0 B5 — resolve the per-user module map once and pass it into the
// builder so badge categories whose owning module is disabled (sleep
// badges when sleep is off, mood badges when mood is off) are skipped
@@ -76,8 +73,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// 10-minute stale window; the app-wide `AchievementUnlockNotifier` polls
// every 2 minutes, so a hard-TTL read always missed and re-paid the cold
// build. SWR serves the prior payload instantly and warms one background
// recompute. Persistence of `pendingUnlocks` stays OUTSIDE this read (see
// below), so a stale-served body never skips an unlock write.
// recompute.
const result = await cachedSwr(
caches.achievements as ServerCache<AchievementsResult>,
user.id,
@@ -85,25 +81,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate,
);
// v1.18.0 B5 — persist newly unlocked achievements OUTSIDE the cached
// factory. `createMany({ skipDuplicates: true })` is idempotent on the
// `(userId, achievementId)` unique, so re-running it on a cache hit is
// a no-op rather than a duplicate, and the write is never skipped just
// because the read was served from cache.
if (result.pendingUnlocks.length > 0) {
await prisma.userAchievement.createMany({
data: result.pendingUnlocks.map((u) => ({
userId: user.id,
achievementId: u.achievementId,
unlockedAt: new Date(u.unlockedAt),
})),
skipDuplicates: true,
});
annotate({
action: { name: "gamification.achievements" },
meta: { newUnlocks: result.pendingUnlocks.length },
});
}
// v1.35.3 — the unlock rows are NOT written here. A GET must not have side
// effects (`api-handler.ts`, the MCP-audience note above
// `READ_HTTP_METHODS`), and this INSERT was one. The badge grid does not
// need the rows: `unlocked` comes from the live metrics and the completion
// date is derived from the account's own history, so the payload below is
// identical whether or not a row exists. The rows pin that date against a
// later data edit and carry it into the backup, which is durable work and
// belongs on the `achievement-unlock-sweep` job — where it also covers the
// accounts that never open this page.
annotate({
action: { name: "gamification.achievements" },
meta: { pendingUnlocks: result.pendingUnlocks.length },
});
// Strip the internal `pendingUnlocks` carrier; it never goes on the wire.
const payload = {
@@ -156,6 +156,7 @@ describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)",
// This batch declares no trigger, so the trigger field is nulled and the
// background timestamp is not written — see the sync-trigger suite.
data: {
lastSyncedAt: expect.any(Date),
healthKitLastSyncedAt: expect.any(Date),
healthKitLastSyncTrigger: null,
},
@@ -170,7 +171,12 @@ describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)",
);
expect(res.status).toBe(200);
expect(prisma.user.update).not.toHaveBeenCalled();
// The SyncMode checkpoint still advances — a MANUAL push is a client
// sync — but none of the HealthKit-specific fields are touched.
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { lastSyncedAt: expect.any(Date) },
});
});
it("surfaces a hard reconciliation failure without checkpointing it", async () => {
+22 -8
View File
@@ -643,9 +643,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
);
});
// This batch route is the native HealthKit ingestion boundary. Stamp only
// after every attempted write has reached a durable, non-failed verdict;
// MANUAL-only and skipped-only batches are not HealthKit syncs.
// This batch route is the native ingestion boundary, so it is also where the
// SyncMode checkpoint (`User.lastSyncedAt`, the same column
// `markSyncCheckpoint` writes) is established — `GET /api/sync/state` only
// reports it. The checkpoint covers MANUAL pushes too: the client synced
// whatever it was holding. A batch carrying a hard failure leaves the
// checkpoint where it was, so the undelivered window stays inside whatever
// the client asks for next.
//
// The HealthKit fields ride the same update and keep their narrower
// condition. Stamp only after every attempted write has reached a durable,
// non-failed verdict; MANUAL-only and skipped-only batches are not
// HealthKit syncs.
//
// The trigger the client declared is stamped alongside, and a background- or
// push-triggered batch additionally dates `healthKitLastBackgroundSyncAt`.
@@ -655,17 +664,22 @@ async function postBatch(request: NextRequest): Promise<Response> {
// trigger writes null over the previous value rather than leaving a stale one
// standing: the field describes THIS sync, and an older build's silence is
// not evidence about it.
if (healthKitSyncSucceeded) {
if (failedCount === 0) {
const syncedAt = new Date();
const deliveredWithoutTheApp =
syncTrigger === "background" || syncTrigger === "push";
await prisma.user.update({
where: { id: user.id },
data: {
healthKitLastSyncedAt: syncedAt,
healthKitLastSyncTrigger: syncTrigger ?? null,
...(deliveredWithoutTheApp
? { healthKitLastBackgroundSyncAt: syncedAt }
lastSyncedAt: syncedAt,
...(healthKitSyncSucceeded
? {
healthKitLastSyncedAt: syncedAt,
healthKitLastSyncTrigger: syncTrigger ?? null,
...(deliveredWithoutTheApp
? { healthKitLastBackgroundSyncAt: syncedAt }
: {}),
}
: {}),
},
});
@@ -21,6 +21,9 @@ vi.mock("@/lib/db", () => ({
// v1.15.19 — resolver-null convergence probe before standalone insert.
findFirst: vi.fn(),
},
// The bulk drain stamps the SyncMode checkpoint (`User.lastSyncedAt`) —
// it is a client push, and the push is what the checkpoint describes.
user: { update: vi.fn().mockResolvedValue({}) },
auditLog: { create: vi.fn() },
},
}));
@@ -1486,3 +1489,49 @@ describe("POST /api/medications/intake/bulk — identity stability floor", () =>
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
});
});
// ────────────────────────────────────────────────────────────────────
// SyncMode checkpoint — the push owns `User.lastSyncedAt`
// ────────────────────────────────────────────────────────────────────
/**
* `GET /api/sync/state` reports the checkpoint and writes nothing, so the
* ingest boundaries are the only writers. If this one stops stamping, the
* checkpoint stays null forever and every paired client re-pairs full.
*/
describe("POST /api/medications/intake/bulk — sync checkpoint", () => {
beforeEach(() => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{ id: "med-1" },
] as never);
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({
id: "row-1",
} as never);
});
it("stamps lastSyncedAt when the drain lands", async () => {
const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
scheduledFor: "2026-06-15T05:00:00.000Z",
takenAt: "2026-06-15T05:02:00.000Z",
},
],
}),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { lastSyncedAt: expect.any(Date) },
});
});
});
@@ -52,6 +52,7 @@ import {
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
recomputeMedicationComplianceForDay,
@@ -932,6 +933,12 @@ async function postBulk(request: NextRequest): Promise<Response> {
})();
}
// The SyncMode checkpoint belongs to the push that moved the data, not to
// the `/api/sync/state` handshake that reports it. This endpoint is one of
// the three domains that handshake summarises, so an intake drain advances
// it.
await markSyncCheckpoint(user.id);
return apiSuccess({
processed: entries.length,
inserted,
@@ -29,6 +29,9 @@ const { moodEntryMock, txClient } = vi.hoisted(() => {
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: moodEntryMock,
// The bulk drain stamps the SyncMode checkpoint (`User.lastSyncedAt`) —
// it is a client push, and the push is what the checkpoint describes.
user: { update: vi.fn().mockResolvedValue({}) },
auditLog: { create: vi.fn() },
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
@@ -688,3 +691,32 @@ describe("POST /api/mood-entries/bulk — upsert + tag links are atomic", () =>
expect(prisma.$transaction).toHaveBeenCalled();
});
});
/**
* The SyncMode checkpoint (`User.lastSyncedAt`) belongs to the push that moved
* the data. `GET /api/sync/state` reports it and writes nothing, so if this
* boundary stops stamping, the checkpoint stays null forever and every paired
* client re-pairs with a full backfill.
*/
describe("POST /api/mood-entries/bulk — sync checkpoint", () => {
beforeEach(() => {
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([]);
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
id: "entry-1",
} as never);
});
it("stamps lastSyncedAt when the drain lands", async () => {
const res = await POST(
postReq({
entries: [{ mood: "OKAY", moodLoggedAt: "2026-05-16T08:00:00.000Z" }],
}),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { lastSyncedAt: expect.any(Date) },
});
});
});
+6
View File
@@ -38,6 +38,7 @@ import {
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
import {
getScoreForMood,
moodLevelEnum,
@@ -493,6 +494,11 @@ async function postBulk(request: NextRequest): Promise<Response> {
}
}
// The SyncMode checkpoint belongs to the push that moved the data, not to
// the `/api/sync/state` handshake that reports it. This endpoint is one of
// the three domains that handshake summarises, so a mood drain advances it.
await markSyncCheckpoint(user.id);
return apiSuccess({
processed: entries.length,
inserted,
@@ -0,0 +1,92 @@
/**
* `GET /api/sync/state` is a read.
*
* It used to bump `User.lastSyncedAt` on every call, which is the exact
* side-effecting GET the MCP-audience note in `api-handler.ts` warns against,
* and it advanced the checkpoint past a window the client had not drained.
* The checkpoint is now owned by the ingest boundaries; this route reports it.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findFirst: vi.fn(), count: vi.fn() },
moodEntry: { findFirst: vi.fn(), count: vi.fn() },
medicationIntakeEvent: { findFirst: vi.fn(), count: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null }),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
const CHECKPOINT = new Date("2026-07-30T08:15:00.000Z");
type SyncStateBody = {
lastSyncedAt: string | null;
serverNow: string;
timezone: string;
};
async function callGet(): Promise<SyncStateBody> {
const res = (await (
GET as unknown as (req: unknown) => Promise<{ data: SyncStateBody }>
)({})) as { data: SyncStateBody };
return res.data;
}
describe("GET /api/sync/state", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.measurement.count).mockResolvedValue(0 as never);
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0 as never);
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0 as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
lastSyncedAt: CHECKPOINT,
timezone: "Europe/Berlin",
} as never);
});
it("writes nothing — the handshake never touches the user row", async () => {
await callGet();
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("reports the stored checkpoint rather than the previous handshake", async () => {
const body = await callGet();
expect(body.lastSyncedAt).toBe(CHECKPOINT.toISOString());
});
it("keeps reporting the same checkpoint across repeated calls", async () => {
const first = await callGet();
const second = await callGet();
expect(second.lastSyncedAt).toBe(first.lastSyncedAt);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("reports a never-synced account as null so the client re-pairs full", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
lastSyncedAt: null,
timezone: "Europe/Berlin",
} as never);
const body = await callGet();
expect(body.lastSyncedAt).toBeNull();
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
+18 -18
View File
@@ -12,7 +12,9 @@
* {
* userId,
* timezone,
* lastSyncedAt, — User.lastSyncedAt (ISO string or null)
* lastSyncedAt, — User.lastSyncedAt (ISO string or null).
* Set by the ingest boundaries, not here:
* this route is a read and writes nothing.
* serverNow, — `new Date().toISOString()` for clock skew
* measurements: { lastUpdatedAt, liveCount, tombstonedCount },
* mood: { lastUpdatedAt, liveCount, tombstonedCount },
@@ -45,10 +47,10 @@ import {
export const GET = apiHandler(async (_request: NextRequest) => {
const { user } = await requireAuth();
// Per-domain aggregate reads + one user-side bump. Each runs against
// indexed columns (userId, deleted_at / updated_at) so it stays
// O(rows-for-user) and not O(rows-across-tenants). v1.7.0 adds the
// mood + intake blocks now that both carry the sync columns.
// Per-domain aggregate reads. Each runs against indexed columns (userId,
// deleted_at / updated_at) so it stays O(rows-for-user) and not
// O(rows-across-tenants). v1.7.0 adds the mood + intake blocks now that
// both carry the sync columns.
const [
measurementLatest,
measurementLive,
@@ -96,16 +98,14 @@ export const GET = apiHandler(async (_request: NextRequest) => {
}),
]);
// The handshake also bumps the user's lastSyncedAt — the call IS
// the handshake. iOS reads the OLD lastSyncedAt from the response
// and then trusts that subsequent server writes after the new
// checkpoint will round-trip via the standard read paths.
const previous = currentUser?.lastSyncedAt ?? null;
const nextCheckpoint = new Date();
await prisma.user.update({
where: { id: user.id },
data: { lastSyncedAt: nextCheckpoint },
});
// The handshake reports the checkpoint; it does not set it. The column is
// stamped by the ingest boundaries that actually move data (see
// `markSyncCheckpoint`), so `lastSyncedAt` answers "when did this account
// last push a batch" rather than "when did a client last ask". NULL still
// means never synced, and the value can now only trail the truth — the
// handshake used to advance it past a window the client had not drained.
const lastSyncedAt = currentUser?.lastSyncedAt ?? null;
const serverNow = new Date();
annotate({
action: { name: "sync.state" },
@@ -114,15 +114,15 @@ export const GET = apiHandler(async (_request: NextRequest) => {
tombstonedCount: measurementTomb,
moodLiveCount: moodLive,
intakeLiveCount: intakeLive,
lastSyncedAtBefore: previous?.toISOString() ?? null,
lastSyncedAt: lastSyncedAt?.toISOString() ?? null,
},
});
return apiSuccess({
userId: user.id,
timezone: currentUser?.timezone ?? "Europe/Berlin",
lastSyncedAt: previous?.toISOString() ?? null,
serverNow: nextCheckpoint.toISOString(),
lastSyncedAt: lastSyncedAt?.toISOString() ?? null,
serverNow: serverNow.toISOString(),
measurements: {
lastUpdatedAt: measurementLatest?.updatedAt?.toISOString() ?? null,
liveCount: measurementLive,
@@ -0,0 +1,118 @@
/**
* `refreshPredictionCacheForUser` — the request-free recompute the nightly
* job runs.
*
* Two things are load-bearing. It has to reach the persistence at all (the
* job's whole purpose is that a forecast exists for an account that never
* opened the calendar), and the write's failure has to escape: the previous
* implementation caught everything and returned, so a cache that had not been
* written for weeks looked exactly like one with nothing to write.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
cycleProfile: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
cycleDayLog: { findMany: vi.fn() },
measurement: { findMany: vi.fn() },
cyclePrediction: { findUnique: vi.fn(), upsert: vi.fn() },
},
}));
import { prisma } from "@/lib/db";
import { refreshPredictionCacheForUser } from "@/lib/cycle/prediction-refresh";
const NOW = new Date("2026-06-10T02:20:00.000Z");
function profile(overrides: Record<string, unknown> = {}) {
return {
userId: "user-1",
goal: "GENERAL_HEALTH",
typicalCycleLength: 28,
typicalPeriodLength: 5,
lutealPhaseLength: 14,
predictionEnabled: true,
rawChartMode: false,
secondarySymptom: "MUCUS",
cycleTrackingEnabled: true,
...overrides,
};
}
/** Four period starts 28 days apart, the last one still open. */
const CYCLE_STARTS = ["2026-03-05", "2026-04-02", "2026-04-30", "2026-05-28"];
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(
profile() as never,
);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
CYCLE_STARTS.map((startDate, i) => ({
id: `cyc-${i}`,
userId: "user-1",
startDate,
endDate: null,
periodEndDate: null,
ovulationDate: null,
ovulationConfirmed: false,
})) as never,
);
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cyclePrediction.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.cyclePrediction.upsert).mockResolvedValue({} as never);
});
describe("refreshPredictionCacheForUser", () => {
it("writes the forecast for an account that never opened the calendar", async () => {
const outcome = await refreshPredictionCacheForUser("user-1", NOW);
expect(outcome).toBe("persisted");
expect(prisma.cyclePrediction.upsert).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.cyclePrediction.upsert).mock.calls[0]![0] as {
where: { userId: string };
create: { nextPeriodStart: string; generatedAt: Date };
};
expect(call.where.userId).toBe("user-1");
// A real forecast, not a placeholder: four 28-day cycles put the next
// period start 28 days after the last one.
expect(call.create.nextPeriodStart).toBe("2026-06-25");
expect(call.create.generatedAt).toEqual(NOW);
});
it("writes nothing for an account with prediction turned off", async () => {
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(
profile({ predictionEnabled: false }) as never,
);
const outcome = await refreshPredictionCacheForUser("user-1", NOW);
expect(outcome).toBe("no-prediction");
expect(prisma.cyclePrediction.upsert).not.toHaveBeenCalled();
});
it("writes nothing for an account that has no cycle profile", async () => {
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
const outcome = await refreshPredictionCacheForUser("user-1", NOW);
expect(outcome).toBe("no-profile");
expect(prisma.cyclePrediction.upsert).not.toHaveBeenCalled();
});
it("lets a failed write escape instead of swallowing it", async () => {
vi.mocked(prisma.cyclePrediction.upsert).mockRejectedValue(
new Error("write rejected"),
);
await expect(
refreshPredictionCacheForUser("user-1", NOW),
).rejects.toThrowError("write rejected");
});
});
+57 -55
View File
@@ -2,13 +2,20 @@
* Cycle prediction cache — debounced stale-while-revalidate (the v1.8.7
* insight-assessment cache pattern, ios-contract §2.D).
*
* `GET /api/cycle/calendar` runs the pure engine synchronously (it is
* cheap) for the live forecast it returns, AND persists that forecast to
* the `CyclePrediction` cache row so the Coach snapshot + notifications
* read a stable materialised value without re-running the engine. The
* persist is debounced: a re-write only lands when the prior row is older
* than the debounce window OR the forecast actually changed, so a tight
* read loop never thrashes the row.
* The `CyclePrediction` row is what the cycle-reminder cron scans to find
* who is due a period-soon or fertile-soon push. It is written by the
* `cycle-prediction-refresh` job (see `prediction-refresh.ts`), never by a
* read: `GET /api/cycle/calendar` runs the same engine for the forecast it
* returns and persists nothing.
*
* The persist is debounced: a re-write only lands when the prior row is
* older than the debounce window OR the forecast actually changed, so a
* repeated refresh never thrashes the row.
*
* Failures propagate. This used to swallow every error, which made a cache
* that had not been written for weeks indistinguishable from one with
* nothing to write; the caller is a job and puts the failure on the
* operator's failing-jobs surface.
*/
import { prisma } from "@/lib/db";
import type { CyclePredictionResult } from "@/lib/cycle/types";
@@ -18,63 +25,58 @@ const PREDICTION_DEBOUNCE_MS = 6 * 60 * 60 * 1000; // 6h
/**
* Persist (or refresh) the cached forecast for a user, debounced. A
* no-op when a fresh-enough identical row already exists. Best-effort:
* never throws into the read path.
* no-op when a fresh-enough identical row already exists.
*/
export async function persistPredictionCache(
userId: string,
result: CyclePredictionResult,
now: Date = new Date(),
): Promise<void> {
try {
const existing = await prisma.cyclePrediction.findUnique({
where: { userId },
select: {
method: true,
nextPeriodStart: true,
nextPeriodStartLow: true,
nextPeriodStartHigh: true,
confidence: true,
cyclesObserved: true,
generatedAt: true,
},
});
const existing = await prisma.cyclePrediction.findUnique({
where: { userId },
select: {
method: true,
nextPeriodStart: true,
nextPeriodStartLow: true,
nextPeriodStartHigh: true,
confidence: true,
cyclesObserved: true,
generatedAt: true,
},
});
const unchanged =
existing !== null &&
existing.method === result.method &&
existing.nextPeriodStart === result.nextPeriodStart &&
existing.nextPeriodStartLow === result.nextPeriodStartLow &&
existing.nextPeriodStartHigh === result.nextPeriodStartHigh &&
existing.confidence === result.confidence &&
existing.cyclesObserved === result.cyclesObserved;
const unchanged =
existing !== null &&
existing.method === result.method &&
existing.nextPeriodStart === result.nextPeriodStart &&
existing.nextPeriodStartLow === result.nextPeriodStartLow &&
existing.nextPeriodStartHigh === result.nextPeriodStartHigh &&
existing.confidence === result.confidence &&
existing.cyclesObserved === result.cyclesObserved;
const fresh =
existing !== null &&
now.getTime() - existing.generatedAt.getTime() < PREDICTION_DEBOUNCE_MS;
const fresh =
existing !== null &&
now.getTime() - existing.generatedAt.getTime() < PREDICTION_DEBOUNCE_MS;
// Skip the write only when the row is BOTH unchanged AND fresh.
if (unchanged && fresh) return;
// Skip the write only when the row is BOTH unchanged AND fresh.
if (unchanged && fresh) return;
const data = {
method: result.method,
nextPeriodStart: result.nextPeriodStart,
nextPeriodStartLow: result.nextPeriodStartLow,
nextPeriodStartHigh: result.nextPeriodStartHigh,
fertileWindowStart: result.fertileWindowStart,
fertileWindowEnd: result.fertileWindowEnd,
predictedOvulation: result.predictedOvulation,
confidence: result.confidence,
cyclesObserved: result.cyclesObserved,
generatedAt: now,
};
const data = {
method: result.method,
nextPeriodStart: result.nextPeriodStart,
nextPeriodStartLow: result.nextPeriodStartLow,
nextPeriodStartHigh: result.nextPeriodStartHigh,
fertileWindowStart: result.fertileWindowStart,
fertileWindowEnd: result.fertileWindowEnd,
predictedOvulation: result.predictedOvulation,
confidence: result.confidence,
cyclesObserved: result.cyclesObserved,
generatedAt: now,
};
await prisma.cyclePrediction.upsert({
where: { userId },
create: { userId, ...data },
update: data,
});
} catch {
/* best-effort cache write — the live engine result is authoritative */
}
await prisma.cyclePrediction.upsert({
where: { userId },
create: { userId, ...data },
update: data,
});
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Recompute the cached cycle forecast for one account from the persisted
* rows, without a request.
*
* `CyclePrediction` is not a cache of a read — it is the index the
* cycle-reminder cron scans to find who is due a period-soon or fertile-soon
* push. Until v1.35.3 its only writer was `GET /api/cycle/calendar`, which
* meant the reminders an account received depended on whether somebody had
* opened the calendar recently, and the write itself was a discarded promise
* inside a `try {} catch {}` that swallowed the failure. Both ends are fixed
* here: the refresh runs on the schedule that consumes it, and a failed write
* throws so the job reports it.
*
* The reads mirror the calendar route's default window so the forecast the
* cron scans and the forecast the calendar renders come out of the same
* inputs.
*/
import { prisma } from "@/lib/db";
import { predictCycle } from "@/lib/cycle";
import {
toCycleInputs,
toDayLogInputs,
toProfileInput,
} from "@/lib/cycle/engine-adapter";
import { persistPredictionCache } from "@/lib/cycle/prediction-cache";
import { addDays } from "@/lib/cycle/day-math";
import { BBT_WINDOW } from "@/lib/cycle/types";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
/** Day-log lookback: the calendar's default past span or the symptothermal
* window, whichever reaches further back. */
const LOOKBACK_DAYS = Math.max(90, BBT_WINDOW);
/** Wrist/skin-temperature lookback feeding the temperature-trend layer. */
const TEMPERATURE_LOOKBACK_DAYS = 90;
export type PredictionRefreshOutcome =
/** A forecast was computed and the cache row reflects it. */
| "persisted"
/** No profile row, so the account has never touched cycle tracking. */
| "no-profile"
/** Prediction is off for this account (raw-chart mode / explicit opt-out),
* or the engine had nothing to forecast from. */
| "no-prediction";
/**
* Recompute and persist the forecast for one account. Throws if the write
* fails — the caller is a job and owns the failure.
*/
export async function refreshPredictionCacheForUser(
userId: string,
now: Date = new Date(),
): Promise<PredictionRefreshOutcome> {
const [profile, user] = await Promise.all([
prisma.cycleProfile.findUnique({ where: { userId } }),
prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
}),
]);
if (!profile) return "no-profile";
if (!profile.predictionEnabled || profile.rawChartMode) {
return "no-prediction";
}
const tz = user?.timezone ?? DEFAULT_TIMEZONE;
const today = moodDateKey(now, tz);
const [cycles, dayLogs, nightlyTemps] = await Promise.all([
prisma.menstrualCycle.findMany({
where: { userId, deletedAt: null },
orderBy: { startDate: "asc" },
}),
prisma.cycleDayLog.findMany({
where: {
userId,
deletedAt: null,
date: { gte: addDays(today, -LOOKBACK_DAYS) },
},
orderBy: { date: "asc" },
select: {
date: true,
flow: true,
basalBodyTempC: true,
temperatureExcluded: true,
ovulationTest: true,
cervicalMucus: true,
cervixPosition: true,
cervixFirmness: true,
cervixOpening: true,
},
}),
prisma.measurement.findMany({
where: {
userId,
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: new Date(
Date.parse(
`${addDays(today, -TEMPERATURE_LOOKBACK_DAYS)}T00:00:00Z`,
),
),
},
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
}),
]);
const nights = nightlyTemps.map((m) => ({
date: moodDateKey(m.measuredAt, tz),
valueC: m.value,
}));
const prediction = predictCycle(
toCycleInputs(cycles),
toDayLogInputs(dayLogs),
toProfileInput(profile),
today,
nights,
);
await persistPredictionCache(userId, prediction, now);
return "persisted";
}
@@ -0,0 +1,135 @@
/**
* The nightly achievement-unlock sweep.
*
* This is where the `UserAchievement` rows are written now that
* `GET /api/gamification/achievements` is a pure read. Two things have to
* hold: the rows actually get written (a sweep that persists nothing is the
* silent half of a two-ended change), and the module gate the route applied
* still applies here — a badge whose owning module is off must never be
* pinned just because nobody is watching the read path any more.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findMany: vi.fn() },
userAchievement: { createMany: vi.fn() },
},
}));
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: vi.fn(async () => ({})),
}));
vi.mock("@/lib/logging/background", () => ({
withBackgroundEvent: async (
_name: string,
fn: (evt: { addMeta: () => void }) => Promise<unknown>,
) => fn({ addMeta: () => {} }),
}));
import { prisma } from "@/lib/db";
import { resolveModuleMap } from "@/lib/modules/gate";
import {
runAchievementUnlockSweep,
handleAchievementUnlockSweep,
} from "@/lib/jobs/achievement-unlock-sweep";
const USERS = [
{ id: "user-1", timezone: "Europe/Berlin" },
{ id: "user-2", timezone: "Europe/Berlin" },
];
function resultWith(ids: string[]) {
return {
pendingUnlocks: ids.map((achievementId) => ({
achievementId,
unlockedAt: new Date("2026-07-04T10:00:00.000Z"),
})),
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.user.findMany).mockResolvedValue(USERS as never);
vi.mocked(prisma.userAchievement.createMany).mockResolvedValue({
count: 1,
} as never);
});
describe("achievement-unlock-sweep", () => {
it("pins every unpinned unlock it finds", async () => {
const summary = await runAchievementUnlockSweep({
resolveModules: (async () => ({})) as never,
buildResult: (async () => resultWith(["mood-first"])) as never,
});
expect(summary.scanned).toBe(2);
// The count is the point: an empty sweep would satisfy "no duplicates"
// just as well as a working one.
expect(vi.mocked(prisma.userAchievement.createMany)).toHaveBeenCalledTimes(
2,
);
expect(summary.persisted).toBe(2);
expect(vi.mocked(prisma.userAchievement.createMany)).toHaveBeenCalledWith({
data: [
{
userId: "user-1",
achievementId: "mood-first",
unlockedAt: new Date("2026-07-04T10:00:00.000Z"),
},
],
skipDuplicates: true,
});
});
it("writes nothing for an account with the achievements module off", async () => {
const summary = await runAchievementUnlockSweep({
resolveModules: (async () => ({ achievements: false })) as never,
buildResult: (async () => resultWith(["mood-first"])) as never,
});
expect(summary.skipped).toBe(2);
expect(summary.persisted).toBe(0);
expect(vi.mocked(prisma.userAchievement.createMany)).not.toHaveBeenCalled();
});
it("keeps sweeping after one account throws and counts the failure", async () => {
let call = 0;
const summary = await runAchievementUnlockSweep({
resolveModules: (async () => ({})) as never,
buildResult: (async () => {
call += 1;
if (call === 1) throw new Error("evaluation blew up");
return resultWith(["mood-first"]);
}) as never,
});
expect(summary.failed).toBe(1);
expect(summary.persisted).toBe(1);
expect(vi.mocked(prisma.userAchievement.createMany)).toHaveBeenCalledTimes(
1,
);
});
it("fails the job when every account failed", async () => {
// A fault that hits every account (a dead pool, a schema drift) is
// systemic and must reach the operator's failing-jobs surface rather
// than pass quietly with a zero count.
vi.mocked(prisma.user.findMany).mockResolvedValue([USERS[0]] as never);
vi.mocked(resolveModuleMap).mockRejectedValue(new Error("pool gone"));
const outcome = await handleAchievementUnlockSweep([]);
expect(outcome.ok).toBe(false);
});
it("succeeds when there was simply nothing to pin", async () => {
const summary = await runAchievementUnlockSweep({
resolveModules: (async () => ({})) as never,
buildResult: (async () => resultWith([])) as never,
});
expect(summary.persisted).toBe(0);
expect(summary.failed).toBe(0);
expect(vi.mocked(prisma.userAchievement.createMany)).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,108 @@
/**
* The nightly cycle-forecast refresh.
*
* `CyclePrediction` is the row the cycle-reminder cron scans, and this job is
* its only writer now that `GET /api/cycle/calendar` is a pure read. The
* property that matters is that the forecast for an account that never opens
* the calendar still gets written — the failure this replaces was silent in
* both directions (a write on a read, and a discarded promise that hid the
* failure).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { cycleProfile: { findMany: vi.fn() } },
}));
vi.mock("@/lib/cycle/gate", () => ({
isCycleAvailableForUser: vi.fn(async () => true),
}));
vi.mock("@/lib/cycle/prediction-refresh", () => ({
refreshPredictionCacheForUser: vi.fn(async () => "persisted"),
}));
vi.mock("@/lib/logging/background", () => ({
withBackgroundEvent: async (
_name: string,
fn: (evt: { addMeta: () => void }) => Promise<unknown>,
) => fn({ addMeta: () => {} }),
}));
import { prisma } from "@/lib/db";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { refreshPredictionCacheForUser } from "@/lib/cycle/prediction-refresh";
import {
runCyclePredictionRefresh,
handleCyclePredictionRefresh,
} from "@/lib/jobs/cycle-prediction-refresh";
const NOW = new Date("2026-07-30T02:20:00.000Z");
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.cycleProfile.findMany).mockResolvedValue([
{ userId: "user-1" },
{ userId: "user-2" },
] as never);
vi.mocked(isCycleAvailableForUser).mockResolvedValue(true);
vi.mocked(refreshPredictionCacheForUser).mockResolvedValue("persisted");
});
describe("cycle-prediction-refresh", () => {
it("refreshes every tracking account, opened calendar or not", async () => {
const summary = await runCyclePredictionRefresh(NOW);
expect(summary.scanned).toBe(2);
// The count is the point: a cohort query that matched nothing would
// satisfy "no bad writes" just as well as a working sweep.
expect(vi.mocked(refreshPredictionCacheForUser)).toHaveBeenCalledTimes(2);
expect(summary.refreshed).toBe(2);
expect(vi.mocked(refreshPredictionCacheForUser)).toHaveBeenCalledWith(
"user-1",
NOW,
);
});
it("skips an account whose cycle module is off", async () => {
vi.mocked(isCycleAvailableForUser).mockResolvedValue(false);
const summary = await runCyclePredictionRefresh(NOW);
expect(summary.skipped).toBe(2);
expect(summary.refreshed).toBe(0);
expect(vi.mocked(refreshPredictionCacheForUser)).not.toHaveBeenCalled();
});
it("counts an account whose refresh throws and carries on", async () => {
vi.mocked(refreshPredictionCacheForUser)
.mockRejectedValueOnce(new Error("bad rows"))
.mockResolvedValueOnce("persisted");
const summary = await runCyclePredictionRefresh(NOW);
expect(summary.failed).toBe(1);
expect(summary.refreshed).toBe(1);
});
it("fails the job when every account failed", async () => {
// Systemic, not one account's data — it belongs on the operator's
// failing-jobs surface rather than passing quietly.
vi.mocked(prisma.cycleProfile.findMany).mockResolvedValue([
{ userId: "user-1" },
] as never);
vi.mocked(refreshPredictionCacheForUser).mockRejectedValue(
new Error("pool gone"),
);
const outcome = await handleCyclePredictionRefresh([]);
expect(outcome.ok).toBe(false);
});
it("succeeds when no account tracks a cycle", async () => {
vi.mocked(prisma.cycleProfile.findMany).mockResolvedValue([] as never);
const outcome = await handleCyclePredictionRefresh([]);
expect(outcome.ok).toBe(true);
});
});
@@ -0,0 +1,78 @@
/**
* Dead-queue guard for the two queues that took the writes off the read paths.
*
* Moving a write off a GET only works if the new owner actually runs. A queue
* that is not in `allQueues` is never provisioned, a schedule without a
* `createAndWork` binding drains into nothing, and either failure is silent:
* the cron fires, the job vanishes, and the only symptom is a forecast that
* stops moving or an unlock date that is never pinned. Both look exactly like
* "nothing to do".
*
* Source-text assertions over the registrar, matching the sibling
* `*-queue.test.ts` guards.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { SUBSYSTEM_SURFACE } from "@/lib/jobs/subsystem-surface";
const registrar = readFileSync(
join(__dirname, "..", "reminder", "register-maintenance.ts"),
"utf8",
);
const allQueues = registrar.match(/const allQueues\s*=\s*\[([\s\S]*?)\n\];/);
const QUEUES = [
{
label: "cycle-prediction-refresh",
binding: "CYCLE_PREDICTION_REFRESH_QUEUE",
cron: "CYCLE_PREDICTION_REFRESH_CRON",
handler: "handleCyclePredictionRefresh",
name: "cycle-prediction-refresh",
},
{
label: "achievement-unlock-sweep",
binding: "ACHIEVEMENT_UNLOCK_SWEEP_QUEUE",
cron: "ACHIEVEMENT_UNLOCK_SWEEP_CRON",
handler: "handleAchievementUnlockSweep",
name: "achievement-unlock-sweep",
},
];
describe("queues that own a write moved off a GET", () => {
it("extracted an allQueues list to assert against", () => {
// The regexes below all read from this capture; if it ever stops
// matching, every assertion in this file would pass on an empty string.
expect(allQueues).not.toBeNull();
expect(allQueues![1].length).toBeGreaterThan(0);
});
for (const queue of QUEUES) {
describe(queue.label, () => {
it("is provisioned in the allQueues list", () => {
expect(allQueues![1]).toMatch(new RegExp(`\\b${queue.binding}\\b`));
});
it("carries a cron schedule", () => {
expect(registrar).toMatch(
new RegExp(`\\[\\s*${queue.binding},\\s*${queue.cron}`),
);
});
it("binds a createAndWork handler that drains it", () => {
expect(registrar).toMatch(
new RegExp(
`createAndWork[\\s\\S]{0,200}${queue.binding}[\\s\\S]{0,200}${queue.handler}`,
),
);
});
it("declares a failure audience", () => {
expect(Object.keys(SUBSYSTEM_SURFACE)).toContain(queue.name);
});
});
}
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Nightly sweep that pins newly unlocked achievements.
*
* An unlock date is derived from the account's own history, so the badge
* grid renders correctly whether or not a `UserAchievement` row exists — the
* row is what makes the DATE durable once the underlying rows move, and it
* is what the backup carries ("the date is the part that cannot be
* recovered").
*
* Until v1.35.3 the only thing that wrote those rows was
* `GET /api/gamification/achievements`. That made a durable record a side
* effect of somebody looking at a page: an account that earned a badge and
* never opened the app pinned nothing, and a read carried an INSERT that an
* MCP-audience read token would have been admitted on the moment any route
* declared a narrow scope. The evaluation is unchanged and still runs on the
* read; only the persistence moved here, where it covers every account
* rather than the ones that happened to look.
*
* The date written is the computed completion date, not the sweep's own
* clock, so a badge pinned tonight and the same badge pinned a week from now
* carry the identical timestamp. Running late costs nothing but exposure to
* a data edit in between.
*
* Runs at 04:25 Europe/Berlin, after the cycle-prediction refresh (04:20).
*/
import type { Job } from "pg-boss";
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
import { withBackgroundEvent } from "@/lib/logging/background";
import { resolveModuleMap } from "@/lib/modules/gate";
import { buildAchievementsResult } from "@/lib/gamification/achievements-result";
import { getWorkerPrisma } from "./reminder/shared";
export const ACHIEVEMENT_UNLOCK_SWEEP_QUEUE = "achievement-unlock-sweep";
export const ACHIEVEMENT_UNLOCK_SWEEP_CRON = "25 4 * * *";
export interface AchievementUnlockSweepPayload {
triggeredAt: string;
}
export interface AchievementUnlockSweepSummary {
/** Accounts the sweep looked at. */
scanned: number;
/** Accounts with the achievements module off. */
skipped: number;
/** Unlock rows written across every account. */
persisted: number;
/** Accounts whose evaluation or write threw. */
failed: number;
}
/**
* Pin every unpinned unlock across all accounts. Exported for the test; the
* handler below is the queue binding.
*/
export async function runAchievementUnlockSweep(
options: {
resolveModules?: typeof resolveModuleMap;
buildResult?: typeof buildAchievementsResult;
} = {},
): Promise<AchievementUnlockSweepSummary> {
const resolveModules = options.resolveModules ?? resolveModuleMap;
const buildResult = options.buildResult ?? buildAchievementsResult;
const prisma = getWorkerPrisma();
const users = await prisma.user.findMany({ orderBy: { id: "asc" } });
const summary: AchievementUnlockSweepSummary = {
scanned: users.length,
skipped: 0,
persisted: 0,
failed: 0,
};
for (const user of users) {
try {
const moduleMap = await resolveModules(user.id);
// Same gate the route applies: an account with the module off gets no
// badge evaluation and no unlock persistence.
if (moduleMap.achievements === false) {
summary.skipped += 1;
continue;
}
const result = await buildResult(user, moduleMap);
if (result.pendingUnlocks.length === 0) continue;
// Idempotent on the `(userId, achievementId)` unique, so a re-run
// against an already-pinned badge is a no-op rather than a duplicate.
const written = await prisma.userAchievement.createMany({
data: result.pendingUnlocks.map((u) => ({
userId: user.id,
achievementId: u.achievementId,
unlockedAt: new Date(u.unlockedAt),
})),
skipDuplicates: true,
});
summary.persisted += written.count;
} catch {
// One account's evaluation must not cost every other account its
// pinned dates. The count is what makes a systemic failure visible.
summary.failed += 1;
}
}
return summary;
}
export async function handleAchievementUnlockSweep(
jobs: Job<AchievementUnlockSweepPayload>[],
): Promise<JobOutcome> {
void jobs;
return withBackgroundEvent("job.achievement_unlock_sweep", async (evt) => {
const summary = await runAchievementUnlockSweep();
evt.addMeta("achievement_sweep_scanned", summary.scanned);
evt.addMeta("achievement_sweep_skipped", summary.skipped);
evt.addMeta("achievement_sweep_persisted", summary.persisted);
evt.addMeta("achievement_sweep_failed", summary.failed);
// Every account failing is a systemic fault, not one account's data.
if (summary.failed > 0 && summary.failed === summary.scanned) {
return jobFailed(
`achievement-unlock-sweep failed for all ${summary.failed} accounts`,
);
}
return jobDone({
users_scanned: summary.scanned,
skipped: summary.skipped,
persisted: summary.persisted,
users_failed: summary.failed,
});
});
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Nightly refresh of the `CyclePrediction` forecast rows.
*
* The cycle-reminder cron scans `CyclePrediction` to decide who is due a
* period-soon or fertile-soon push. Until v1.35.3 those rows were written
* only as a side effect of `GET /api/cycle/calendar`, so an account that
* tracked its cycle but never opened the calendar page was scanned against a
* forecast made from whatever its data looked like the last time somebody
* looked — or was never scanned at all. Moving the write here makes the
* reminder's input a function of the account's data instead of its browsing.
*
* Cohort: accounts that hold a `CycleProfile` with prediction not explicitly
* off, resolved through the same two-layer cycle gate the routes use, so an
* operator-disabled module refreshes nothing. Per-account failures are
* counted and the pass continues; a pass that failed for every account it
* touched fails the job so the operator sees it.
*
* Runs at 04:20 Europe/Berlin, after the 03:xx retention block. The forecast
* moves on a day scale and the reminder windows are days wide, so a fixed
* server-side hour is close enough for every timezone the cohort spans.
*/
import type { Job } from "pg-boss";
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
import { withBackgroundEvent } from "@/lib/logging/background";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { refreshPredictionCacheForUser } from "@/lib/cycle/prediction-refresh";
import { getWorkerPrisma } from "./reminder/shared";
export const CYCLE_PREDICTION_REFRESH_QUEUE = "cycle-prediction-refresh";
export const CYCLE_PREDICTION_REFRESH_CRON = "20 4 * * *";
export interface CyclePredictionRefreshPayload {
triggeredAt: string;
}
export interface CyclePredictionRefreshSummary {
/** Profiles the cohort query returned. */
scanned: number;
/** Accounts whose forecast row now reflects their data. */
refreshed: number;
/** Accounts skipped: module off, prediction off, or raw-chart mode. */
skipped: number;
/** Accounts whose refresh threw. */
failed: number;
}
/**
* Refresh every eligible account's forecast. Exported for the test; the
* handler below is the queue binding.
*/
export async function runCyclePredictionRefresh(
now: Date = new Date(),
options: {
isAvailable?: typeof isCycleAvailableForUser;
refresh?: typeof refreshPredictionCacheForUser;
} = {},
): Promise<CyclePredictionRefreshSummary> {
const isAvailable = options.isAvailable ?? isCycleAvailableForUser;
const refresh = options.refresh ?? refreshPredictionCacheForUser;
const prisma = getWorkerPrisma();
const profiles = await prisma.cycleProfile.findMany({
where: { NOT: { predictionEnabled: false } },
select: { userId: true },
});
const summary: CyclePredictionRefreshSummary = {
scanned: profiles.length,
refreshed: 0,
skipped: 0,
failed: 0,
};
for (const { userId } of profiles) {
try {
if (!(await isAvailable(userId))) {
summary.skipped += 1;
continue;
}
const outcome = await refresh(userId, now);
if (outcome === "persisted") summary.refreshed += 1;
else summary.skipped += 1;
} catch {
// One account's bad data must not cost every other account its
// reminders. The count is what makes a systemic failure visible.
summary.failed += 1;
}
}
return summary;
}
export async function handleCyclePredictionRefresh(
jobs: Job<CyclePredictionRefreshPayload>[],
): Promise<JobOutcome> {
void jobs;
return withBackgroundEvent("job.cycle_prediction_refresh", async (evt) => {
const summary = await runCyclePredictionRefresh();
evt.addMeta("cycle_prediction_scanned", summary.scanned);
evt.addMeta("cycle_prediction_refreshed", summary.refreshed);
evt.addMeta("cycle_prediction_skipped", summary.skipped);
evt.addMeta("cycle_prediction_failed", summary.failed);
// Every account failing is a systemic fault (a dead pool, a schema drift),
// not one account's bad data. Anything short of that stays a success with
// the count on the event.
if (summary.failed > 0 && summary.failed === summary.scanned) {
return jobFailed(
`cycle-prediction-refresh failed for all ${summary.failed} accounts`,
);
}
return jobDone({
users_scanned: summary.scanned,
refreshed: summary.refreshed,
skipped: summary.skipped,
users_failed: summary.failed,
});
});
}
@@ -189,6 +189,18 @@ import {
handleDocumentPurge,
type DocumentPurgePayload,
} from "@/lib/jobs/document-purge";
import {
CYCLE_PREDICTION_REFRESH_QUEUE,
CYCLE_PREDICTION_REFRESH_CRON,
handleCyclePredictionRefresh,
type CyclePredictionRefreshPayload,
} from "@/lib/jobs/cycle-prediction-refresh";
import {
ACHIEVEMENT_UNLOCK_SWEEP_QUEUE,
ACHIEVEMENT_UNLOCK_SWEEP_CRON,
handleAchievementUnlockSweep,
type AchievementUnlockSweepPayload,
} from "@/lib/jobs/achievement-unlock-sweep";
const DATA_BACKUP_QUEUE = "data-backup";
@@ -349,6 +361,13 @@ const allQueues = [
// v1.31.0 — data-arrival reaction-marker prune. Without this entry the
// daily schedule silently no-ops and the markers accumulate forever.
ARRIVAL_REACTION_CLEANUP_QUEUE,
// v1.35.3 — nightly refresh of the cycle forecast rows the reminder cron
// scans. Without this entry the schedule silently no-ops and the forecast
// is only as fresh as the last time somebody opened the calendar.
CYCLE_PREDICTION_REFRESH_QUEUE,
// v1.35.3 — nightly pin for newly unlocked achievements. Without this entry
// the schedule silently no-ops and no unlock date is ever made durable.
ACHIEVEMENT_UNLOCK_SWEEP_QUEUE,
// v1.7.0 — soft-deleted measurement tombstone prune. Without this entry
// the daily schedule silently no-ops and pruned-past-retention tombstones
// pile up forever.
@@ -494,6 +513,22 @@ const schedules: ScheduleEntry[] = [
[NOTE_ENCRYPTION_BACKFILL_QUEUE, NOTE_ENCRYPTION_BACKFILL_CRON],
// v1.24 — daily 04:00 Europe/Berlin prune for dead MCP connector tokens.
[MCP_TOKEN_CLEANUP_QUEUE, MCP_TOKEN_CLEANUP_CRON, cronIsTheRetry],
// v1.35.3 — daily 04:20 Europe/Berlin refresh of the cycle forecast rows
// the reminder cron scans. Tomorrow's tick is the retry: the forecast moves
// on a day scale and the reminder windows are days wide.
[
CYCLE_PREDICTION_REFRESH_QUEUE,
CYCLE_PREDICTION_REFRESH_CRON,
cronIsTheRetry,
],
// v1.35.3 — daily 04:25 Europe/Berlin pin for newly unlocked achievements.
// Tomorrow's tick is the retry: the date written is the computed completion
// date, so a night late writes the identical row.
[
ACHIEVEMENT_UNLOCK_SWEEP_QUEUE,
ACHIEVEMENT_UNLOCK_SWEEP_CRON,
cronIsTheRetry,
],
// v1.25 — daily 04:05 Europe/Berlin medication-note encryption backfill
// discovery. The empty cron payload (no `userId`) is the handler's signal to
// fan out one per-user job per account still holding a plaintext medication
@@ -699,6 +734,24 @@ export async function registerMaintenanceQueues(
{ localConcurrency: 1 },
handleArrivalReactionCleanup,
);
// v1.35.3 — nightly cycle-forecast refresh. Single-flight: two ticks
// recomputing the same cohort is wasted work and the second upsert is a
// no-op against the debounce.
await createAndWork<CyclePredictionRefreshPayload>(
boss,
CYCLE_PREDICTION_REFRESH_QUEUE,
{ localConcurrency: 1 },
handleCyclePredictionRefresh,
);
// v1.35.3 — nightly achievement-unlock pin. Single-flight: the underlying
// createMany is idempotent on the (userId, achievementId) unique, so a
// second concurrent tick would only re-walk the same accounts.
await createAndWork<AchievementUnlockSweepPayload>(
boss,
ACHIEVEMENT_UNLOCK_SWEEP_QUEUE,
{ localConcurrency: 1 },
handleAchievementUnlockSweep,
);
// v1.7.0 — daily prune of expired measurement tombstones. Single-flight
// like every other cleanup queue.
await createAndWork<MeasurementTombstoneCleanupPayload>(
+2
View File
@@ -116,6 +116,8 @@ const subsystemSurface = {
"mood-reminder-cleanup": { audience: "system" },
"push-attempt-cleanup": { audience: "system" },
"arrival-reaction-cleanup": { audience: "system" },
"cycle-prediction-refresh": { audience: "system" },
"achievement-unlock-sweep": { audience: "system" },
"measurement-tombstone-cleanup": { audience: "system" },
"coach-message-cleanup": { audience: "system" },
"note-encryption-backfill": { audience: "system" },
+2 -2
View File
@@ -55,7 +55,7 @@ const syncStateResponse = z
.meta({
id: "SyncStateResponse",
description:
"iOS SyncMode handshake. Each GET also advances the server-side `lastSyncedAt` checkpoint and returns the previous value. The cheap 'should I sync?' summary; the durable delta cursor lives on `/api/sync/changes`.",
"iOS SyncMode handshake. Reports the server-side `lastSyncedAt` checkpoint, which the batch ingest endpoints set when a client push lands; the handshake itself writes nothing. The cheap 'should I sync?' summary; the durable delta cursor lives on `/api/sync/changes`.",
});
const syncMeasurementUpsert = measurementResource
@@ -224,7 +224,7 @@ export const syncPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Sync"],
summary: "Sync handshake + window metadata",
description:
"Cheap 'should I sync?' summary. Returns the previous `lastSyncedAt` checkpoint and advances it server-side on each call. The `sync` block carries the incremental-delta window + tombstone retention so the client reads them rather than hardcoding.",
"Cheap 'should I sync?' summary. Returns the `lastSyncedAt` checkpoint, which the batch ingest endpoints advance when a client push lands; this route is a read and writes nothing. The `sync` block carries the incremental-delta window + tombstone retention so the client reads them rather than hardcoding.",
responses: {
"200": {
description: "Sync state summary.",
+33
View File
@@ -0,0 +1,33 @@
/**
* The SyncMode checkpoint — `User.lastSyncedAt`.
*
* One writer contract: the checkpoint is stamped by the client-push ingest
* boundaries (the measurement / mood / intake batch endpoints), never by the
* handshake that reports it. `GET /api/sync/state` is the only reader, so the
* column has to describe a sync that actually moved data rather than the fact
* that a client asked what the server knows.
*
* Stamping it on the handshake was also unsafe in exactly the watermark sense
* the provider pipelines already guard against (`sync-measure-watermark`,
* `sync-dead-token`): it advanced the checkpoint past a window the client had
* not drained yet, and a client that trusts the checkpoint then skips that
* window. Under-advancing a watermark costs a redundant fetch; over-advancing
* loses rows. Stamped here, the checkpoint can only ever trail the truth.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
/**
* Stamp `User.lastSyncedAt`. Call from an ingest boundary once the batch has
* reached a durable verdict — never from a read.
*/
export async function markSyncCheckpoint(
userId: string,
at: Date = new Date(),
): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { lastSyncedAt: at },
});
annotate({ meta: { sync_checkpoint_at: at.toISOString() } });
}