Merge pull request #679 from MBombeck/release/v1.35.5

release/v1.35.5
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-02 02:15:25 +02:00
commit 4a8a4e1e30
33 files changed
+1593 -152

No files matched your search

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