diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4aeedec14..483115844 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,36 @@
## [Unreleased]
+## [1.35.5] — 2026-08-02
+
+### Fixed
+
+- Cycle reminders no longer depend on whether you happened to open the calendar.
+ The prediction a reminder is sent from was written only while that page was
+ being viewed, so an account that never opened it had nothing for the reminder
+ job to read. The prediction is now refreshed nightly for every account that
+ tracks a cycle, whether or not anyone looks at the page.
+- A failed prediction write is no longer silent. It used to be discarded, so
+ nothing anywhere recorded that it had not happened.
+- The sync handshake no longer marks an account as synced. Asking the server
+ where to resume is not the same as sending anything, and treating it as a sync
+ moved the resume point past data the app had not fetched yet. The mark is now
+ set by the three paths that actually receive data, so it can only trail a real
+ sync rather than run ahead of one.
+- Badges are awarded whether or not you open the app. The date a badge was
+ earned was recorded only when the achievements screen was loaded, so an
+ account that earned one and did not look never had it written down, and the
+ date is the part that cannot be reconstructed later. A nightly pass now
+ records it, using the date it was actually earned rather than the date it was
+ noticed.
+
+### Internal
+
+- Three read endpoints no longer write. A read that writes widens what a
+ read-only credential can reach, which the code says plainly at the place the
+ rule is enforced, and it had drifted at three routes. A check now watches
+ those three.
+
## [1.35.4] — 2026-08-02
### Fixed
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index cd5d39b81..932c2def9 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.35.4
+ version: 1.35.5
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -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:
diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts
index 03b200203..3cd202203 100644
--- a/e2e/a11y.spec.ts
+++ b/e2e/a11y.spec.ts
@@ -664,12 +664,21 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [
painted: (page) => page.locator(".recharts-wrapper").first(),
},
{
+ // The two states this page's OWN render produces: the list, or the
+ // no-workouts empty state. Never `[role="status"]`, because the busy
+ // skeletons carry it too (`chart-skeleton` in the streamed route shell is
+ // one), so that selector matched the loading tree the server sends before
+ // the page client has mounted anything. On a loaded runner the scan then
+ // landed on a document whose only heading had not rendered yet and blamed
+ // the app for a missing `
` it does have. Same rule as the overview
+ // case above: a sentinel must prove the content, not the placeholder
+ // standing in for it.
name: "/insights/workouts list",
path: "/insights/workouts",
painted: (page) =>
page
.locator(
- '#main-content [data-slot="workout-list"], #main-content [role="status"]',
+ '#main-content [data-slot="workout-list"], #main-content [data-slot="empty-state"]',
)
.first(),
},
@@ -679,9 +688,13 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [
painted: (page) => page.locator('[data-slot="workout-detail-header"]'),
},
{
+ // `coach-page` is the sizing wrapper around a ``,
+ // so it is on screen while the conversation below it is still nothing at
+ // all. Gate on the conversation surface itself.
name: "/coach",
path: "/coach",
- painted: (page) => page.locator('[data-slot="coach-page"]').first(),
+ painted: (page) =>
+ page.locator('[data-slot="coach-conversation"][data-variant="page"]'),
},
];
diff --git a/e2e/utils/settle.ts b/e2e/utils/settle.ts
index de8afc088..dac403129 100644
--- a/e2e/utils/settle.ts
+++ b/e2e/utils/settle.ts
@@ -1,5 +1,23 @@
import { expect, type Locator, type Page } from "@playwright/test";
+/**
+ * How long either settle wait in `settleBeforeMeasure` may take before the
+ * spec moves on.
+ *
+ * Both of those waits are written to end on their own terms; neither was
+ * bounded, and neither inherits a bound from anywhere, because the config
+ * leaves `actionTimeout` and `navigationTimeout` unset and Playwright reads
+ * that as "no limit". One request that never completes therefore ends the
+ * whole test instead of just the wait, and it ends it in the least useful way
+ * available: the run reports whichever call was still pending when the fixture
+ * tore the page down, so the message names a `page.evaluate` against a closed
+ * target rather than the surface that stalled. Six seconds is an order of
+ * magnitude above the slowest settle measured anywhere in this suite. Past
+ * that it is a stall, and a stall belongs in front of the spec's own
+ * assertions, which can say what is actually wrong.
+ */
+const SETTLE_BUDGET_MS = 6_000;
+
/**
* Gate a one-shot DOM read on the element it is about to measure.
*
@@ -45,6 +63,30 @@ export async function settleBeforeMeasure(
.catch(() => {});
// Fonts and late CSS change geometry after the element is visible, and
// every measurement in this suite is geometric.
- await page.waitForLoadState("networkidle").catch(() => {});
- await page.evaluate(() => document.fonts?.ready);
+ await page
+ .waitForLoadState("networkidle", { timeout: SETTLE_BUDGET_MS })
+ .catch(() => {});
+ await waitForFonts(page);
+}
+
+/**
+ * Wait for webfonts to settle, bounded inside the page.
+ *
+ * `document.fonts.ready` belongs to the document, so awaiting it from a
+ * `page.evaluate` hands the test an unbounded wait on a promise no timeout
+ * reaches. Race it against a timer in the page instead, so a font that never
+ * arrives costs this wait and nothing else.
+ */
+export async function waitForFonts(
+ page: Page,
+ timeout = SETTLE_BUDGET_MS,
+): Promise {
+ await page.evaluate(async (budget: number) => {
+ const ready = document.fonts?.ready;
+ if (!ready) return;
+ await Promise.race([
+ ready,
+ new Promise((resolve) => window.setTimeout(resolve, budget)),
+ ]);
+ }, timeout);
}
diff --git a/e2e/v1341-ui-evidence.spec.ts b/e2e/v1341-ui-evidence.spec.ts
index 6448d3472..bbfccb930 100644
--- a/e2e/v1341-ui-evidence.spec.ts
+++ b/e2e/v1341-ui-evidence.spec.ts
@@ -11,7 +11,7 @@ import {
mockDashboardSnapshot,
} from "./utils/mock-dashboard-snapshot";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
-import { settleBeforeMeasure } from "./utils/settle";
+import { settleBeforeMeasure, waitForFonts } from "./utils/settle";
const EVIDENCE_DIR = join(process.cwd(), "test-results", "v1341");
@@ -46,7 +46,9 @@ async function settleAndCapture(
content:
"*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}",
});
- await page.evaluate(() => document.fonts?.ready);
+ // The injected stylesheet can pull a font the page had not needed yet, so
+ // the wait repeats after it. Bounded, for the reason `waitForFonts` gives.
+ await waitForFonts(page);
const path = join(EVIDENCE_DIR, filename);
await page.screenshot({ path, animations: "disabled" });
await testInfo.attach(filename, { path, contentType: "image/png" });
diff --git a/package.json b/package.json
index 21819c8f7..653629c95 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.35.4",
+ "version": "1.35.5",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index bc3a14196..ddb70cc5d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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.
diff --git a/public/sw.js b/public/sw.js
index e902efa2a..563dc2dbf 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
- /* @sw-version-fallback */ "v1.35.4";
+ /* @sw-version-fallback */ "v1.35.5";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
diff --git a/src/app/api/__tests__/read-routes-write-nothing.test.ts b/src/app/api/__tests__/read-routes-write-nothing.test.ts
new file mode 100644
index 000000000..4418293a8
--- /dev/null
+++ b/src/app/api/__tests__/read-routes-write-nothing.test.ts
@@ -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);
+ });
+ }
+});
diff --git a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts
index 83ca9fb97..eb19019b7 100644
--- a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts
+++ b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts
@@ -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).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.
diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts
index 830999cfd..0a4c95aad 100644
--- a/src/app/api/cycle/calendar/route.ts
+++ b/src/app/api/cycle/calendar/route.ts
@@ -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,
diff --git a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts
index 76842cf9e..b892cd289 100644
--- a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts
+++ b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts
@@ -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.
});
diff --git a/src/app/api/gamification/achievements/__tests__/writes-nothing.test.ts b/src/app/api/gamification/achievements/__tests__/writes-nothing.test.ts
new file mode 100644
index 000000000..e600c2bd0
--- /dev/null
+++ b/src/app/api/gamification/achievements/__tests__/writes-nothing.test.ts
@@ -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 })
+ .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");
+ });
+});
diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts
index 0661bcbdc..595a1679c 100644
--- a/src/app/api/gamification/achievements/route.ts
+++ b/src/app/api/gamification/achievements/route.ts
@@ -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,
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 = {
diff --git a/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts b/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts
index ab6f7dcf3..01f5cb72d 100644
--- a/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts
+++ b/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts
@@ -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 () => {
diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts
index 68cee2ff2..df8ffcd59 100644
--- a/src/app/api/measurements/batch/route.ts
+++ b/src/app/api/measurements/batch/route.ts
@@ -643,9 +643,18 @@ async function postBatch(request: NextRequest): Promise {
);
});
- // 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 {
// 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 }
+ : {}),
+ }
: {}),
},
});
diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts
index 8f1632bde..b8b61c992 100644
--- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts
+++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts
@@ -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) },
+ });
+ });
+});
diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts
index 996cab697..9feded721 100644
--- a/src/app/api/medications/intake/bulk/route.ts
+++ b/src/app/api/medications/intake/bulk/route.ts
@@ -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 {
})();
}
+ // 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,
diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts
index 081284866..9062e59dc 100644
--- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts
+++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts
@@ -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) },
+ });
+ });
+});
diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts
index b9ee78003..a8f3bd3d2 100644
--- a/src/app/api/mood-entries/bulk/route.ts
+++ b/src/app/api/mood-entries/bulk/route.ts
@@ -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 {
}
}
+ // 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,
diff --git a/src/app/api/sync/state/__tests__/route.test.ts b/src/app/api/sync/state/__tests__/route.test.ts
new file mode 100644
index 000000000..46c8c3036
--- /dev/null
+++ b/src/app/api/sync/state/__tests__/route.test.ts
@@ -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: 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 {
+ 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();
+ });
+});
diff --git a/src/app/api/sync/state/route.ts b/src/app/api/sync/state/route.ts
index 18dba6b9b..dd9ce0a4e 100644
--- a/src/app/api/sync/state/route.ts
+++ b/src/app/api/sync/state/route.ts
@@ -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,
diff --git a/src/lib/cycle/__tests__/prediction-refresh.test.ts b/src/lib/cycle/__tests__/prediction-refresh.test.ts
new file mode 100644
index 000000000..ae37e6a8d
--- /dev/null
+++ b/src/lib/cycle/__tests__/prediction-refresh.test.ts
@@ -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 = {}) {
+ 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");
+ });
+});
diff --git a/src/lib/cycle/prediction-cache.ts b/src/lib/cycle/prediction-cache.ts
index 4ad546003..5eac68203 100644
--- a/src/lib/cycle/prediction-cache.ts
+++ b/src/lib/cycle/prediction-cache.ts
@@ -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 {
- 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,
+ });
}
diff --git a/src/lib/cycle/prediction-refresh.ts b/src/lib/cycle/prediction-refresh.ts
new file mode 100644
index 000000000..c324788e5
--- /dev/null
+++ b/src/lib/cycle/prediction-refresh.ts
@@ -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 {
+ 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";
+}
diff --git a/src/lib/jobs/__tests__/achievement-unlock-sweep.test.ts b/src/lib/jobs/__tests__/achievement-unlock-sweep.test.ts
new file mode 100644
index 000000000..d751a0368
--- /dev/null
+++ b/src/lib/jobs/__tests__/achievement-unlock-sweep.test.ts
@@ -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,
+ ) => 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();
+ });
+});
diff --git a/src/lib/jobs/__tests__/cycle-prediction-refresh.test.ts b/src/lib/jobs/__tests__/cycle-prediction-refresh.test.ts
new file mode 100644
index 000000000..f177b7b5d
--- /dev/null
+++ b/src/lib/jobs/__tests__/cycle-prediction-refresh.test.ts
@@ -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,
+ ) => 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);
+ });
+});
diff --git a/src/lib/jobs/__tests__/read-side-effect-queues.test.ts b/src/lib/jobs/__tests__/read-side-effect-queues.test.ts
new file mode 100644
index 000000000..71c952909
--- /dev/null
+++ b/src/lib/jobs/__tests__/read-side-effect-queues.test.ts
@@ -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);
+ });
+ });
+ }
+});
diff --git a/src/lib/jobs/achievement-unlock-sweep.ts b/src/lib/jobs/achievement-unlock-sweep.ts
new file mode 100644
index 000000000..0a4a12685
--- /dev/null
+++ b/src/lib/jobs/achievement-unlock-sweep.ts
@@ -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 {
+ 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[],
+): Promise {
+ 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,
+ });
+ });
+}
diff --git a/src/lib/jobs/cycle-prediction-refresh.ts b/src/lib/jobs/cycle-prediction-refresh.ts
new file mode 100644
index 000000000..9e47d94f6
--- /dev/null
+++ b/src/lib/jobs/cycle-prediction-refresh.ts
@@ -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 {
+ 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[],
+): Promise {
+ 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,
+ });
+ });
+}
diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts
index 1f4f327ad..332dd8c1d 100644
--- a/src/lib/jobs/reminder/register-maintenance.ts
+++ b/src/lib/jobs/reminder/register-maintenance.ts
@@ -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(
+ 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(
+ 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(
diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts
index aa208997b..04550e269 100644
--- a/src/lib/jobs/subsystem-surface.ts
+++ b/src/lib/jobs/subsystem-surface.ts
@@ -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" },
diff --git a/src/lib/openapi/routes/sync.ts b/src/lib/openapi/routes/sync.ts
index 001dc5623..a28d266b9 100644
--- a/src/lib/openapi/routes/sync.ts
+++ b/src/lib/openapi/routes/sync.ts
@@ -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 = {
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.",
diff --git a/src/lib/sync/checkpoint.ts b/src/lib/sync/checkpoint.ts
new file mode 100644
index 000000000..a90a85776
--- /dev/null
+++ b/src/lib/sync/checkpoint.ts
@@ -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 {
+ await prisma.user.update({
+ where: { id: userId },
+ data: { lastSyncedAt: at },
+ });
+ annotate({ meta: { sync_checkpoint_at: at.toISOString() } });
+}
diff --git a/tests/integration/cycle-routes.test.ts b/tests/integration/cycle-routes.test.ts
index 446245858..26f755aae 100644
--- a/tests/integration/cycle-routes.test.ts
+++ b/tests/integration/cycle-routes.test.ts
@@ -480,16 +480,67 @@ describe("cycle calendar", () => {
// GENERAL_HEALTH default → fertile window suppressed.
expect(json.data.prediction.fertileWindowStart).toBeNull();
- // The cache row is persisted fire-and-forget (the route never blocks
- // the read on it), so poll briefly for it to land.
- let cache = null;
- for (let i = 0; i < 20 && cache === null; i++) {
- cache = await prisma.cyclePrediction.findUnique({
+ // The read persists nothing. The prediction row exists for the reminder
+ // job, and writing it here made the job's behaviour depend on whether
+ // anyone had opened this page. Poll anyway rather than reading once, so
+ // that a write which merely arrives late still fails the case.
+ for (let i = 0; i < 20; i++) {
+ const cache = await prisma.cyclePrediction.findUnique({
where: { userId: FEMALE_USER_ID },
});
- if (cache === null) await new Promise((r) => setTimeout(r, 25));
+ expect(cache).toBeNull();
+ await new Promise((r) => setTimeout(r, 25));
}
- expect(cache).not.toBeNull();
+ });
+
+ it("the nightly refresh writes the prediction the read no longer writes", async () => {
+ // The other end of the move. Without this the case above would pass
+ // against a prediction nothing writes at all.
+ //
+ // `beforeEach` truncates, so this case seeds its own history rather than
+ // inheriting the one above. It also never touches the calendar route: the
+ // point is that the prediction lands without anyone opening that page.
+ //
+ // The profile row is seeded directly for the same reason. Every
+ // `/api/cycle/*` route creates one on the way past, so in production the
+ // cohort is "has ever used cycle tracking" rather than "opened the
+ // calendar recently". Calling a route to get one here would defeat the
+ // case.
+ const prisma = getPrismaClient();
+ await prisma.cycleProfile.create({ data: { userId: FEMALE_USER_ID } });
+ for (const [start, end, len] of [
+ ["2026-01-01", "2026-01-28", 28],
+ ["2026-01-29", "2026-02-25", 28],
+ ] as const) {
+ await prisma.menstrualCycle.create({
+ data: {
+ userId: FEMALE_USER_ID,
+ startDate: start,
+ endDate: end,
+ lengthDays: len,
+ periodEndDate: null,
+ },
+ });
+ }
+ await prisma.menstrualCycle.create({
+ data: { userId: FEMALE_USER_ID, startDate: "2026-02-26" },
+ });
+
+ expect(
+ await prisma.cyclePrediction.findUnique({
+ where: { userId: FEMALE_USER_ID },
+ }),
+ ).toBeNull();
+
+ const { refreshPredictionCacheForUser } =
+ await import("@/lib/cycle/prediction-refresh");
+ await refreshPredictionCacheForUser(FEMALE_USER_ID);
+
+ expect(
+ await prisma.cyclePrediction.findUnique({
+ where: { userId: FEMALE_USER_ID },
+ }),
+ ).not.toBeNull();
});
});
diff --git a/tests/integration/sync-state.test.ts b/tests/integration/sync-state.test.ts
index 01d349efd..b3318519f 100644
--- a/tests/integration/sync-state.test.ts
+++ b/tests/integration/sync-state.test.ts
@@ -103,21 +103,41 @@ describe("GET /api/sync/state (real Postgres)", () => {
expect(json.data.intakes.liveCount).toBe(0);
expect(json.data.intakes.tombstonedCount).toBe(0);
- // Server-side, lastSyncedAt now carries a value.
+ // The handshake asks where to resume. It does not deliver anything, so it
+ // must not move the resume point: advancing past a window the client has
+ // not drained is how rows go missing.
const after = await getPrismaClient().user.findUnique({
where: { id: TEST_USER_ID },
select: { lastSyncedAt: true },
});
- expect(after?.lastSyncedAt).not.toBeNull();
+ expect(after?.lastSyncedAt).toBeNull();
});
- it("returns the previous checkpoint on the second call", async () => {
+ it("still reports null on the second call, because asking twice is not syncing", async () => {
+ // The old contract stamped the checkpoint on the first call, so the second
+ // read found one. Nothing has arrived between these two calls, so the
+ // honest answer is still null.
const { GET } = await import("@/app/api/sync/state/route");
await GET(makeRequest());
const res = await GET(makeRequest());
const json = (await res.json()) as {
data: { lastSyncedAt: string | null };
};
+ expect(json.data.lastSyncedAt).toBeNull();
+ });
+
+ it("reports a checkpoint once data has actually arrived", async () => {
+ // The other half of the move: the mark now comes from an ingest boundary.
+ // Without this case the two above would pass just as well against a column
+ // nothing writes at all, which is the failure they exist to rule out.
+ const { markSyncCheckpoint } = await import("@/lib/sync/checkpoint");
+ await markSyncCheckpoint(TEST_USER_ID);
+
+ const { GET } = await import("@/app/api/sync/state/route");
+ const res = await GET(makeRequest());
+ const json = (await res.json()) as {
+ data: { lastSyncedAt: string | null };
+ };
expect(json.data.lastSyncedAt).not.toBeNull();
});