From da2cd6287650b4d63a79f2e0c431d90de8001989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 00:33:55 +0200 Subject: [PATCH 1/8] fix(rollups): register read-path warm-ups with the background registry The mood rollup warm-up on the dashboard summary was started with a bare `void`, so `settleBackgroundTasks()` never saw it. On a stale account the warm-up started a full recompute that could still be writing when the statement counter closed its window, and the hot-route budget read 30 against 28 depending on where it landed. Every read-path start of that shape now goes through `trackBackgroundTask(runDetached(...))`: the measurement and mood rollup warm-ups (seven call sites), the dashboard's today-briefing warm, and the two delegated-access bookkeeping writes in the record guard. A guard refuses a bare `void` on any of them. `trackBackgroundTask` now wraps its handle in `Promise.resolve`: a test double that returns nothing used to leave `undefined` in the set, and settle spun on it at full CPU. The budget fixture mints the mood rollups in its seed, as it already did for the measurement rollups. Eight serial runs of query-budget-hot-routes read the same counts every time (dashboard summary 28, snapshot 99, day 47, health-status 59, correlations 43). --- .../background-task-settle-guard.test.ts | 8 +++ src/__tests__/detached-work-guard.test.ts | 53 +++++++++++++++++++ .../analytics/__tests__/mood-series.test.ts | 31 ++++++++++- src/lib/analytics/mood-series.ts | 6 ++- src/lib/analytics/response/default-body.ts | 4 +- src/lib/analytics/summaries-slice.ts | 4 +- src/lib/auth/guards/record.ts | 8 ++- src/lib/dashboard/snapshot-read.ts | 22 +++++--- src/lib/insights/comprehensive-aggregator.ts | 4 +- .../comprehensive-overview/mood-series.ts | 4 +- src/lib/insights/mood-daily-points.ts | 4 +- src/lib/logging/background-tasks.ts | 11 ++-- src/lib/targets/build-response.ts | 4 +- .../query-budget-hot-routes.test.ts | 6 +++ 14 files changed, 148 insertions(+), 21 deletions(-) diff --git a/src/__tests__/background-task-settle-guard.test.ts b/src/__tests__/background-task-settle-guard.test.ts index e6b564813..dad2f0e28 100644 --- a/src/__tests__/background-task-settle-guard.test.ts +++ b/src/__tests__/background-task-settle-guard.test.ts @@ -142,6 +142,14 @@ describe("background-task settle contract", () => { expect(pendingBackgroundTaskCount()).toBe(0); }); + it("drains a handle that is not a promise instead of spinning on it", async () => { + // A mocked background starter that returns nothing hands the registry + // `undefined`; left in the set, settle would loop on it forever. + trackBackgroundTask(undefined as unknown as Promise); + await settleBackgroundTasks(); + expect(pendingBackgroundTaskCount()).toBe(0); + }); + it("registers what fireAndForget starts", async () => { let release: () => void = () => {}; fireAndForget( diff --git a/src/__tests__/detached-work-guard.test.ts b/src/__tests__/detached-work-guard.test.ts index 8f3992048..c4903abf0 100644 --- a/src/__tests__/detached-work-guard.test.ts +++ b/src/__tests__/detached-work-guard.test.ts @@ -91,3 +91,56 @@ describe("detached work cannot rename the request's event", () => { expect(startsDetached(detached)).toBe(true); }); }); + +/** + * Read-path work a request starts and never awaits. Each of these runs real + * statements against Postgres after the response is already on its way, so a + * bare `void` hides it from `settleBackgroundTasks()`: the statement counter + * then closes its window while a stale account's rollup recompute is still + * writing, and the next test inherits the tail. The dashboard summary budget + * read 30 against 28 that way, depending on which side of the window a full + * mood recompute landed. + */ +const READ_PATH_BACKGROUND_STARTERS = [ + "ensureUserRollupsFresh", + "ensureUserMoodRollupsFresh", + "requestTodayBriefingWarm", + "touchGrantUsage", + "recordDelegatedAccess", +] as const; + +describe("read-path background work is registered", () => { + const files = readSourceFolder("src", { + floor: 3000, + include: (rel) => + !rel.startsWith("generated/") && + !rel.includes("__tests__/") && + !/\.test\.tsx?$/.test(rel), + }); + const bare = new RegExp( + String.raw`\bvoid\s+(${READ_PATH_BACKGROUND_STARTERS.join("|")})\s*\(`, + ); + + it("starts none of them with a bare void", () => { + const offenders = files.flatMap(([rel, text]) => + stripComments(text) + .split("\n") + .flatMap((line, i) => (bare.test(line) ? [`src/${rel}:${i + 1}`] : [])), + ); + expect( + offenders, + "wrap the call in trackBackgroundTask(runDetached(() => ...)) or " + + "fireAndForget(...) so the suite can await it", + ).toEqual([]); + }); + + it("still finds each starter at a detached call site", () => { + const joined = files.map(([, text]) => stripComments(text)).join("\n"); + for (const name of READ_PATH_BACKGROUND_STARTERS) { + expect( + new RegExp(String.raw`=>\s*${name}\s*\(`).test(joined), + `${name} is no longer started detached anywhere; drop it from the list`, + ).toBe(true); + } + }); +}); diff --git a/src/lib/analytics/__tests__/mood-series.test.ts b/src/lib/analytics/__tests__/mood-series.test.ts index e29667876..a3ba1fcd2 100644 --- a/src/lib/analytics/__tests__/mood-series.test.ts +++ b/src/lib/analytics/__tests__/mood-series.test.ts @@ -31,7 +31,14 @@ vi.mock("@/lib/rollups/mood-rollups", async () => { import { buildMoodDailySeries } from "../mood-series"; import { prisma } from "@/lib/db"; -import { readMoodDayRollups } from "@/lib/rollups/mood-rollups"; +import { + ensureUserMoodRollupsFresh, + readMoodDayRollups, +} from "@/lib/rollups/mood-rollups"; +import { + pendingBackgroundTaskCount, + settleBackgroundTasks, +} from "@/lib/logging/background-tasks"; beforeEach(() => { vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); @@ -104,4 +111,26 @@ describe("buildMoodDailySeries", () => { expect(series.source).toBe("rollup"); expect(series.summary.count).toBe(0); }); + + it("files the rollup warm-up with the background registry", async () => { + // The warm-up is not awaited by the read, so a caller that has to know + // when the request's work is over (the statement counter, the suite's + // settle hook) can only see it through the registry. Left unregistered, + // a stale account's full recompute bled into whatever ran next. + let release: () => void = () => {}; + vi.mocked(ensureUserMoodRollupsFresh).mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve({ recomputed: true }); + }), + ); + + await buildMoodDailySeries("user-1"); + + expect(ensureUserMoodRollupsFresh).toHaveBeenCalledWith("user-1"); + expect(pendingBackgroundTaskCount()).toBe(1); + release(); + await settleBackgroundTasks(); + expect(pendingBackgroundTaskCount()).toBe(0); + }); }); diff --git a/src/lib/analytics/mood-series.ts b/src/lib/analytics/mood-series.ts index 8e57d64e3..e921062c5 100644 --- a/src/lib/analytics/mood-series.ts +++ b/src/lib/analytics/mood-series.ts @@ -12,6 +12,8 @@ */ import type { PrismaClient } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { runDetached } from "@/lib/logging/detached"; +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { ensureUserMoodRollupsFresh, @@ -94,7 +96,9 @@ export async function buildMoodDailySeries( ): Promise { // Fire-and-forget warm-up so the next cold mount for this user lands on // the rollup tier even when the boot-time backfill hasn't reached them. - void ensureUserMoodRollupsFresh(userId); + // Registered so a caller that must know when the request's work is over + // (the suite's settle hook, the statement counter) can await it. + trackBackgroundTask(runDetached(() => ensureUserMoodRollupsFresh(userId))); const since = new Date(Date.now() - MOOD_SERIES_WINDOW_MS); const rollups = await readMoodDayRollups(userId, since); diff --git a/src/lib/analytics/response/default-body.ts b/src/lib/analytics/response/default-body.ts index 4ba36afe1..e8c5bb87d 100644 --- a/src/lib/analytics/response/default-body.ts +++ b/src/lib/analytics/response/default-body.ts @@ -5,6 +5,8 @@ * the sleep stages. The stages run in the order the route always ran them. */ import type { SessionUser } from "@/lib/auth/session-user"; +import { runDetached } from "@/lib/logging/detached"; +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import { annotate } from "@/lib/logging/context"; import type { Locale } from "@/lib/i18n/config"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; @@ -42,7 +44,7 @@ export async function buildAnalyticsResponse( // measurement lands, and the next request returns the up-to-date // value once the background refresh completes. `ensureUserRollupsFresh` // wraps its own try/catch, so the void call cannot reject. - void ensureUserRollupsFresh(user.id); + trackBackgroundTask(runDetached(() => ensureUserRollupsFresh(user.id))); // Single per-type coverage probe shared by the // bp_in_target / healthScore / correlations branches below. The diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 031022a05..f7d481893 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -59,6 +59,8 @@ import pLimit from "p-limit"; import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { runDetached } from "@/lib/logging/detached"; +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import { HOURLY_MEAN_DAY_TYPES, usesHourlyMeanDay, @@ -284,7 +286,7 @@ export async function computeSummariesSlice( // coverage probe falls back to live SQL for uncovered types, so // correctness is preserved; the read just doesn't block waiting // for the background refresh. - void ensureUserRollupsFresh(userId); + trackBackgroundTask(runDetached(() => ensureUserRollupsFresh(userId))); // v1.4.36 — per-type coverage probe replaces the legacy global // COUNT. The previous gate returned true as soon as ANY type had at diff --git a/src/lib/auth/guards/record.ts b/src/lib/auth/guards/record.ts index 076450841..44010e554 100644 --- a/src/lib/auth/guards/record.ts +++ b/src/lib/auth/guards/record.ts @@ -1,5 +1,7 @@ import type { AccountGrant } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { runDetached } from "@/lib/logging/detached"; +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import { SESSION_USER_SELECT, type SessionUser } from "@/lib/auth/session-user"; import { annotate, getEvent } from "@/lib/logging/context"; import { recordDelegatedAccess } from "@/lib/auth/audit"; @@ -372,13 +374,15 @@ async function resolveSwitchedRecord( }); // Fire-and-forget, the `ApiToken.lastUsedAt` posture: the read must not wait // on the bookkeeping, and the bookkeeping failing must not fail the read. - void touchGrantUsage(grant.id); + trackBackgroundTask(runDetached(() => touchGrantUsage(grant.id))); // The owner's copy of the same fact, and the one they can read: a day- // coalesced row on their own record saying who opened it. It lands here // rather than in the handlers because a route cannot forget what it never // had to remember — every delegated request in the product passes through // this line, and only requests that got past the grant check reach it. - void recordDelegatedAccess(owner.id, auth.user.id); + trackBackgroundTask( + runDetached(() => recordDelegatedAccess(owner.id, auth.user.id)), + ); return { session: auth.session, diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts index cd98809e0..4038d91cd 100644 --- a/src/lib/dashboard/snapshot-read.ts +++ b/src/lib/dashboard/snapshot-read.ts @@ -24,6 +24,8 @@ import type { SessionUser } from "@/lib/auth/session-user"; import { prisma } from "@/lib/db"; +import { runDetached } from "@/lib/logging/detached"; +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import { annotate } from "@/lib/logging/context"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { dashboardSnapshotCacheKey } from "@/lib/cache/invalidate"; @@ -201,14 +203,18 @@ export async function readDashboardSnapshotCached( // its claim, never thrown. if (briefingAi.available) { const lastSeen = cached.tiles?.lastSeenByType ?? {}; - void requestTodayBriefingWarm({ - userId: user.id, - locale, - timezone: user.timezone, - generatedAt, - lastSeenAt: (type) => lastSeen[type]?.lastSeenAt ?? null, - now, - }).catch(() => undefined); + trackBackgroundTask( + runDetached(() => + requestTodayBriefingWarm({ + userId: user.id, + locale, + timezone: user.timezone, + generatedAt, + lastSeenAt: (type) => lastSeen[type]?.lastSeenAt ?? null, + now, + }), + ).catch(() => undefined), + ); } return { body, locale }; diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts index 0c9e035a5..5f46f0cc0 100644 --- a/src/lib/insights/comprehensive-aggregator.ts +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -98,6 +98,8 @@ * shape `summarize()` would return. */ import { prisma } from "@/lib/db"; +import { runDetached } from "@/lib/logging/detached"; +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import type { DataSummary } from "@/lib/analytics/trends"; import { liveTrend, @@ -261,7 +263,7 @@ export async function buildComprehensiveAggregate( // coverage probe falls back to live SQL for uncovered types, so // correctness is preserved; the read just doesn't block waiting // for the background refresh. - void ensureUserRollupsFresh(userId); + trackBackgroundTask(runDetached(() => ensureUserRollupsFresh(userId))); // v1.4.36 — per-type coverage probe. The legacy global COUNT // returned true as soon as any one type had a DAY bucket, which made diff --git a/src/lib/insights/comprehensive-overview/mood-series.ts b/src/lib/insights/comprehensive-overview/mood-series.ts index 40efa5afd..6deda77c9 100644 --- a/src/lib/insights/comprehensive-overview/mood-series.ts +++ b/src/lib/insights/comprehensive-overview/mood-series.ts @@ -22,6 +22,8 @@ * left on it deliberately (see their call sites in `build.ts`). */ import { prisma } from "@/lib/db"; +import { runDetached } from "@/lib/logging/detached"; +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import type { DataPoint } from "@/lib/analytics/trends"; import { ensureUserMoodRollupsFresh, @@ -41,7 +43,7 @@ export async function readOverviewMoodSeries( userId: string, since: Date, ): Promise { - void ensureUserMoodRollupsFresh(userId); + trackBackgroundTask(runDetached(() => ensureUserMoodRollupsFresh(userId))); const moodRollupDayRows = await readMoodDayRollups(userId, since); if (moodRollupDayRows.length > 0) { return { diff --git a/src/lib/insights/mood-daily-points.ts b/src/lib/insights/mood-daily-points.ts index 00b91b87a..9309c1bb0 100644 --- a/src/lib/insights/mood-daily-points.ts +++ b/src/lib/insights/mood-daily-points.ts @@ -4,6 +4,8 @@ * Split out of `features.ts`, which builds the block from them. */ import { prisma } from "@/lib/db"; +import { runDetached } from "@/lib/logging/detached"; +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import { ensureUserMoodRollupsFresh, readMoodDayRollups, @@ -39,7 +41,7 @@ export async function readMoodDailyPoints( totalEntries: number; latestScore: number | null; }> { - void ensureUserMoodRollupsFresh(userId); + trackBackgroundTask(runDetached(() => ensureUserMoodRollupsFresh(userId))); // Five-year window mirrors the mood-rollup writer default; covers // every realistic user history span without an unbounded scan. const fiveYearsAgo = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000); diff --git a/src/lib/logging/background-tasks.ts b/src/lib/logging/background-tasks.ts index 5516ef44e..7b1dee275 100644 --- a/src/lib/logging/background-tasks.ts +++ b/src/lib/logging/background-tasks.ts @@ -56,9 +56,14 @@ const inFlight = new Set>(); export function trackBackgroundTask(settled: Promise): void { if (!TRACK_IN_FLIGHT) return; - inFlight.add(settled); - void settled.finally(() => { - inFlight.delete(settled); + // A test double that stands in for the background work often returns + // nothing at all. Filed as is, `undefined` would never leave the set and + // `settleBackgroundTasks` would spin on it forever at full CPU. A real + // promise passes through `Promise.resolve` as the same object. + const handle = Promise.resolve(settled); + inFlight.add(handle); + void handle.finally(() => { + inFlight.delete(handle); }); } diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 1c28f8775..b236b894a 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -11,6 +11,8 @@ * are read in the user's own calendar. */ import { prisma } from "@/lib/db"; +import { runDetached } from "@/lib/logging/detached"; +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import { annotate } from "@/lib/logging/context"; import { COURSES_COMPLIANCE_SELECT, @@ -102,7 +104,7 @@ export async function buildTargetsResponse(user: AuthedUser) { queryNow.getTime() - 5 * 365 * 24 * 60 * 60 * 1000, ); - void ensureUserMoodRollupsFresh(userId); + trackBackgroundTask(runDetached(() => ensureUserMoodRollupsFresh(userId))); // All independent reads stay in one bounded fan-out. The medication event // read is the sole dependent query and remains chained to its medication diff --git a/tests/integration/query-budget-hot-routes.test.ts b/tests/integration/query-budget-hot-routes.test.ts index cb0390f26..0a50202d6 100644 --- a/tests/integration/query-budget-hot-routes.test.ts +++ b/tests/integration/query-budget-hot-routes.test.ts @@ -281,9 +281,15 @@ async function seed(): Promise { }), }); + // Both rollup tiers are minted here, the way a running account has them. + // Seeded rows without their rollups make the first read start a full + // recompute in the background, and that is a warm-up cost, not the read's. const { recomputeUserRollups } = await import("@/lib/rollups/measurement-rollups"); await recomputeUserRollups(USER_ID); + const { recomputeUserMoodRollups } = + await import("@/lib/rollups/mood-rollups"); + await recomputeUserMoodRollups(USER_ID); const session = await db.session.create({ data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, From f7b89f96e199d7bb05799d369ace9d544d7a9034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 00:34:55 +0200 Subject: [PATCH 2/8] perf(sync): walk the mirror domain from the switched tokens `/api/sync/changes` read the `measurementMirror` domain with one query that ordered every token-linked reading by the joined token's `mirror_changed_at`. No index on `measurements` can serve that order, so every poll read and sorted every row of every token ever switched, even when nothing had changed past the client's watermark. The walk now reads the account's tokens with `mirror_changed_at` at or past the watermark first. Nearly every poll finds none and stops there. Otherwise it walks each change stamp's readings by `(ingest_token_id, id)`, the order the index holds, with tokens that share a stamp walked together so rows interleave by id as before. Rows, order, page boundaries and the cursor are unchanged; the wire contract is untouched. EXPLAIN (ANALYZE, BUFFERS) on a seeded Postgres 16: one account, a token with 300,000 readings switched at T0, a second token with 50 readings switched at T1, 900,000 untokened readings, page size 200. steady-state poll (watermark past every switch) before: 53 ms, Merge Join over ~300,000 index rows, 10,827 buffers after: 0.07 ms token lookup + 0.02 ms resume probe, 10 buffers first page after the switch before: 51 ms, top-N heapsort over ~300,000 rows, 10,948 buffers after: 0.08 ms + 0.17 ms index scan on (ingest_token_id, id) resume in the middle of the big token before: 53 ms after: 0.02 ms + 0.20 ms, Index Cond on (ingest_token_id, id) A new integration file pages both forms side by side (tied stamps, a watermark inside a tie, soft-deleted rows, another account's token, page sizes from 1 to 100) and asserts identical pages, and that a poll with nothing switched past its watermark reads no reading at all. --- src/lib/sync/changes/read-rows.ts | 106 +++++++--- tests/integration/sync-mirror-walk.test.ts | 215 +++++++++++++++++++++ 2 files changed, 295 insertions(+), 26 deletions(-) create mode 100644 tests/integration/sync-mirror-walk.test.ts diff --git a/src/lib/sync/changes/read-rows.ts b/src/lib/sync/changes/read-rows.ts index b23595c79..544d36161 100644 --- a/src/lib/sync/changes/read-rows.ts +++ b/src/lib/sync/changes/read-rows.ts @@ -28,12 +28,6 @@ const MEASUREMENT_SELECT = { ingestToken: APPLE_HEALTH_MIRROR_SELECT.ingestToken, } satisfies Prisma.MeasurementSelect; -const MEASUREMENT_MIRROR_SELECT = { - id: true, - source: true, - ingestToken: { select: { mirrorToAppleHealth: true, mirrorChangedAt: true } }, -} satisfies Prisma.MeasurementSelect; - const MOOD_SELECT = { id: true, date: true, @@ -81,8 +75,15 @@ export type CycleDayRow = Prisma.CycleDayLogGetPayload<{ include: typeof dayLogSymptomInclude; }>; export type CycleRow = Prisma.MenstrualCycleGetPayload; +/** A reading the mirror walk re-delivers, with its token's current setting. */ export type MeasurementMirrorRow = Prisma.MeasurementGetPayload<{ - select: typeof MEASUREMENT_MIRROR_SELECT; + select: { + id: true; + source: true; + ingestToken: { + select: { mirrorToAppleHealth: true; mirrorChangedAt: true }; + }; + }; }>; /** @@ -109,41 +110,94 @@ export interface DomainRows { measurementMirror: MeasurementMirrorRow[]; } +const MEASUREMENT_MIRROR_ROW_SELECT = { + id: true, + source: true, +} satisfies Prisma.MeasurementSelect; + /** * The live readings of every token whose Apple Health setting changed past * the watermark, in `(token.mirrorChangedAt, measurement.id)` order. Switching * the setting writes the token row only, so the readings keep their * `updatedAt` (nothing keyed on it recomputes) and arrive through this walk * instead of the measurement one. + * + * The walk starts from the tokens, not the readings. Ordering readings by a + * column of the joined token row cannot use any index on `measurements`, so + * the single-query form sorted every row of every token that had ever been + * switched, on every poll, even when nothing had changed since the client's + * watermark. Reading the switched tokens first is a lookup on a handful of + * rows; nearly every poll finds none and ends there. Otherwise the readings + * are walked one change stamp at a time, by `(ingest_token_id, id)`, which is + * the order the index already holds. Tokens that share a stamp are walked + * together so their rows interleave by id exactly as the one-query form + * ordered them. + * + * The token lookup filters on the token's owner. That is exact because a + * reading only ever links to a token of its own account: the link is written + * for a narrow `measurements:write` Bearer, which never acts on another record + * (`requireRecordAuth` refuses the carrier), and a restore keeps the link only + * for a token the restoring account holds. */ -function readMeasurementMirrorRows( +async function readMeasurementMirrorRows( userId: string, wm: DomainWatermark | undefined, limit: number, ): Promise { const at = wm ? new Date(wm.updatedAtMs) : null; - return prisma.measurement.findMany({ + const tokens = await prisma.apiToken.findMany({ where: { userId, - deletedAt: null, - ingestToken: { mirrorChangedAt: { not: null } }, - ...(at && - wm && { - OR: [ - { ingestToken: { mirrorChangedAt: { gt: at } } }, - { - AND: [ - { ingestToken: { mirrorChangedAt: at } }, - { id: { gt: wm.id } }, - ], - }, - ], - }), + mirrorChangedAt: at ? { gte: at } : { not: null }, }, - orderBy: [{ ingestToken: { mirrorChangedAt: "asc" } }, { id: "asc" }], - take: limit + 1, - select: MEASUREMENT_MIRROR_SELECT, + orderBy: [{ mirrorChangedAt: "asc" }, { id: "asc" }], + select: { id: true, mirrorToAppleHealth: true, mirrorChangedAt: true }, }); + if (tokens.length === 0) return []; + + // One group per change stamp, in stamp order. + const groups: { stampMs: number; tokens: typeof tokens }[] = []; + for (const token of tokens) { + const stampMs = token.mirrorChangedAt!.getTime(); + const last = groups[groups.length - 1]; + if (last && last.stampMs === stampMs) last.tokens.push(token); + else groups.push({ stampMs, tokens: [token] }); + } + + const out: MeasurementMirrorRow[] = []; + for (const group of groups) { + const want = limit + 1 - out.length; + if (want <= 0) break; + // The watermark's own stamp resumes after the last row delivered; every + // later stamp starts from its first row. + const resume = wm && group.stampMs === wm.updatedAtMs ? wm.id : null; + const byId = new Map(group.tokens.map((t) => [t.id, t])); + const rows = await prisma.measurement.findMany({ + where: { + ingestTokenId: + group.tokens.length === 1 + ? group.tokens[0].id + : { in: group.tokens.map((t) => t.id) }, + userId, + deletedAt: null, + ...(resume !== null && { id: { gt: resume } }), + }, + orderBy: { id: "asc" }, + take: want, + select: { ...MEASUREMENT_MIRROR_ROW_SELECT, ingestTokenId: true }, + }); + for (const { ingestTokenId, ...row } of rows) { + const token = byId.get(ingestTokenId!)!; + out.push({ + ...row, + ingestToken: { + mirrorToAppleHealth: token.mirrorToAppleHealth, + mirrorChangedAt: token.mirrorChangedAt, + }, + }); + } + } + return out; } /** diff --git a/tests/integration/sync-mirror-walk.test.ts b/tests/integration/sync-mirror-walk.test.ts new file mode 100644 index 000000000..51a01ce3e --- /dev/null +++ b/tests/integration/sync-mirror-walk.test.ts @@ -0,0 +1,215 @@ +/** + * The `measurementMirror` walk of `/api/sync/changes`, against Postgres. + * + * The walk reads the switched tokens first and then each change stamp's + * readings by `(ingest_token_id, id)`. The page sequence a client sees must be + * exactly the one the earlier single query produced, which ordered every + * token-linked reading by `(token.mirrorChangedAt, measurement.id)` and + * resumed past `(stamp, id)`. That query is kept below as the reference, and + * every page of every walk is compared against it: tied stamps across tokens, + * a watermark inside a tie, soft-deleted rows, another account's token, a + * token never switched, readings with no token, and page sizes from one row + * up to the whole set. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { readDomainRows } from "@/lib/sync/changes/read-rows"; +import type { SyncCursor } from "@/lib/sync/cursor"; + +import { makeMeasurement, makeToken, makeUser } from "../kit/factories"; +import { countStatements } from "./query-counter"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); + +interface Wm { + updatedAtMs: number; + id: string; +} + +/** The single-query walk this replaced, verbatim in its semantics. */ +async function referenceWalk( + userId: string, + wm: Wm | undefined, + limit: number, +) { + const at = wm ? new Date(wm.updatedAtMs) : null; + return prisma.measurement.findMany({ + where: { + userId, + deletedAt: null, + ingestToken: { mirrorChangedAt: { not: null } }, + ...(at && + wm && { + OR: [ + { ingestToken: { mirrorChangedAt: { gt: at } } }, + { + AND: [ + { ingestToken: { mirrorChangedAt: at } }, + { id: { gt: wm.id } }, + ], + }, + ], + }), + }, + orderBy: [{ ingestToken: { mirrorChangedAt: "asc" } }, { id: "asc" }], + take: limit + 1, + select: { + id: true, + source: true, + ingestToken: { + select: { mirrorToAppleHealth: true, mirrorChangedAt: true }, + }, + }, + }); +} + +async function walk(userId: string, wm: Wm | undefined, limit: number) { + const cursor = { v: 2, measurementMirror: wm } as unknown as SyncCursor; + const rows = await readDomainRows(userId, cursor, limit, false); + return rows.measurementMirror; +} + +/** Page through both walks from `start`, asserting each page is identical. */ +async function pageThrough(userId: string, limit: number, start?: Wm) { + let wm = start; + const seen: string[] = []; + for (let guard = 0; guard < 200; guard += 1) { + const [actual, expected] = await Promise.all([ + walk(userId, wm, limit), + referenceWalk(userId, wm, limit), + ]); + expect(actual, `limit ${limit}, page ${guard}`).toEqual(expected); + const page = actual.slice(0, limit); + seen.push(...page.map((r) => r.id)); + if (actual.length <= limit) return { seen, wm }; + const last = page[page.length - 1]; + wm = { + updatedAtMs: last.ingestToken!.mirrorChangedAt!.getTime(), + id: last.id, + }; + } + throw new Error("walk did not terminate"); +} + +let userId: string; +let otherId: string; +const T0 = new Date("2026-10-01T08:00:00.000Z"); +const T1 = new Date("2026-10-02T08:00:00.000Z"); +const T2 = new Date("2026-10-03T08:00:00.000Z"); + +async function rowsFor( + owner: { id: string }, + tokenId: string | null, + n: number, + base: number, + deleted: number[] = [], +) { + for (let i = 0; i < n; i += 1) { + await makeMeasurement(owner, { + type: "WEIGHT", + unit: "kg", + value: 80 + i / 10, + source: "EXTERNAL", + measuredAt: new Date(Date.UTC(2026, 8, 1, 0, base + i)), + ingestTokenId: tokenId, + deletedAt: deleted.includes(i) ? new Date() : null, + }); + } +} + +async function stamp(tokenId: string, at: Date, on = true) { + await prisma.apiToken.update({ + where: { id: tokenId }, + data: { mirrorChangedAt: at, mirrorToAppleHealth: on }, + }); +} + +beforeEach(async () => { + await truncateAllTables(prisma); + const user = await makeUser({ timezone: "UTC" }); + const other = await makeUser({ timezone: "UTC" }); + userId = user.id; + otherId = other.id; + const [a, b, c, d, never, othersOwn] = await Promise.all([ + makeToken(user, { name: "A" }), + makeToken(user, { name: "B" }), + makeToken(user, { name: "C" }), + makeToken(user, { name: "D" }), + makeToken(user, { name: "never" }), + makeToken(other, { name: "others-own" }), + ]); + await rowsFor(user, a.tokenId, 7, 0, [3]); + await rowsFor(user, b.tokenId, 5, 100, [0]); + await rowsFor(user, c.tokenId, 3, 200); + await rowsFor(user, never.tokenId, 3, 300); + await rowsFor(user, d.tokenId, 2, 400); + await rowsFor(other, othersOwn.tokenId, 3, 600); + await rowsFor(user, null, 4, 700); + // A and B share a stamp, so their rows interleave by id. + await stamp(a.tokenId, T0); + await stamp(b.tokenId, T0, false); + await stamp(c.tokenId, T1); + await stamp(d.tokenId, T2); + await stamp(othersOwn.tokenId, T1); +}); + +describe("the measurementMirror walk", () => { + it.each([1, 2, 3, 5, 8, 100])( + "pages exactly as the single query did, %i rows a page", + async (limit) => { + const { seen } = await pageThrough(userId, limit); + // 6 + 4 live rows on the tied stamp, 3 on C, 2 on D. Nothing from the + // never-switched token, nothing of the other account's, nothing + // deleted, nothing without a token. + expect(seen).toHaveLength(15); + expect(new Set(seen).size).toBe(15); + }, + ); + + it("resumes inside a tied stamp, and re-delivers a token switched again", async () => { + const all = await walk(userId, undefined, 100); + const tied = all.filter( + (r) => r.ingestToken!.mirrorChangedAt!.getTime() === T0.getTime(), + ); + // Both tied tokens' rows interleave by id, so a watermark three rows into + // the tie leaves rows of both still to come. + await pageThrough(userId, 2, { updatedAtMs: T0.getTime(), id: tied[2].id }); + + const last = all[all.length - 1]; + const caughtUp: Wm = { + updatedAtMs: last.ingestToken!.mirrorChangedAt!.getTime(), + id: last.id, + }; + expect(await walk(userId, caughtUp, 100)).toEqual([]); + + const c = await prisma.apiToken.findFirstOrThrow({ + where: { userId, name: "C" }, + }); + await stamp(c.id, new Date("2026-10-04T08:00:00.000Z"), false); + const { seen } = await pageThrough(userId, 2, caughtUp); + expect(seen).toHaveLength(3); + }); + + it("answers a poll with nothing switched past its watermark from the token table alone", async () => { + const wm = { updatedAtMs: T2.getTime() + 1, id: "" }; + const measured = await countStatements(() => walk(userId, wm, 100)); + expect(measured.result).toEqual([]); + // The walk's reading-side shape: id, source and the token link. + const readingWalks = measured.statements.filter((s) => + s.startsWith( + 'SELECT "public"."measurements"."id", "public"."measurements"."source"::text, "public"."measurements"."ingest_token_id"', + ), + ); + expect(readingWalks).toEqual([]); + const tokenLookups = measured.statements.filter((s) => + s.includes('"public"."api_tokens"."mirror_changed_at" FROM'), + ); + expect(tokenLookups).toHaveLength(1); + }); + + it("leaves the other account's walk to the other account", async () => { + const { seen } = await pageThrough(otherId, 2); + expect(seen).toHaveLength(3); + }); +}); From c6bd27f06feeeb955e76f7b887eeffb050157da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 00:35:36 +0200 Subject: [PATCH 3/8] fix(jobs): load every job handler once at worker boot and report failures Since handlers load on their queue's first job, a module the image cannot load no longer fails the worker's boot. The worker comes up healthy, the queue burns its retries when it first has work, and a one-shot job (a restore, an import tail) ends failed with nobody told at deploy time. After binding every queue the worker now runs `preloadHandlers` in the background, without awaiting it, so readiness is unchanged. It loads each handler once through the same memoised loader its queue is bound to, so a module that loads stays loaded for the first job. Each failure is reported on the `job.run.failed` line at error, with the queue's name and `reason: "handler_load_failed"`, plus the worker error report (stderr and GlitchTip). A summary line on stderr names every queue that failed. The sweep never throws, and a failing report does not stop it. docs/ops/deploy.md says what to look for after a deploy. --- docs/ops/deploy.md | 15 ++ .../__tests__/preload-handlers.test.ts | 168 ++++++++++++++++++ src/lib/jobs/registry/index.ts | 14 +- src/lib/jobs/registry/register.ts | 96 ++++++++-- src/lib/jobs/reminder-worker.ts | 13 +- src/lib/jobs/run-job.ts | 14 ++ 6 files changed, 305 insertions(+), 15 deletions(-) create mode 100644 src/lib/jobs/registry/__tests__/preload-handlers.test.ts diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 046be2905..ff425a19a 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -237,6 +237,21 @@ Pass `--only=prod` / `--only=demo` to check a single target, or `--attempts=N --interval=ms` to widen the retry window while a slow pull settles. +The worker loads each job's handler module on that job's first run, so a +module the image cannot load does not stop the worker from starting. Once +it has bound its queues, the worker loads every handler a single time in +the background and reports each one that fails, normally within a minute +of start. Each failure is a `job.run.failed` event at `error` with +`reason: "handler_load_failed"` and the queue's name, and stderr also gets +`[pg-boss] [registry] preloaded N/M job handlers; failed to load: `. +If either shows up after a deploy, the image is broken for those queues, +and their jobs will fail when they run. Roll back (see Rollback above) +instead of waiting for the retries to run out: + +```sh +docker compose logs --since 5m app 2>&1 | grep -E 'handler_load_failed|failed to load:' +``` + ## Walk the synthetic journey The version check proves the instance serves the right build. It does not diff --git a/src/lib/jobs/registry/__tests__/preload-handlers.test.ts b/src/lib/jobs/registry/__tests__/preload-handlers.test.ts new file mode 100644 index 000000000..6f73883e9 --- /dev/null +++ b/src/lib/jobs/registry/__tests__/preload-handlers.test.ts @@ -0,0 +1,168 @@ +/** + * The worker's boot preload of every job handler module. + * + * Handlers load on their queue's first job, so a module that cannot load + * stopped failing the boot and started failing jobs instead: the queue burned + * its retries, and a one-shot job (a restore, an import tail) ended failed + * with nobody told at deploy time. The worker now loads every handler once in + * the background after binding and reports each failure by queue name. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Job, PgBoss } from "pg-boss"; + +const bound = vi.hoisted( + () => new Map Promise>(), +); + +vi.mock("@/lib/jobs/reminder/registrar-shared", () => ({ + createAndSchedule: vi.fn(async () => undefined), + retireQueues: vi.fn(async () => undefined), + createAndWork: vi.fn( + async ( + _boss: unknown, + queue: string, + _opts: unknown, + handler: (jobs: unknown[]) => Promise, + ) => { + bound.set(queue, handler); + }, + ), +})); + +vi.mock("@/lib/jobs/reminder/shared", () => ({ workerLog: vi.fn() })); + +vi.mock("@/lib/logging/signal", () => ({ emitSignal: vi.fn() })); +vi.mock("@/lib/jobs/report-worker-error", () => ({ + reportWorkerError: vi.fn(async () => undefined), +})); +vi.mock("@/lib/jobs/job-failures", () => ({ + readConsecutiveRunFailures: vi.fn(async () => 0), +})); + +import { workerLog } from "@/lib/jobs/reminder/shared"; +import { emitSignal } from "@/lib/logging/signal"; +import { reportWorkerError } from "@/lib/jobs/report-worker-error"; +import { reportHandlerLoadFailure } from "@/lib/jobs/run-job"; + +import type { JobDefinition, JobGroup } from "../define-job"; +import { + preloadHandlers, + registerJobGroup, + type BoundLoader, +} from "../register"; + +const boss = {} as PgBoss; + +function definition( + queue: string, + load: () => Promise, +): JobDefinition { + return { queue, work: { batchSize: 1 }, load } as unknown as JobDefinition; +} + +beforeEach(() => { + bound.clear(); + vi.clearAllMocks(); +}); + +describe("preloading the job handlers at worker boot", () => { + it("reports a module that fails to load, by queue, and loads the rest", async () => { + const ok = vi.fn(async () => async () => ({ ok: true as const })); + const broken = vi.fn(async () => { + throw new Error("Cannot find module './gone'"); + }); + const group = { + name: "maintenance", + jobs: [ + definition("first-ok", ok), + definition("broken-module", broken), + definition("second-ok", ok), + ], + } as unknown as JobGroup; + + const loaders: BoundLoader[] = []; + await registerJobGroup(boss, group, (b) => loaders.push(b)); + expect(loaders.map((b) => b.queue)).toEqual([ + "first-ok", + "broken-module", + "second-ok", + ]); + + const report = vi.fn(async () => undefined); + const failed = await preloadHandlers(loaders, report); + + expect(failed).toEqual(["broken-module"]); + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith( + "broken-module", + expect.objectContaining({ message: "Cannot find module './gone'" }), + ); + expect(ok).toHaveBeenCalledTimes(2); + expect(workerLog).toHaveBeenCalledWith( + "error", + expect.stringContaining("failed to load: broken-module"), + ); + }); + + it("preloads through the loader the queue is bound to, so the first job does not import again", async () => { + const handler = vi.fn(async () => ({ ok: true as const })); + const load = vi.fn(async () => handler); + const group = { + name: "maintenance", + jobs: [definition("warm", load)], + } as unknown as JobGroup; + + const loaders: BoundLoader[] = []; + await registerJobGroup(boss, group, (b) => loaders.push(b)); + await preloadHandlers(loaders, vi.fn()); + await bound.get("warm")!([{ id: "j1", data: {} } as Job]); + + expect(load).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("does not stop the sweep when a report itself fails", async () => { + const loaders: BoundLoader[] = [ + { queue: "a", load: () => Promise.reject(new Error("a")) }, + { queue: "b", load: () => Promise.reject(new Error("b")) }, + ]; + const report = vi.fn(async () => { + throw new Error("sink down"); + }); + await expect(preloadHandlers(loaders, report)).resolves.toEqual(["a", "b"]); + expect(report).toHaveBeenCalledTimes(2); + }); + + it("reports a load failure on the job.run.failed line, at error, naming the queue", async () => { + const error = new Error("SyntaxError in handler"); + await reportHandlerLoadFailure("broken-module", error); + + expect(emitSignal).toHaveBeenCalledWith({ + action: "job.run.failed", + level: "error", + meta: { + queue: "broken-module", + reason: "handler_load_failed", + consecutiveFailures: 0, + }, + error, + }); + expect(reportWorkerError).toHaveBeenCalledWith("broken-module", error, { + phase: "handler_preload", + }); + }); + + it("is started by the worker after binding, without holding up the boot", () => { + const source = readFileSync( + join(process.cwd(), "src/lib/jobs/reminder-worker.ts"), + "utf8", + ); + const register = source.indexOf("registry.registerAllJobGroups("); + const preload = source.indexOf("void registry.preloadHandlers(bound)"); + expect(register).toBeGreaterThan(-1); + expect(preload).toBeGreaterThan(register); + }); +}); diff --git a/src/lib/jobs/registry/index.ts b/src/lib/jobs/registry/index.ts index a6d02a764..6ab42fe0c 100644 --- a/src/lib/jobs/registry/index.ts +++ b/src/lib/jobs/registry/index.ts @@ -10,7 +10,13 @@ import type { JobDefinition, JobGroup } from "./define-job"; import { INTEGRATION_SYNC_GROUP } from "./integration-sync"; import { MAINTENANCE_GROUP } from "./maintenance"; import { REMINDERS_GROUP } from "./reminders"; -import { registerJobGroup, runBootDiscovery } from "./register"; +import { + registerJobGroup, + runBootDiscovery, + type BoundLoader, +} from "./register"; + +export { preloadHandlers, type BoundLoader } from "./register"; import { ROLLUP_GROUP } from "./rollup"; import { STATUS_GROUP } from "./status"; @@ -29,16 +35,18 @@ export function allJobDefinitions(): readonly JobDefinition[] { /** * Register every group in order. `onGroup` runs before each one, so the boot - * can time the groups as steps. Returns every queue name created. + * can time the groups as steps; `onBound` receives every queue's handler + * loader as it is bound. Returns every queue name created. */ export async function registerAllJobGroups( boss: PgBoss, onGroup: (group: JobGroup) => void = () => {}, + onBound: (bound: BoundLoader) => void = () => {}, ): Promise { const queues: string[] = []; for (const group of JOB_GROUPS) { onGroup(group); - queues.push(...(await registerJobGroup(boss, group))); + queues.push(...(await registerJobGroup(boss, group, onBound))); } return queues; } diff --git a/src/lib/jobs/registry/register.ts b/src/lib/jobs/registry/register.ts index 3a2a36bef..c11052fdb 100644 --- a/src/lib/jobs/registry/register.ts +++ b/src/lib/jobs/registry/register.ts @@ -13,6 +13,14 @@ * queue's protections do not depend on whether its module has loaded yet. A * module that fails to load fails the job that asked for it, with the queue's * name on the error, and the next job tries the import again. + * + * Loading lazily moved a broken module from a failed boot to a failed job: the + * worker comes up healthy, and the queue burns its retries the first time it + * has work, which for a one-shot job (a restore, an import tail) means the + * job ends failed with nobody told at deploy time. `preloadHandlers` closes + * that gap. The worker runs it once after binding, in the background, through + * the same memoised loader the queue uses, and reports every module that does + * not load before any job needs it. */ import type { Job, PgBoss } from "pg-boss"; @@ -26,6 +34,7 @@ import { type ScheduleEntry, } from "@/lib/jobs/reminder/registrar-shared"; import { workerLog } from "@/lib/jobs/reminder/shared"; +import { reportHandlerLoadFailure } from "@/lib/jobs/run-job"; import type { BatchHandler, @@ -35,6 +44,34 @@ import type { JobGroup, } from "./define-job"; +/** A queue's handler loader, memoised: one import, retried after a failure. */ +export type HandlerLoader = () => Promise>>; + +/** One bound queue and the loader its handler goes through. */ +export interface BoundLoader { + readonly queue: string; + readonly load: HandlerLoader; +} + +/** + * The loader a definition's binding shares: the first call imports, every + * later call answers from the same promise, and a failed import is forgotten + * so the next call tries again. + */ +export function handlerLoader( + definition: JobDefinition, + ctx: JobContext, +): HandlerLoader { + let loading: Promise>> | null = null; + return () => { + loading ??= definition.load(ctx).catch((err: unknown) => { + loading = null; + throw err; + }); + return loading; + }; +} + /** * The handler a definition binds: its loaded module behind the lock and the * lane it declares. @@ -42,15 +79,8 @@ import type { export function bindableHandler( definition: JobDefinition, ctx: JobContext, + load: HandlerLoader = handlerLoader(definition, ctx), ): BatchHandler> { - let loading: Promise>> | null = null; - const load = (): Promise>> => { - loading ??= definition.load(ctx).catch((err: unknown) => { - loading = null; - throw err; - }); - return loading; - }; let handler: BatchHandler> = async (jobs) => (await load())(jobs); if (definition.lane) { @@ -88,11 +118,13 @@ export function policiesOf(jobs: readonly JobDefinition[]): QueuePolicyTable { /** * Register one group: create, schedule, retire, bind. Returns the queue names - * it created. + * it created. `onBound` is handed each queue's loader as it is bound, so the + * boot can preload through the very loader the queue will use. */ export async function registerJobGroup( boss: PgBoss, group: JobGroup, + onBound: (bound: BoundLoader) => void = () => {}, ): Promise { const queues = group.jobs.map((job) => job.queue); await createAndSchedule( @@ -104,7 +136,14 @@ export async function registerJobGroup( if (group.retired) await retireQueues(boss, group.retired); const ctx: JobContext = { boss }; for (const job of group.jobs) { - await createAndWork(boss, job.queue, job.work, bindableHandler(job, ctx)); + const load = handlerLoader(job, ctx); + await createAndWork( + boss, + job.queue, + job.work, + bindableHandler(job, ctx, load), + ); + onBound({ queue: job.queue, load }); } if (group.afterBind) await group.afterBind(ctx); return queues; @@ -144,3 +183,40 @@ export async function runBootDiscovery( } } } + +/** + * Load every bound handler once, in declaration order, and report each one + * that fails: `job.run.failed` at error with the queue's name and the reason + * `handler_load_failed`, the line the repeated-failure alert already keys on, + * plus the worker error report (stderr and GlitchTip). Never throws, and the + * caller does not wait for it, so a broken module costs the boot nothing but + * the report. A module that loads stays loaded, and the queue's first job + * finds it in the loader's memo. Returns the queues that failed. + */ +export async function preloadHandlers( + bound: readonly BoundLoader[], + report: ( + queue: string, + error: unknown, + ) => Promise = reportHandlerLoadFailure, +): Promise { + const failed: string[] = []; + for (const { queue, load } of bound) { + try { + await load(); + } catch (error) { + failed.push(queue); + try { + await report(queue, error); + } catch { + // The report is the last line of defence; it must not stop the sweep. + } + } + } + workerLog( + failed.length === 0 ? "info" : "error", + `[registry] preloaded ${bound.length - failed.length}/${bound.length} job handlers` + + (failed.length === 0 ? "" : `; failed to load: ${failed.join(", ")}`), + ); + return failed; +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index c882ca002..715ce80b2 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -17,6 +17,7 @@ import { reportJobsCutOffAtBoot } from "@/lib/jobs/job-observer"; import { beginBootStep, reportBootSteps } from "@/lib/boot/boot-steps"; import { assertSubsystemEnabled } from "@/lib/process-type"; import { DATABASE_URL, workerLog } from "./reminder/shared"; +import type { BoundLoader } from "./registry/index"; /** * Give the event loop a turn around a module load, so requests and the @@ -76,8 +77,11 @@ export async function startReminderWorker() { "registry", () => import("./registry/index"), ); - const registeredQueues = await registry.registerAllJobGroups(boss, (group) => - beginBootStep(`register_${group.name}`), + const bound: BoundLoader[] = []; + const registeredQueues = await registry.registerAllJobGroups( + boss, + (group) => beginBootStep(`register_${group.name}`), + (loader) => bound.push(loader), ); workerLog( "info", @@ -100,6 +104,11 @@ export async function startReminderWorker() { await registry.runAllBootDiscovery(); await reportBootSteps({ boot_queue_count: registeredQueues.length }); + + // Every handler module is loaded once now, in the background, so one that + // cannot load is reported at deploy time rather than on its queue's first + // job. Not awaited: readiness does not wait on it, and it never throws. + void registry.preloadHandlers(bound); return boss; } diff --git a/src/lib/jobs/run-job.ts b/src/lib/jobs/run-job.ts index f4df20089..6a7b74716 100644 --- a/src/lib/jobs/run-job.ts +++ b/src/lib/jobs/run-job.ts @@ -90,6 +90,20 @@ async function signalRunFailed( }); } +/** + * A queue's handler module that failed to load before any job asked for it + * (the worker's boot preload, `preloadHandlers`). Reported the way a failed + * run is, so the operator hears about it at deploy time on the line the + * repeated-failure alert already reads, instead of on the queue's first job. + */ +export async function reportHandlerLoadFailure( + queue: string, + error: unknown, +): Promise { + await reportWorkerError(queue, error, { phase: "handler_preload" }); + await signalRunFailed(queue, "handler_load_failed", error); +} + /** * Wrap a handler for `boss.work`. Resolves with the bounded, redacted * persistence representation on `ok: true`; reports and rethrows on From f3b9329fb212e053007207e037fb6f8163edaad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 00:35:40 +0200 Subject: [PATCH 4/8] fix(sync): mark the read caches stale after a provider backfill `afterProviderBackfill` refolded the rollups over the touched range and re-warmed the status cards, but never marked the analytics caches stale, so after a Fitbit or Google Health history backfill the dashboard and analytics reads served the pre-backfill body until its TTL ran out. The per-write tail has always done this through `afterMeasurementMutation`. The backfill tail now calls `invalidateUserMeasurements` with `touchedFrom` set to the first touched day, after the fold and also when the fold fails, since the rows changed either way. --- .../sync-kit/__tests__/after-backfill.test.ts | 88 +++++++++++++++++++ src/lib/integrations/sync-kit/after-write.ts | 21 ++++- 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 src/lib/integrations/sync-kit/__tests__/after-backfill.test.ts diff --git a/src/lib/integrations/sync-kit/__tests__/after-backfill.test.ts b/src/lib/integrations/sync-kit/__tests__/after-backfill.test.ts new file mode 100644 index 000000000..b5e09f480 --- /dev/null +++ b/src/lib/integrations/sync-kit/__tests__/after-backfill.test.ts @@ -0,0 +1,88 @@ +/** + * The end of a provider's full-history backfill marks the read caches stale. + * + * The per-write tail always did (through `afterMeasurementMutation`), but the + * backfill tail refolded the rollups and re-warmed the status cards without + * touching the analytics caches, so the dashboard and the analytics reads went + * on serving the pre-backfill body until its TTL ran out. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + collapseToTypeDayKeys: (keys: Array<{ type: string; measuredAt: Date }>) => + keys, + recomputeUserRollups: vi.fn(async () => undefined), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), + invalidateUserDashboardSnapshot: vi.fn(), +})); +vi.mock("@/lib/insights/status-invalidation", () => ({ + invalidateStatusInsightsForTypes: vi.fn(async () => undefined), +})); +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn(), +})); +vi.mock("@/lib/arrivals/measurement-emit", () => ({ + emitInsertedMeasurementArrivals: vi.fn(async () => undefined), +})); + +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation"; +import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; + +import { afterProviderBackfill } from "../after-write"; + +const keys = [ + { type: "WEIGHT" as const, measuredAt: new Date("2026-03-05T00:00:00Z") }, + { type: "PULSE" as const, measuredAt: new Date("2026-01-10T00:00:00Z") }, + { type: "WEIGHT" as const, measuredAt: new Date("2026-02-01T00:00:00Z") }, +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("afterProviderBackfill", () => { + it("marks the user's read caches stale back to the first touched day", async () => { + await afterProviderBackfill({ userId: "u1", provider: "fitbit", keys }); + + expect(recomputeUserRollups).toHaveBeenCalledWith("u1", { + types: ["WEIGHT", "PULSE"], + from: new Date("2026-01-10T00:00:00Z"), + to: new Date("2026-03-06T00:00:00Z"), + }); + expect(invalidateUserMeasurements).toHaveBeenCalledTimes(1); + expect(invalidateUserMeasurements).toHaveBeenCalledWith("u1", { + touchedFrom: new Date("2026-01-10T00:00:00Z"), + }); + // After the fold, so a reader rebuilds against the new rollups. + expect( + vi.mocked(invalidateUserMeasurements).mock.invocationCallOrder[0], + ).toBeGreaterThan( + vi.mocked(recomputeUserRollups).mock.invocationCallOrder[0]!, + ); + expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith( + "u1", + ["WEIGHT", "PULSE"], + { source: "sync" }, + ); + }); + + it("marks them stale even when the fold fails, and still throws for the caller", async () => { + vi.mocked(recomputeUserRollups).mockRejectedValueOnce(new Error("fold")); + + await expect( + afterProviderBackfill({ userId: "u1", provider: "fitbit", keys }), + ).rejects.toThrow("fold"); + expect(invalidateUserMeasurements).toHaveBeenCalledWith("u1", { + touchedFrom: new Date("2026-01-10T00:00:00Z"), + }); + }); + + it("does nothing for a backfill that touched nothing", async () => { + await afterProviderBackfill({ userId: "u1", provider: "fitbit", keys: [] }); + expect(recomputeUserRollups).not.toHaveBeenCalled(); + expect(invalidateUserMeasurements).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/integrations/sync-kit/after-write.ts b/src/lib/integrations/sync-kit/after-write.ts index 99c0b542c..61c0c394b 100644 --- a/src/lib/integrations/sync-kit/after-write.ts +++ b/src/lib/integrations/sync-kit/after-write.ts @@ -29,7 +29,10 @@ import { emitInsertedMeasurementArrivals, type InsertedMeasurementArrivalRow, } from "@/lib/arrivals/measurement-emit"; -import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; +import { + invalidateUserDashboardSnapshot, + invalidateUserMeasurements, +} from "@/lib/cache/invalidate"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation"; import { getEvent } from "@/lib/logging/context"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; @@ -116,8 +119,14 @@ export function invalidateProviderStatus( /** * The end of a full-history backfill: refold the whole touched range once - * (from the first touched day to the end of the last), then re-warm the - * touched status cards. Throws when the fold fails, for the caller to count. + * (from the first touched day to the end of the last), mark the read caches + * stale back to the first touched day, then re-warm the touched status cards. + * Throws when the fold fails, for the caller to count. + * + * The caches are marked even when the fold fails: the rows changed either + * way, and a cached analytics body built before the backfill would otherwise + * be served until its TTL ran out. That is the same order and the same + * posture as the per-write tail (`afterMeasurementMutation`). */ export async function afterProviderBackfill(input: { userId: string; @@ -135,6 +144,10 @@ export async function afterProviderBackfill(input: { // The keys are UTC day starts; the end is past the last touched day so the // aggregator's `< to` bound covers it. const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000); - await recomputeUserRollups(input.userId, { types, from, to }); + try { + await recomputeUserRollups(input.userId, { types, from, to }); + } finally { + invalidateUserMeasurements(input.userId, { touchedFrom: from }); + } invalidateProviderStatus(input.userId, input.provider, types); } From 2ae23f4ca806ba1eae5b2ede80586cdd674cbdf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 00:44:15 +0200 Subject: [PATCH 5/8] refactor(settings): remove the preview-feature opt-ins The per-account preview switch was kept for the topic overview pages, which were dropped before they shipped. Its one flag was stored, served and backed up, and read by nothing; the column-reader audit listed it as a pending consumer that will not come. Removed completely: - `/api/settings/preview-features` (GET, PATCH) is retired: it answers 410 Gone with `meta.errorCode: route.retired` and `removedIn: 1.46.0`, and the contract publishes it under Retired. The route, its validation schema, the `PreviewFeatures` OpenAPI schemas and the settings module are deleted. - Migration 0485 drops `users.preview_features_json` under a bounded lock wait, with the retry marker. - The backup no longer writes the field. A file from v1.44 or v1.45 that carries it still restores: the schema accepts it and no codec reads it, so nothing is refused or written for it. The backup-parity goldens lose the field and apply one setting fewer. - The data-wipe plan entry and the sharing-surface actor entry go with the route (frozen count 273 -> 272). No client build reads the route: neither iOS repository nor any local checkout references it. --- docs/api/openapi.yaml | 144 +++++++----------- .../migration.sql | 27 ++++ prisma/schema.prisma | 5 - src/__tests__/sharing-surface-guard.test.ts | 7 +- .../api/settings/preview-features/route.ts | 80 ---------- src/lib/data-wipe/wipe-plan.ts | 2 - .../__tests__/account-settings-backup.test.ts | 15 ++ .../export/account-settings-backup-schema.ts | 3 + src/lib/export/account-settings-backup.ts | 1 - src/lib/http/retired-routes.ts | 8 + src/lib/openapi/routes/settings.ts | 70 +-------- .../__tests__/preview-features.test.ts | 26 ---- src/lib/settings/preview-features.ts | 33 ---- src/lib/settings/section-slugs.ts | 4 - src/lib/validations/settings-prefs.ts | 13 +- tests/durations/unit.json | 1 - .../export-disaster-recovery.json | 6 - .../backup-parity/export-portable.json | 6 - .../restore-disaster-recovery-over-live.json | 2 +- .../restore-disaster-recovery.json | 8 +- .../backup-parity/restore-portable.json | 8 +- .../backup-parity/restore-skips.json | 1 - .../backup-parity/restore-version-1.json | 2 - .../backup-parity/seeded-account.json | 3 - .../integration/settings-prefs-routes.test.ts | 71 +-------- tests/integration/settings-round-trip.test.ts | 16 -- 26 files changed, 125 insertions(+), 437 deletions(-) create mode 100644 prisma/migrations/0485_drop_user_preview_features/migration.sql delete mode 100644 src/app/api/settings/preview-features/route.ts delete mode 100644 src/lib/settings/__tests__/preview-features.test.ts delete mode 100644 src/lib/settings/preview-features.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d9ce7210b..7c255e133 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -15428,45 +15428,6 @@ paths: "403": *a9 "422": *a4 "429": *a5 - /api/settings/preview-features: - get: - tags: - - Meta - summary: Get the preview-feature opt-ins - description: Every flag, off unless the caller switched it on. - responses: - "200": - description: The opt-ins. - content: - application/json: - schema: - $ref: "#/components/schemas/PreviewFeaturesEnvelope" - "401": *a1 - "403": *a9 - "422": *a4 - "429": *a5 - patch: - tags: - - Meta - summary: Switch preview features on or off - description: Sets the flags the body names and keeps the rest. An unknown flag or an empty body is refused with 422. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/PreviewFeaturesPatchBody" - responses: - "200": - description: The opt-ins as stored. - content: - application/json: - schema: - $ref: "#/components/schemas/PreviewFeaturesSavedEnvelope" - "401": *a1 - "403": *a9 - "422": *a4 - "429": *a5 /api/settings/privacy-summary: get: tags: @@ -27212,6 +27173,61 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + /api/settings/preview-features: + get: + security: [] + deprecated: true + tags: + - Retired + summary: GET /api/settings/preview-features — removed in v1.46.0 + description: >- + Removed in v1.46.0. Every request to this path answers 410 Gone, whatever the method and whatever credential it + carries. There is no replacement; the capability is gone rather than moved. + + + Stored per-account opt-ins to preview features, of which there was one, the topic overview pages. Those pages + were dropped before they shipped, so the switch governed nothing; the route and its column are gone, and no + preview is gated per account. + + + Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken + deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and + do not offer a retry. + responses: + "410": + description: The path was removed in v1.46.0 and will not answer again. `meta.errorCode` = `route.retired`, + `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + patch: + security: [] + deprecated: true + tags: + - Retired + summary: PATCH /api/settings/preview-features — removed in v1.46.0 + description: >- + Removed in v1.46.0. Every request to this path answers 410 Gone, whatever the method and whatever credential it + carries. There is no replacement; the capability is gone rather than moved. + + + Stored per-account opt-ins to preview features, of which there was one, the topic overview pages. Those pages + were dropped before they shipped, so the switch governed nothing; the route and its column are gone, and no + preview is gated per account. + + + Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken + deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and + do not offer a retry. + responses: + "410": + description: The path was removed in v1.46.0 and will not answer again. `meta.errorCode` = `route.retired`, + `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" components: schemas: CreateCycleCustomSymptomRequest: @@ -33264,12 +33280,6 @@ components: - version - list additionalProperties: false - PreviewFeaturesPatchBody: - type: object - properties: - topics: - type: boolean - additionalProperties: false ConsentPostBody: type: object properties: @@ -73560,50 +73570,6 @@ components: - data - error additionalProperties: false - PreviewFeaturesEnvelope: - type: object - properties: - data: - $ref: "#/components/schemas/PreviewFeatures" - error: - type: "null" - meta: - type: object - properties: - requestId: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false - PreviewFeatures: - type: object - properties: - topics: - type: boolean - description: Topic overview pages above the single-metric Insights pages. Off by default. - required: - - topics - additionalProperties: false - description: Per-user opt-in to preview features, one boolean per flag, every flag off by default. - PreviewFeaturesSavedEnvelope: - type: object - properties: - data: - $ref: "#/components/schemas/PreviewFeatures" - error: - type: "null" - meta: - type: object - properties: - requestId: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false PrivacySummaryEnvelope: type: object properties: diff --git a/prisma/migrations/0485_drop_user_preview_features/migration.sql b/prisma/migrations/0485_drop_user_preview_features/migration.sql new file mode 100644 index 000000000..93c04cbf8 --- /dev/null +++ b/prisma/migrations/0485_drop_user_preview_features/migration.sql @@ -0,0 +1,27 @@ +-- Drop the per-user preview-feature opt-ins. +-- +-- `users.preview_features_json` (0440) stored one flag, `topics`, for the +-- topic overview pages. Those pages were dropped before they shipped, so no +-- surface ever read the flag; `/api/settings/preview-features` is retired +-- with this release and answers 410. +-- +-- What is lost is a switch that switched nothing. A backup written by a +-- release that still had the column carries the field, and the restore +-- accepts and ignores it. +-- +-- Dropping a column takes an ACCESS EXCLUSIVE lock on `users` for a catalogue +-- update only (no rewrite). The lock wait is bounded so a long-running reader +-- cannot queue every sign-in behind this statement; the deploy retries a +-- timed-out attempt. +-- +-- Reversibility: re-adding the nullable column restores the shape; every +-- account then reads as all flags off, which is what every account had. +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + ALTER TABLE "users" DROP COLUMN IF EXISTS "preview_features_json"; +END +$migration$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6ef090a1d..b20f8c070 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -387,11 +387,6 @@ model User { // copies a stored browser value up once when the server has none for that list. // Shape: { "version": 1, "labs": { "view": "list", "order": [""], "sortDir": "manual" }, ... } moduleListLayoutJson Json? @map("module_list_layout_json") - /// Per-user opt-in to preview features, one boolean per flag, all off by - /// default (`src/lib/settings/preview-features.ts`). Read and written - /// through `/api/settings/preview-features` and carried by the backup. - /// @pending-consumer(topics preview) the `topics` flag is stored, but no surface reads it and the Settings > System switch is unlisted until the topic overview pages ship. - previewFeaturesJson Json? @map("preview_features_json") /// Optional activity goals the person set for themselves: workouts, active /// minutes and steps per week, steps per month. Null or an absent key = no /// goal. Parsed by `src/lib/validations/activity-goals.ts`, written through diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index e8e67147d..3cb450621 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -2163,8 +2163,6 @@ const ACTOR_ROUTES: Record = { "The UI language belongs to the person reading the screen, never to the record on it. Under a record scope this would transplant a delegate's choice onto the owner's row and send the owner's cron mail in a language they may not read. It has to keep working rather than merely refuse safely: the switcher's mount-time backfill fires on every page load, including the shared dashboard.", "app/api/settings/module-list-layout/route.ts": "How the person's own labs, illness and checkups lists are laid out: card or list view, manual order, sort. It reads and writes the caller's own row only. Inside another record the ids of a saved order simply match nothing and the list keeps the server order, so serving the person's own choice there reveals nothing of the owner's and grants nothing; refusing it under a switch broke the list controls in exactly the context they are used in.", - "app/api/settings/preview-features/route.ts": - "The person's own opt-in to preview features. A preview entry is chrome of the person reading the screen, never a property of the record on it; it reads and writes the caller's own row and nothing of the owner's.", "app/api/feature-flags/route.ts": "Reads the `AppSettings` singleton and no user row at all, so there is no record for a switch to substitute. It answers about the deployment, which for this purpose is the caller's side of the request; the shell gates the Coach launcher and the assistant surfaces on it, so a 403 here is a piece of chrome deciding it does not exist.", }; @@ -2273,8 +2271,11 @@ const ACTOR_ROUTES: Record = { * side). The activity goals route does not: setting or changing goals stays * owner-only, and a delegate sees the goals only as the calendar reports them. * 270 -> 273. + * + * v1.46 -- the preview-feature switch is removed with its column; the topic + * pages it was kept for were dropped. 273 -> 272. */ -const FROZEN_ENTRY_COUNT = 273; +const FROZEN_ENTRY_COUNT = 272; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/settings/preview-features/route.ts b/src/app/api/settings/preview-features/route.ts deleted file mode 100644 index 8a6859f9a..000000000 --- a/src/app/api/settings/preview-features/route.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * GET / PATCH the caller's preview-feature opt-ins. - * - * GET answers every flag, off unless the caller switched it on. PATCH sets - * the flags the body names and keeps the rest; unknown flags are refused - * with the multi-issue 422 so a typo cannot silently do nothing. The blob - * lives on its own `User` column (`preview_features_json`). - * - * The opt-ins are the caller's own, so this is an actor surface - * (`requireActorAuth`): a browser acting for another record keeps reaching - * the person's own switch. The PATCH reads and writes under a row lock, so two - * flags switched at once both stay switched. - */ -import { apiHandler, requireActorAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; -import { parseJsonBody } from "@/lib/api/body"; -import { prisma, toJson } from "@/lib/db"; -import { annotate } from "@/lib/logging/context"; -import { - PREVIEW_FEATURE_KEYS, - resolvePreviewFeatures, - type PreviewFeatures, -} from "@/lib/settings/preview-features"; -import { previewFeaturesPatchSchema } from "@/lib/validations/settings-prefs"; - -export const dynamic = "force-dynamic"; - -async function readStored( - userId: string, - db: Pick = prisma, -): Promise { - const row = await db.user.findUnique({ - where: { id: userId }, - select: { previewFeaturesJson: true }, - }); - return row?.previewFeaturesJson; -} - -function enabledFlags(features: PreviewFeatures): string[] { - return PREVIEW_FEATURE_KEYS.filter((key) => features[key]); -} - -export const GET = apiHandler(async () => { - const { user } = await requireActorAuth(); - const features = resolvePreviewFeatures(await readStored(user.id)); - annotate({ - action: { name: "settings.preview-features.read" }, - meta: { enabled: enabledFlags(features) }, - }); - return apiSuccess(features); -}); - -export const PATCH = apiHandler(async (request: Request) => { - const { user } = await requireActorAuth(); - - const parsed = await parseJsonBody(request, previewFeaturesPatchSchema, { - maxBytes: 1024, - action: "settings.preview-features.validation-failed", - }); - if (!parsed.ok) return parsed.error; - - const next = await prisma.$transaction(async (tx) => { - await tx.$queryRaw`SELECT 1 FROM "users" WHERE "id" = ${user.id} FOR UPDATE`; - const current = resolvePreviewFeatures(await readStored(user.id, tx)); - const merged: PreviewFeatures = { - topics: parsed.data.topics ?? current.topics, - }; - await tx.user.update({ - where: { id: user.id }, - data: { previewFeaturesJson: toJson(merged) }, - }); - return merged; - }); - - annotate({ - action: { name: "settings.preview-features.update" }, - meta: { enabled: enabledFlags(next) }, - }); - return apiSuccess(next); -}); diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 316c278d5..92cead915 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -516,8 +516,6 @@ export const USER_KEPT_FIELDS: Readonly> = { oidcIssuer: "a sign-in credential — the external identity binding", oidcSub: "a sign-in credential — the external identity binding", createdAt: "when the account was created; the account is preserved", - previewFeaturesJson: - "which preview features the person opted into — how the interface is shown, not record content", updatedAt: "row bookkeeping maintained by Prisma", managedProfileAt: "whether this record is one somebody else runs — account shape, not record content. Clearing it would strip the guardian's reach from a record that has no credentials of its own, leaving an account nobody can open, administer or delete. Emptying a record is not emancipating the person in it.", diff --git a/src/lib/export/__tests__/account-settings-backup.test.ts b/src/lib/export/__tests__/account-settings-backup.test.ts index 4784869d2..27bf42b17 100644 --- a/src/lib/export/__tests__/account-settings-backup.test.ts +++ b/src/lib/export/__tests__/account-settings-backup.test.ts @@ -111,6 +111,21 @@ describe("admitAccountSettings", () => { expect(admitAccountSettings({}, ctx)).toEqual({ data: {}, refused: [] }); }); + it("accepts and ignores the retired preview-feature opt-ins of an older file", async () => { + // Written by v1.44 and v1.45, whose column v1.46 dropped. The file must + // still restore, so the field is neither refused nor written. + const { accountSettingsBackupSchema } = + await import("@/lib/export/account-settings-backup-schema"); + const entry = accountSettingsBackupSchema.parse({ + timezone: "Europe/Berlin", + previewFeaturesJson: { topics: true }, + }); + expect(admitAccountSettings(entry, ctx)).toEqual({ + data: { timezone: "Europe/Berlin" }, + refused: [], + }); + }); + it("refuses a language, unit or time zone this release does not know", () => { const { data, refused } = admitAccountSettings( { diff --git a/src/lib/export/account-settings-backup-schema.ts b/src/lib/export/account-settings-backup-schema.ts index ea418f938..c8d2269a0 100644 --- a/src/lib/export/account-settings-backup-schema.ts +++ b/src/lib/export/account-settings-backup-schema.ts @@ -66,6 +66,9 @@ export const accountSettingsBackupSchema = z moodTagLayoutJson: z.unknown().optional(), documentsLayoutJson: z.unknown().optional(), moduleListLayoutJson: z.unknown().optional(), + // Retired in v1.46.0 with its column (the preview-feature opt-ins). A file + // written by v1.44 or v1.45 still carries it; it is accepted and ignored, + // because no codec reads it, so such a file restores everything else. previewFeaturesJson: z.unknown().optional(), activityGoalsJson: z.unknown().optional(), reportSelectionJson: z.unknown().optional(), diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts index 235827f93..3a2490a99 100644 --- a/src/lib/export/account-settings-backup.ts +++ b/src/lib/export/account-settings-backup.ts @@ -539,7 +539,6 @@ const ACCOUNT_SETTING_CODECS = { moodTagLayoutJson: json("moodTagLayoutJson"), documentsLayoutJson: json("documentsLayoutJson"), moduleListLayoutJson: json("moduleListLayoutJson"), - previewFeaturesJson: json("previewFeaturesJson"), activityGoalsJson: activityGoals, reportSelectionJson: json("reportSelectionJson"), globalExcludedInjectionSites: excludedSites, diff --git a/src/lib/http/retired-routes.ts b/src/lib/http/retired-routes.ts index 8ed40ff6d..22580e55c 100644 --- a/src/lib/http/retired-routes.ts +++ b/src/lib/http/retired-routes.ts @@ -160,6 +160,14 @@ export const RETIRED_ROUTES: readonly RetiredRoute[] = [ "Research Mode was an opt-in for the estimated drug-level curve. The chart had stopped consulting the flag several releases earlier and painted for every account regardless, so the switch governed nothing. The curve is simply part of the medication page now and needs no preference read.", methods: ["GET", "POST", "DELETE"], }, + { + path: "/api/settings/preview-features", + removedIn: "1.46.0", + replacedBy: null, + reason: + "Stored per-account opt-ins to preview features, of which there was one, the topic overview pages. Those pages were dropped before they shipped, so the switch governed nothing; the route and its column are gone, and no preview is gated per account.", + methods: ["GET", "PATCH"], + }, ]; const BY_PATH: ReadonlyMap = new Map( diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 5b1ac7c2b..2a269fcb5 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -27,10 +27,7 @@ import { MODULE_LIST_VIEWS, MODULE_SORT_DIRS, } from "@/lib/settings/module-list-layout"; -import { - moduleListLayoutPutSchema, - previewFeaturesPatchSchema, -} from "@/lib/validations/settings-prefs"; +import { moduleListLayoutPutSchema } from "@/lib/validations/settings-prefs"; const moduleListPrefsEntry = z .object({ @@ -72,20 +69,6 @@ const moduleListLayoutSchema = z "Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render a list ignores it.", }); -const previewFeaturesSchema = z - .object({ - topics: z - .boolean() - .describe( - "Topic overview pages above the single-metric Insights pages. Off by default.", - ), - }) - .meta({ - id: "PreviewFeatures", - description: - "Per-user opt-in to preview features, one boolean per flag, every flag off by default.", - }); - // v1.16.11 — the one threshold read every dose-status consumer makes // (cards, table, take-all-due derivation). `lateMinutes` / // `missedMinutes` are the operator-level singleton; the low-stock @@ -970,57 +953,6 @@ export const settingsPaths: NonNullable = { }, }, }, - "/api/settings/preview-features": { - get: { - tags: ["Meta"], - summary: "Get the preview-feature opt-ins", - description: "Every flag, off unless the caller switched it on.", - responses: { - "200": { - description: "The opt-ins.", - content: { - "application/json": { - schema: dataEnvelope( - previewFeaturesSchema, - "PreviewFeaturesEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - patch: { - tags: ["Meta"], - summary: "Switch preview features on or off", - description: - "Sets the flags the body names and keeps the rest. An unknown flag or an empty body is refused with 422.", - requestBody: { - required: true, - content: { - "application/json": { - schema: previewFeaturesPatchSchema.meta({ - id: "PreviewFeaturesPatchBody", - }), - }, - }, - }, - responses: { - "200": { - description: "The opt-ins as stored.", - content: { - "application/json": { - schema: dataEnvelope( - previewFeaturesSchema, - "PreviewFeaturesSavedEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, "/api/settings/privacy-summary": { get: { tags: ["Meta"], diff --git a/src/lib/settings/__tests__/preview-features.test.ts b/src/lib/settings/__tests__/preview-features.test.ts deleted file mode 100644 index df5251b6d..000000000 --- a/src/lib/settings/__tests__/preview-features.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - DEFAULT_PREVIEW_FEATURES, - resolvePreviewFeatures, -} from "../preview-features"; - -describe("resolvePreviewFeatures", () => { - it("is all off by default", () => { - expect(DEFAULT_PREVIEW_FEATURES).toEqual({ topics: false }); - expect(resolvePreviewFeatures(null)).toEqual({ topics: false }); - }); - - it("reads a flag only on an explicit true", () => { - expect(resolvePreviewFeatures({ topics: true })).toEqual({ topics: true }); - expect(resolvePreviewFeatures({ topics: "true" })).toEqual({ - topics: false, - }); - }); - - it("drops unknown keys", () => { - expect(resolvePreviewFeatures({ topics: true, retired: true })).toEqual({ - topics: true, - }); - }); -}); diff --git a/src/lib/settings/preview-features.ts b/src/lib/settings/preview-features.ts deleted file mode 100644 index 514b55fd1..000000000 --- a/src/lib/settings/preview-features.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Per-user opt-in to preview features, persisted in `User.previewFeaturesJson`. - * - * One boolean per flag, every flag off by default. A preview feature is - * something finished enough to try and not yet the default; switching one on - * adds it beside what is there and never replaces an existing page. - * - * Flags: - * - `topics`: topic overview pages above the single-metric Insights pages. - * Stored, served by `/api/settings/preview-features` and carried by the - * backup. The Settings > System switch is not listed until the topic pages - * ship, because nothing reads the flag yet. - * - * Unknown keys in a stored blob are dropped on read, so a flag retired later - * disappears cleanly and a malformed blob reads as everything off. - */ - -export const PREVIEW_FEATURE_KEYS = ["topics"] as const; -export type PreviewFeatureKey = (typeof PREVIEW_FEATURE_KEYS)[number]; - -export type PreviewFeatures = Record; - -export const DEFAULT_PREVIEW_FEATURES: PreviewFeatures = { topics: false }; - -export function resolvePreviewFeatures(raw: unknown): PreviewFeatures { - const out: PreviewFeatures = { ...DEFAULT_PREVIEW_FEATURES }; - if (raw === null || raw === undefined || typeof raw !== "object") return out; - const blob = raw as Record; - for (const key of PREVIEW_FEATURE_KEYS) { - if (blob[key] === true) out[key] = true; - } - return out; -} diff --git a/src/lib/settings/section-slugs.ts b/src/lib/settings/section-slugs.ts index b6f69ed40..843203284 100644 --- a/src/lib/settings/section-slugs.ts +++ b/src/lib/settings/section-slugs.ts @@ -114,10 +114,6 @@ export const SETTINGS_SECTION_SLUGS = [ // pieces into one coherent privacy pane. Sits next to `advanced` (the // destructive-action home) and before `about`. "privacy", - // v1.44 — "Preview features" (per-user opt-ins, Settings > System) is not - // listed until the first preview it switches, the topic pages, ships: a - // switch that changes nothing is a promise the page cannot keep. The stored - // flag, `/api/settings/preview-features` and its backup round-trip stay. "about", ] as const; diff --git a/src/lib/validations/settings-prefs.ts b/src/lib/validations/settings-prefs.ts index c3cc82112..26ab7745f 100644 --- a/src/lib/validations/settings-prefs.ts +++ b/src/lib/validations/settings-prefs.ts @@ -1,8 +1,8 @@ /** * Request schemas for the per-user Settings preferences that are not part of * the account payload: the list presentation of the labs, illness and - * checkups lists, and the preview-feature opt-ins. Shared by the routes and - * the OpenAPI registry so the wire contract has one source. + * checkups lists. Shared by the route and the OpenAPI registry so the wire + * contract has one source. */ import { z } from "zod/v4"; @@ -26,12 +26,3 @@ export const moduleListLayoutPutSchema = z sortDir: z.enum(MODULE_SORT_DIRS).optional(), }) .strict(); - -export const previewFeaturesPatchSchema = z - .object({ - topics: z.boolean().optional(), - }) - .strict() - .refine((body) => Object.keys(body).length > 0, { - message: "Name at least one preview feature.", - }); diff --git a/tests/durations/unit.json b/tests/durations/unit.json index 58db3c2d0..f2898cc5f 100644 --- a/tests/durations/unit.json +++ b/tests/durations/unit.json @@ -2660,7 +2660,6 @@ "src/lib/search/__tests__/match.test.ts": 0, "src/lib/search/__tests__/search-records.test.ts": 0, "src/lib/settings/__tests__/module-list-layout.test.ts": 0, - "src/lib/settings/__tests__/preview-features.test.ts": 0, "src/lib/sharing/__tests__/account-access-parse.test.ts": 0, "src/lib/sharing/__tests__/account-access.test.ts": 0, "src/lib/sharing/__tests__/account-label.test.ts": 0, diff --git a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json index c741d2b27..9980c3b21 100644 --- a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json @@ -124,9 +124,6 @@ "moduleListLayoutJson": { "roundTrip": "rt-User-moduleListLayoutJson" }, - "previewFeaturesJson": { - "roundTrip": "rt-User-previewFeaturesJson" - }, "activityGoalsJson": { "stepsPerMonth": 250000, "workoutsPerWeek": 4 @@ -2566,9 +2563,6 @@ "moduleListLayoutJson": { "roundTrip": "rt-User-moduleListLayoutJson" }, - "previewFeaturesJson": { - "roundTrip": "rt-User-previewFeaturesJson" - }, "activityGoalsJson": { "stepsPerMonth": 250000, "workoutsPerWeek": 4 diff --git a/tests/integration/__golden__/backup-parity/export-portable.json b/tests/integration/__golden__/backup-parity/export-portable.json index 436afeeb9..5b89d119e 100644 --- a/tests/integration/__golden__/backup-parity/export-portable.json +++ b/tests/integration/__golden__/backup-parity/export-portable.json @@ -63,9 +63,6 @@ "moduleListLayoutJson": { "roundTrip": "rt-User-moduleListLayoutJson" }, - "previewFeaturesJson": { - "roundTrip": "rt-User-previewFeaturesJson" - }, "activityGoalsJson": { "stepsPerMonth": 250000, "workoutsPerWeek": 4 @@ -1725,9 +1722,6 @@ "moduleListLayoutJson": { "roundTrip": "rt-User-moduleListLayoutJson" }, - "previewFeaturesJson": { - "roundTrip": "rt-User-previewFeaturesJson" - }, "activityGoalsJson": { "stepsPerMonth": 250000, "workoutsPerWeek": 4 diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json index 77fa33c88..c9d652853 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json @@ -332,7 +332,7 @@ "symptomDefinitions": 2, "lifeEvents": 3 }, - "accountSettingsApplied": 62, + "accountSettingsApplied": 61, "skipped": { "catalogueKeys": [], "links": 0 diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json index a8d078b9b..01506ddb6 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json @@ -332,7 +332,7 @@ "symptomDefinitions": 0, "lifeEvents": 0 }, - "accountSettingsApplied": 62, + "accountSettingsApplied": 61, "skipped": { "catalogueKeys": [], "links": 0 @@ -2759,9 +2759,6 @@ "moduleListLayoutJson": { "roundTrip": "rt-User-moduleListLayoutJson" }, - "previewFeaturesJson": { - "roundTrip": "rt-User-previewFeaturesJson" - }, "activityGoalsJson": { "stepsPerMonth": 250000, "workoutsPerWeek": 4 @@ -3029,9 +3026,6 @@ "moduleListLayoutJson": { "roundTrip": "rt-User-moduleListLayoutJson" }, - "previewFeaturesJson": { - "roundTrip": "rt-User-previewFeaturesJson" - }, "activityGoalsJson": { "stepsPerMonth": 250000, "workoutsPerWeek": 4 diff --git a/tests/integration/__golden__/backup-parity/restore-portable.json b/tests/integration/__golden__/backup-parity/restore-portable.json index 68ae26e99..815f639ac 100644 --- a/tests/integration/__golden__/backup-parity/restore-portable.json +++ b/tests/integration/__golden__/backup-parity/restore-portable.json @@ -378,7 +378,7 @@ "symptomDefinitions": 0, "lifeEvents": 0 }, - "accountSettingsApplied": 62, + "accountSettingsApplied": 61, "skipped": { "catalogueKeys": [ { @@ -2254,9 +2254,6 @@ "moduleListLayoutJson": { "roundTrip": "rt-User-moduleListLayoutJson" }, - "previewFeaturesJson": { - "roundTrip": "rt-User-previewFeaturesJson" - }, "activityGoalsJson": { "stepsPerMonth": 250000, "workoutsPerWeek": 4 @@ -2463,9 +2460,6 @@ "moduleListLayoutJson": { "roundTrip": "rt-User-moduleListLayoutJson" }, - "previewFeaturesJson": { - "roundTrip": "rt-User-previewFeaturesJson" - }, "activityGoalsJson": { "stepsPerMonth": 250000, "workoutsPerWeek": 4 diff --git a/tests/integration/__golden__/backup-parity/restore-skips.json b/tests/integration/__golden__/backup-parity/restore-skips.json index 1dec697bd..8879bf3f8 100644 --- a/tests/integration/__golden__/backup-parity/restore-skips.json +++ b/tests/integration/__golden__/backup-parity/restore-skips.json @@ -894,7 +894,6 @@ "documentsLayoutJson": null, "moodTagLayoutJson": null, "moduleListLayoutJson": null, - "previewFeaturesJson": null, "activityGoalsJson": null, "glucoseUnit": null, "hasDiabetes": false, diff --git a/tests/integration/__golden__/backup-parity/restore-version-1.json b/tests/integration/__golden__/backup-parity/restore-version-1.json index 68895abe8..ec1685ce6 100644 --- a/tests/integration/__golden__/backup-parity/restore-version-1.json +++ b/tests/integration/__golden__/backup-parity/restore-version-1.json @@ -1027,7 +1027,6 @@ "documentsLayoutJson": null, "moodTagLayoutJson": null, "moduleListLayoutJson": null, - "previewFeaturesJson": null, "activityGoalsJson": null, "glucoseUnit": null, "hasDiabetes": false, @@ -1247,7 +1246,6 @@ "moodTagLayoutJson": null, "documentsLayoutJson": null, "moduleListLayoutJson": null, - "previewFeaturesJson": null, "activityGoalsJson": null, "reportSelectionJson": null, "globalExcludedInjectionSites": [], diff --git a/tests/integration/__golden__/backup-parity/seeded-account.json b/tests/integration/__golden__/backup-parity/seeded-account.json index cd3618a3f..7758138c3 100644 --- a/tests/integration/__golden__/backup-parity/seeded-account.json +++ b/tests/integration/__golden__/backup-parity/seeded-account.json @@ -2374,9 +2374,6 @@ "moduleListLayoutJson": { "roundTrip": "rt-User-moduleListLayoutJson" }, - "previewFeaturesJson": { - "roundTrip": "rt-User-previewFeaturesJson" - }, "activityGoalsJson": { "stepsPerMonth": 250000, "workoutsPerWeek": 4 diff --git a/tests/integration/settings-prefs-routes.test.ts b/tests/integration/settings-prefs-routes.test.ts index 61aaa5a00..2a18cdf04 100644 --- a/tests/integration/settings-prefs-routes.test.ts +++ b/tests/integration/settings-prefs-routes.test.ts @@ -1,13 +1,10 @@ /** - * The two per-user Settings preferences that live outside the account - * payload, end to end against Postgres: - * - * - `/api/settings/module-list-layout`: defaults without a write, one list - * changed per PUT with preserve-when-absent fields and untouched neighbours, - * the multi-issue 422 for unknown values, and the column carrying only what - * was chosen. - * - `/api/settings/preview-features`: all off by default, PATCH keeps the - * flags it does not name, an unknown flag or an empty body is refused. + * The per-user list layout, a Settings preference that lives outside the + * account payload, end to end against Postgres: + * `/api/settings/module-list-layout` answers defaults without a write, + * changes one list per PUT with preserve-when-absent fields and untouched + * neighbours, refuses unknown values with the multi-issue 422, and stores + * only what was chosen. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -173,51 +170,8 @@ describe("/api/settings/module-list-layout — integration", () => { }); }); -describe("/api/settings/preview-features — integration", () => { - const URL = "http://localhost/api/settings/preview-features"; - - it("is all off by default and keeps a flag once switched on", async () => { - const { userId } = await seedUser(); - const { GET, PATCH } = - await import("@/app/api/settings/preview-features/route"); - - const before = await (GET as Handler)(new Request(URL)); - expect(((await before.json()) as { data: unknown }).data).toEqual({ - topics: false, - }); - - const on = await (PATCH as Handler)(json("PATCH", URL, { topics: true })); - expect(on.status).toBe(200); - expect(((await on.json()) as { data: unknown }).data).toEqual({ - topics: true, - }); - - const after = await (GET as Handler)(new Request(URL)); - expect(((await after.json()) as { data: unknown }).data).toEqual({ - topics: true, - }); - const row = await getPrismaClient().user.findUnique({ - where: { id: userId }, - select: { previewFeaturesJson: true }, - }); - expect(row?.previewFeaturesJson).toEqual({ topics: true }); - }); - - it("refuses an unknown flag and an empty body", async () => { - await seedUser(); - const { PATCH } = await import("@/app/api/settings/preview-features/route"); - const unknown = await (PATCH as Handler)( - json("PATCH", URL, { topics: true, darkMagic: true }), - ); - expect(unknown.status).toBe(422); - const empty = await (PATCH as Handler)(json("PATCH", URL, {})); - expect(empty.status).toBe(422); - }); -}); - describe("settings presentation follows the person, also while acting for a record", () => { const LAYOUT = "http://localhost/api/settings/module-list-layout"; - const PREVIEW = "http://localhost/api/settings/preview-features"; async function actingForAnotherRecord() { const prisma = getPrismaClient(); @@ -251,10 +205,9 @@ describe("settings presentation follows the person, also while acting for a reco return { ownerId: owner.id, actorId: actor.id }; } - it("reads and saves the actor's own layout and preview switch", async () => { + it("reads and saves the actor's own layout", async () => { const { ownerId, actorId } = await actingForAnotherRecord(); const layout = await import("@/app/api/settings/module-list-layout/route"); - const preview = await import("@/app/api/settings/preview-features/route"); expect((await (layout.GET as Handler)(new Request(LAYOUT))).status).toBe( 200, @@ -263,20 +216,12 @@ describe("settings presentation follows the person, also while acting for a reco json("PUT", LAYOUT, { version: 1, list: "labs", view: "cards" }), ); expect(put.status).toBe(200); - expect((await (preview.GET as Handler)(new Request(PREVIEW))).status).toBe( - 200, - ); - const patch = await (preview.PATCH as Handler)( - json("PATCH", PREVIEW, { topics: true }), - ); - expect(patch.status).toBe(200); const rows = await getPrismaClient().user.findMany({ where: { id: { in: [ownerId, actorId] } }, select: { id: true, moduleListLayoutJson: true, - previewFeaturesJson: true, }, }); const actor = rows.find((r) => r.id === actorId)!; @@ -284,9 +229,7 @@ describe("settings presentation follows the person, also while acting for a reco expect(actor.moduleListLayoutJson).toMatchObject({ labs: { view: "cards" }, }); - expect(actor.previewFeaturesJson).toEqual({ topics: true }); expect(owner.moduleListLayoutJson).toBeNull(); - expect(owner.previewFeaturesJson).toBeNull(); }); }); diff --git a/tests/integration/settings-round-trip.test.ts b/tests/integration/settings-round-trip.test.ts index 8460aa4da..0045ba1a8 100644 --- a/tests/integration/settings-round-trip.test.ts +++ b/tests/integration/settings-round-trip.test.ts @@ -69,7 +69,6 @@ import * as medicationLayout from "@/app/api/medications/layout/route"; import * as moodTagLayout from "@/app/api/mood/tags/layout/route"; import * as documentsLayout from "@/app/api/documents/inbound/layout/route"; import * as moduleListLayout from "@/app/api/settings/module-list-layout/route"; -import * as previewFeatures from "@/app/api/settings/preview-features/route"; import * as activityGoals from "@/app/api/activity/goals/route"; import * as healthKit from "@/app/api/integrations/healthkit/route"; import { POST as exportHealthRecord } from "@/app/api/export/health-record/route"; @@ -888,21 +887,6 @@ function entries(user: Pick): Entry[] { }, expected: ["list", "alphaAsc"], }, - { - columns: ["previewFeaturesJson"], - write: { - handler: previewFeatures.PATCH, - method: "PATCH", - route: "/api/settings/preview-features", - body: { topics: true }, - }, - read: get( - previewFeatures.GET, - "/api/settings/preview-features", - "topics", - ), - expected: true, - }, { columns: ["activityGoalsJson"], write: { From cc284c430725b81f5ba8d7b25394da84c507613d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 00:51:32 +0200 Subject: [PATCH 6/8] fix(jobs): log a failed preload report instead of swallowing it The sweep keeps going when reporting a load failure itself throws, and now says so on stderr; the error-level guard refuses a silent catch in src/lib/jobs. The preload test reads the worker source through readSource with anchors. --- .../registry/__tests__/preload-handlers.test.ts | 13 ++++++------- src/lib/jobs/registry/register.ts | 9 +++++++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/lib/jobs/registry/__tests__/preload-handlers.test.ts b/src/lib/jobs/registry/__tests__/preload-handlers.test.ts index 6f73883e9..cbb8a425a 100644 --- a/src/lib/jobs/registry/__tests__/preload-handlers.test.ts +++ b/src/lib/jobs/registry/__tests__/preload-handlers.test.ts @@ -7,9 +7,6 @@ * with nobody told at deploy time. The worker now loads every handler once in * the background after binding and reports each failure by queue name. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Job, PgBoss } from "pg-boss"; @@ -47,6 +44,8 @@ import { emitSignal } from "@/lib/logging/signal"; import { reportWorkerError } from "@/lib/jobs/report-worker-error"; import { reportHandlerLoadFailure } from "@/lib/jobs/run-job"; +import { readSource } from "@/__tests__/helpers/source-files"; + import type { JobDefinition, JobGroup } from "../define-job"; import { preloadHandlers, @@ -156,10 +155,10 @@ describe("preloading the job handlers at worker boot", () => { }); it("is started by the worker after binding, without holding up the boot", () => { - const source = readFileSync( - join(process.cwd(), "src/lib/jobs/reminder-worker.ts"), - "utf8", - ); + const source = readSource("src/lib/jobs/reminder-worker.ts", [ + "registry.registerAllJobGroups(", + "void registry.preloadHandlers(bound)", + ]); const register = source.indexOf("registry.registerAllJobGroups("); const preload = source.indexOf("void registry.preloadHandlers(bound)"); expect(register).toBeGreaterThan(-1); diff --git a/src/lib/jobs/registry/register.ts b/src/lib/jobs/registry/register.ts index c11052fdb..f53205381 100644 --- a/src/lib/jobs/registry/register.ts +++ b/src/lib/jobs/registry/register.ts @@ -208,8 +208,13 @@ export async function preloadHandlers( failed.push(queue); try { await report(queue, error); - } catch { - // The report is the last line of defence; it must not stop the sweep. + } catch (reportError) { + // The report failing must not stop the sweep; say so on stderr. + workerLog( + "error", + `[registry] reporting the load failure of ${queue} failed`, + reportError, + ); } } } From 0f9ee08a48bd8f63cf6b82c23b6363e5036df8e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 00:51:32 +0200 Subject: [PATCH 7/8] test(sync): give the module-gate double the mirror walk's token lookup The walk now asks the token table first; the route test's Prisma double answers it with no switched token. --- src/app/api/sync/changes/__tests__/module-gate.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/api/sync/changes/__tests__/module-gate.test.ts b/src/app/api/sync/changes/__tests__/module-gate.test.ts index e75cd7145..9640f30c1 100644 --- a/src/app/api/sync/changes/__tests__/module-gate.test.ts +++ b/src/app/api/sync/changes/__tests__/module-gate.test.ts @@ -22,6 +22,8 @@ vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() })); vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany: vi.fn() }, + // The mirror walk's token lookup: no token switched, so no walk. + apiToken: { findMany: vi.fn(async () => []) }, moodEntry: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, cycleDayLog: { findMany: vi.fn() }, From 0671cb6e0a9d856ac39de8da2332708a6f65c588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 00:51:33 +0200 Subject: [PATCH 8/8] chore: hold the size and version-stamp ratchets Tighten the warm-up comments in the summaries slice and the comprehensive aggregator so neither file grows past its line baseline, drop a release opener from the sharing-surface note, and record the version stamp that left the settings section list. --- src/__tests__/baselines/version-stamp-comments.json | 2 +- src/__tests__/sharing-surface-guard.test.ts | 4 ++-- src/lib/analytics/summaries-slice.ts | 12 +++++------- src/lib/insights/comprehensive-aggregator.ts | 12 +++++------- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index e3c126303..eb3e6f931 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -2752,7 +2752,7 @@ "lib/rollups/mood-rollups.ts": 4, "lib/rollups/tiered-context.ts": 6, "lib/score-config/rows.ts": 1, - "lib/settings/section-slugs.ts": 20, + "lib/settings/section-slugs.ts": 19, "lib/sharing/__tests__/account-access-parse.test.ts": 3, "lib/sharing/__tests__/account-access.test.ts": 2, "lib/sharing/__tests__/account-label.test.ts": 1, diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 3cb450621..e57139b63 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -2272,8 +2272,8 @@ const ACTOR_ROUTES: Record = { * owner-only, and a delegate sees the goals only as the calendar reports them. * 270 -> 273. * - * v1.46 -- the preview-feature switch is removed with its column; the topic - * pages it was kept for were dropped. 273 -> 272. + * The preview-feature switch went with its column in the next release; the + * topic pages it was kept for were dropped. 273 -> 272. */ const FROZEN_ENTRY_COUNT = 272; diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index f7d481893..3d506ac52 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -279,13 +279,11 @@ export async function computeSummariesSlice( // One clock for every window of the slice, JS and SQL alike (see // `request-clock-sql.ts`). const now = new Date(); - // v1.4.37.1 — fire-and-forget. See `src/app/api/analytics/route.ts` - // for the full rationale: awaiting this on the read path can stall - // the event loop for tens of seconds on tenants with large iOS step - // sample sets that keep the 90-day window slightly stale. The downstream - // coverage probe falls back to live SQL for uncovered types, so - // correctness is preserved; the read just doesn't block waiting - // for the background refresh. + // v1.4.37.1 — fire-and-forget (see `src/app/api/analytics/route.ts`): + // awaiting it can stall the event loop for tens of seconds on accounts + // whose iOS steps keep the 90-day window stale, and the coverage probe + // falls back to live SQL meanwhile. Registered so the suite's settle hook + // and the statement counter can await it. trackBackgroundTask(runDetached(() => ensureUserRollupsFresh(userId))); // v1.4.36 — per-type coverage probe replaces the legacy global diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts index 5f46f0cc0..6dcef4f28 100644 --- a/src/lib/insights/comprehensive-aggregator.ts +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -256,13 +256,11 @@ export async function buildComprehensiveAggregate( // bp-raw, first-at, coverage-probe) without re-instrumenting. const timings: Record = {}; - // v1.4.37.1 — fire-and-forget. See `src/app/api/analytics/route.ts` - // for the full rationale: awaiting this on the read path can stall - // the event loop for 30–60 s on power-user accounts whose iOS step - // samples keep the 90-day window slightly stale. The downstream - // coverage probe falls back to live SQL for uncovered types, so - // correctness is preserved; the read just doesn't block waiting - // for the background refresh. + // v1.4.37.1 — fire-and-forget (see `src/app/api/analytics/route.ts`): + // awaiting it can stall the event loop for 30–60 s on accounts whose iOS + // steps keep the 90-day window stale, and the coverage probe falls back to + // live SQL meanwhile. Registered so the suite's settle hook and the + // statement counter can await it. trackBackgroundTask(runDetached(() => ensureUserRollupsFresh(userId))); // v1.4.36 — per-type coverage probe. The legacy global COUNT