mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Take the writes off three read routes
A GET must not have side effects — api-handler.ts says so next to READ_HTTP_METHODS, where an MCP-audience token is admitted on the safe methods precisely because they are assumed to write nothing. Three routes broke that: the sync handshake bumped User.lastSyncedAt, the achievements read inserted unlock rows, and the cycle calendar fired a discarded promise at the forecast cache so a failed write left no trace anywhere. Each write moves to the place that causes it. The sync checkpoint is now stamped by the batch ingest endpoints for measurements, mood and intakes. The handshake reports it. That also fixes the watermark: the checkpoint used to advance past a window the client had not drained, and a client trusting it would skip that window. It can only trail a real sync now. The unlock rows move to a nightly achievement-unlock-sweep. The badge grid never needed them — unlocked comes from the live metrics and the completion date is derived — so the payload is unchanged; the rows pin that date and carry it into the backup, and the sweep reaches accounts that never open the page. The cycle forecast moves to a nightly cycle-prediction-refresh. The CyclePrediction row is not a cache of a read: it is the index the reminder cron scans, so which reminders an account received depended on whether somebody had opened the calendar. persistPredictionCache no longer swallows its errors either — the job reports them.
This commit is contained in:
1 parent
eb641dcd7b
commit
6ac3a61829
27 files changed
+1365
-139
No files matched your search
@@ -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() } });
|
||||
}
|
||||
Reference in new issue
Block a user