mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
33 files changed
+1593
-152
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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() } });
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user