Take the writes off three read routes

A GET must not have side effects — api-handler.ts says so next to
READ_HTTP_METHODS, where an MCP-audience token is admitted on the safe
methods precisely because they are assumed to write nothing. Three routes
broke that: the sync handshake bumped User.lastSyncedAt, the achievements
read inserted unlock rows, and the cycle calendar fired a discarded promise
at the forecast cache so a failed write left no trace anywhere.

Each write moves to the place that causes it.

The sync checkpoint is now stamped by the batch ingest endpoints for
measurements, mood and intakes. The handshake reports it. That also fixes
the watermark: the checkpoint used to advance past a window the client had
not drained, and a client trusting it would skip that window. It can only
trail a real sync now.

The unlock rows move to a nightly achievement-unlock-sweep. The badge grid
never needed them — unlocked comes from the live metrics and the completion
date is derived — so the payload is unchanged; the rows pin that date and
carry it into the backup, and the sweep reaches accounts that never open
the page.

The cycle forecast moves to a nightly cycle-prediction-refresh. The
CyclePrediction row is not a cache of a read: it is the index the reminder
cron scans, so which reminders an account received depended on whether
somebody had opened the calendar. persistPredictionCache no longer
swallows its errors either — the job reports them.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 01:21:03 +02:00
1 parent eb641dcd7b
commit 6ac3a61829
27 files changed
+1365 -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,
@@ -185,7 +185,12 @@ describe("GET /api/gamification/achievements — per-module badge skipping", ()
},
];
type Badge = { id: string; category: string; metric: string };
type Badge = {
id: string;
category: string;
metric: string;
unlocked: boolean;
};
async function badgesFor(
moduleMap: Record<string, boolean>,
@@ -217,14 +222,14 @@ 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);
}
it("persists no unlock at all — a GET does not write", async () => {
// A mood badge unlocks at >=1 entry, so this read HAS an unlock to
// notice. It still writes nothing: the row that pins the date is the
// `achievement-unlock-sweep` job's, and a read carrying an INSERT is
// the side-effecting GET `api-handler.ts` warns against. The badge grid
// is unaffected — `unlocked` comes from the live metrics.
const badges = await badgesFor({ mood: true });
expect(badges.some((b) => b.category === "mood" && b.unlocked)).toBe(true);
expect(prisma.userAchievement.createMany).not.toHaveBeenCalled();
});
});
+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() } });
}